Made server.tsx work with ssh tunnels and server stats. Moved admin settings.

This commit is contained in:
LukeGus
2025-08-17 01:56:47 -05:00
parent 5445cb2b78
commit 981705e81d
28 changed files with 1494 additions and 304 deletions

View File

@@ -1,13 +1,14 @@
import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {TerminalView} from "@/ui/SSH/Terminal/TerminalView.tsx"
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"
import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx"
import {AppView} from "@/ui/Navigation/AppView.tsx"
// import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"
// import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx"
import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx"
import {TabProvider, useTabs} from "@/contexts/TabContext"
import axios from "axios"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({baseURL: apiBase});
@@ -90,6 +91,7 @@ function AppContent() {
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
console.log('Current tab:', currentTab);
console.log('Current tab data:', currentTabData);
@@ -164,7 +166,7 @@ function AppContent() {
overflow: "hidden",
}}
>
<TerminalView isTopbarOpen={isTopbarOpen} />
<AppView isTopbarOpen={isTopbarOpen} />
</div>
{/* Always render Homepage to keep it mounted */}
@@ -202,28 +204,23 @@ function AppContent() {
>
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
{/* Admin Settings tab */}
<div
className="h-screen w-full"
style={{
visibility: showAdmin ? "visible" : "hidden",
pointerEvents: showAdmin ? "auto" : "none",
height: showAdmin ? "100vh" : 0,
width: showAdmin ? "100%" : 0,
position: showAdmin ? "static" : "absolute",
overflow: "hidden",
}}
>
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
{/* Legacy views - keep for compatibility (exclude homepage to avoid duplicate mounts) */}
{mountedViews.has("ssh_manager") && (
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
</div>
)}
{mountedViews.has("terminal") && (
<div style={{display: view === "terminal" ? "block" : "none"}}>
<Terminal onSelectView={handleSelectView}/>
</div>
)}
{mountedViews.has("tunnel") && (
<div style={{display: view === "tunnel" ? "block" : "none"}}>
<SSHTunnel onSelectView={handleSelectView}/>
</div>
)}
{mountedViews.has("config_editor") && (
<div style={{display: view === "config_editor" ? "block" : "none"}}>
<ConfigEditor onSelectView={handleSelectView}/>
</div>
)}
{/* Legacy views removed; tab system controls main content */}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}

View File

@@ -0,0 +1,416 @@
import express from 'express';
import chalk from 'chalk';
import fetch from 'node-fetch';
import net from 'net';
import cors from 'cors';
import { Client, type ConnectConfig } from 'ssh2';
type HostRecord = {
id: number;
ip: string;
port: number;
username?: string;
authType?: 'password' | 'key' | string;
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
};
type HostStatus = 'online' | 'offline';
type StatusEntry = {
status: HostStatus;
lastChecked: string; // ISO string
};
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Fallback explicit CORS headers to cover any edge cases
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
app.use(express.json());
// Logger (customized for Server Stats)
const statsIconSymbol = '📡';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#22c55e')(`[${statsIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
// In-memory state of last known statuses
const hostStatuses: Map<number, StatusEntry> = new Map();
// Fetch all hosts from the database service (internal endpoint, no JWT)
async function fetchAllHosts(): Promise<HostRecord[]> {
const url = 'http://localhost:8081/ssh/db/host/internal';
try {
const resp = await fetch(url, {
headers: { 'x-internal-request': '1' }
});
if (!resp.ok) {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
id: Number(h.id),
ip: String(h.ip),
port: Number(h.port) || 22,
username: h.username,
authType: h.authType,
password: h.password ?? null,
key: h.key ?? null,
keyPassword: h.keyPassword ?? null,
keyType: h.keyType ?? null,
})).filter(h => !!h.id && !!h.ip && !!h.port);
return hosts;
} catch (err) {
logger.error('Failed to fetch hosts from database service', err);
return [];
}
}
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
const all = await fetchAllHosts();
return all.find(h => h.id === id);
}
function buildSshConfig(host: HostRecord): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
username: host.username || 'root',
readyTimeout: 10_000,
algorithms: {
// keep defaults minimal to avoid negotiation issues
}
} as ConnectConfig;
if (host.authType === 'password') {
(base as any).password = host.password || '';
} else if (host.authType === 'key') {
if (host.key) {
(base as any).privateKey = Buffer.from(host.key, 'utf8');
}
if (host.keyPassword) {
(base as any).passphrase = host.keyPassword;
}
}
return base;
}
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const client = new Client();
let settled = false;
const onError = (err: Error) => {
if (!settled) {
settled = true;
try { client.end(); } catch {}
reject(err);
}
};
client.on('ready', async () => {
try {
const result = await fn(client);
if (!settled) {
settled = true;
try { client.end(); } catch {}
resolve(result);
}
} catch (err: any) {
onError(err);
}
});
client.on('error', onError);
client.on('timeout', () => onError(new Error('SSH connection timeout')));
try {
client.connect(buildSshConfig(host));
} catch (err: any) {
onError(err);
}
});
}
function execCommand(client: Client, command: string): Promise<{ stdout: string; stderr: string; code: number | null; }> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = '';
let stderr = '';
let exitCode: number | null = null;
stream.on('close', (code: number | undefined) => {
exitCode = typeof code === 'number' ? code : null;
resolve({ stdout, stderr, code: exitCode });
}).on('data', (data: Buffer) => {
stdout += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => {
stderr += data.toString('utf8');
});
});
});
}
function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== 'cpu') return undefined;
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); // idle + iowait
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== 'number' || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: HostRecord): Promise<{
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
}> {
return withSshConnection(host, async (client) => {
// CPU
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const stat1 = await execCommand(client, 'cat /proc/stat');
await new Promise(r => setTimeout(r, 500));
const stat2 = await execCommand(client, 'cat /proc/stat');
const loadAvgOut = await execCommand(client, 'cat /proc/loadavg');
const coresOut = await execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo');
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number];
}
const coresNum = Number((coresOut.stdout || '').trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
}
// Memory
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, 'cat /proc/meminfo');
const lines = memInfo.stdout.split('\n');
const getVal = (key: string) => {
const line = lines.find(l => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null; // in kB
};
const totalKb = getVal('MemTotal:');
const availKb = getVal('MemAvailable:');
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
// Disk
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
try {
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
// Expected columns: Filesystem Size Used Avail Use% Mounted
const parts = line.split(/\s+/);
if (parts.length >= 6) {
totalHuman = parts[1] || null;
usedHuman = parts[2] || null;
const pctStr = (parts[4] || '').replace('%', '');
const pctNum = Number(pctStr);
diskPercent = Number.isFinite(pctNum) ? pctNum : null;
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
}
return {
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
memory: { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null },
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
};
});
}
function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const onDone = (result: boolean) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => onDone(true));
socket.once('timeout', () => onDone(false));
socket.once('error', () => onDone(false));
socket.connect(port, host);
});
}
async function pollStatusesOnce(): Promise<void> {
const hosts = await fetchAllHosts();
if (hosts.length === 0) {
logger.warn('No hosts retrieved for status polling');
return;
}
const now = new Date().toISOString();
const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000);
hostStatuses.set(h.id, { status: isOnline ? 'online' : 'offline', lastChecked: now });
return isOnline;
});
const results = await Promise.allSettled(checks);
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
const offlineCount = hosts.length - onlineCount;
}
app.get('/status', async (req, res) => {
// Return current cached statuses; if empty, trigger a poll
if (hostStatuses.size === 0) {
await pollStatusesOnce();
}
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) {
result[id] = entry;
}
res.json(result);
});
app.get('/status/:id', async (req, res) => {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({ error: 'Invalid id' });
}
if (!hostStatuses.has(id)) {
await pollStatusesOnce();
}
const entry = hostStatuses.get(id);
if (!entry) {
return res.status(404).json({ error: 'Host not found' });
}
res.json(entry);
});
app.post('/refresh', async (req, res) => {
await pollStatusesOnce();
res.json({ message: 'Refreshed' });
});
app.get('/metrics/:id', async (req, res) => {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({ error: 'Invalid id' });
}
try {
const host = await fetchHostById(id);
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
logger.error('Failed to collect metrics', err);
return res.json({
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString()
});
}
});
const PORT = 8085;
app.listen(PORT, async () => {
try {
await pollStatusesOnce();
} catch (err) {
logger.error('Initial poll failed', err);
}
});
// Background polling every minute
setInterval(() => {
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
}, 60_000);

View File

@@ -3,8 +3,9 @@
import './database/database.js'
import './ssh/ssh.js';
import './ssh_tunnel/ssh_tunnel.js';
import './config_editor/config_editor.js';
import './ssh/ssh_tunnel.js';
import './ssh/config_editor.js';
import './ssh/server-stats.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -34,7 +34,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
className
)}
{...props}
@@ -56,7 +56,7 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&

View File

@@ -1,8 +1,8 @@
import React, { createContext, useContext, useState, useRef, ReactNode } from 'react';
import React, { createContext, useContext, useState, useRef, type ReactNode } from 'react';
export interface Tab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server';
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
@@ -41,8 +41,9 @@ export function TabProvider({ children }: TabProviderProps) {
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
function computeUniqueTerminalTitle(desiredTitle: string | undefined): string {
const baseTitle = (desiredTitle || 'Terminal').trim();
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : 'Terminal';
const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -50,12 +51,12 @@ export function TabProvider({ children }: TabProviderProps) {
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach(t => {
if (t.type !== 'terminal' || !t.title) return;
if (t.type !== tabType || !t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(new RegExp(`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
@@ -71,7 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++;
const effectiveTitle = tabData.type === 'terminal' ? computeUniqueTerminalTitle(tabData.title) : (tabData.title || '');
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
id,

View File

@@ -0,0 +1,396 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
import axios from "axios";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({ baseURL: apiBase });
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): React.ReactElement {
const { state: sidebarState } = useSidebar();
// Registration toggle
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
// OIDC config
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
// Users/admins
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
// Preload OIDC config and users
API.get("/oidc-config", { headers: { Authorization: `Bearer ${jwt}` } })
.then(res => { if (res.data) setOidcConfig(res.data); })
.catch(() => {});
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load initial registration toggle status
React.useEffect(() => {
API.get("/registration-allowed")
.then(res => {
if (typeof res?.data?.allowed === 'boolean') {
setAllowRegistration(res.data.allowed);
}
})
.catch(() => {});
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
setUsersLoading(true);
try {
const response = await API.get("/list", { headers: { Authorization: `Bearer ${jwt}` } });
setUsers(response.data.users);
} finally {
setUsersLoading(false);
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch("/registration-allowed", { allowed: checked }, { headers: { Authorization: `Bearer ${jwt}` } });
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const required = ['client_id','client_secret','issuer_url','authorization_url','token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post("/oidc-config", oidcConfig, { headers: { Authorization: `Bearer ${jwt}` } });
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({ ...prev, [field]: value }));
};
const makeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
await API.post("/make-admin", { username: newAdminUsername.trim() }, { headers: { Authorization: `Bearer ${jwt}` } });
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt");
try {
await API.post("/remove-admin", { username }, { headers: { Authorization: `Bearer ${jwt}` } });
fetchUsers();
} catch {}
};
const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt");
try {
await API.delete("/delete-user", { headers: { Authorization: `Bearer ${jwt}` }, data: { username } });
fetchUsers();
} catch {}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
};
return (
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1>
</div>
<Separator className="p-0.25 w-full"/>
<div className="px-6 py-4 overflow-auto">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
General
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required />
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required />
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required />
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required />
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required />
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required />
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)}
</TableCell>
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required />
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(u => u.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell>
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default AdminSettings;

View File

@@ -138,7 +138,7 @@ export function Homepage({
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
Donate
</Button>
</div>
</div>

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from "react";
import {TerminalComponent} from "./TerminalComponent.tsx";
import {TerminalComponent} from "../SSH/Terminal/TerminalComponent.tsx";
import {Server as ServerView} from "@/ui/SSH/Server/Server.tsx";
import {useTabs} from "@/contexts/TabContext";
import {useTabs} from "@/contexts/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
@@ -12,7 +12,7 @@ interface TerminalViewProps {
isTopbarOpen?: boolean;
}
export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const { state: sidebarState } = useSidebar();
@@ -305,7 +305,7 @@ export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 15;
const bottomMarginPx = 8;
return (
<div

View File

@@ -1,9 +1,10 @@
import React from "react";
import React, { useEffect, useState } from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/contexts/TabContext";
import { getServerStatusById } from "@/ui/SSH/ssh-axios";
interface SSHHost {
id: number;
@@ -34,11 +35,36 @@ interface HostProps {
export function Host({ host }: HostProps): React.ReactElement {
const { addTab } = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
useEffect(() => {
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 60_000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [host.id]);
const handleTerminalClick = () => {
addTab({ type: 'terminal', title, hostConfig: host });
};
@@ -50,7 +76,7 @@ export function Host({ host }: HostProps): React.ReactElement {
return (
<div>
<div className="flex items-center gap-2">
<Status status={"online"} className="!bg-transparent !p-0.75 flex-shrink-0">
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
<p className="font-semibold flex-1 min-w-0 break-words text-sm">

View File

@@ -155,6 +155,16 @@ export function LeftSidebar({
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
const openAdminTab = () => {
if (isSplitScreenActive) return;
if (adminTab) {
setCurrentTab(adminTab.id);
return;
}
const id = addTab({ type: 'admin', title: 'Admin' } as any);
setCurrentTab(id);
};
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]);
@@ -253,6 +263,15 @@ export function LeftSidebar({
return () => clearInterval(interval);
}, [fetchHosts]);
// Immediate refresh when SSH hosts are changed elsewhere in the app
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -536,8 +555,8 @@ export function LeftSidebar({
{/* Error Display */}
{hostsError && (
<div className="px-4 pb-2">
<div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
<div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError}
</div>
</div>
@@ -589,9 +608,7 @@ export function LeftSidebar({
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => {
if (isAdmin) {
setAdminSheetOpen(true);
}
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
</DropdownMenuItem>
@@ -619,7 +636,7 @@ export function LeftSidebar({
</SidebarFooter>
{/* Admin Settings Sheet */}
{isAdmin && (
<Sheet open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
@@ -643,7 +660,7 @@ export function LeftSidebar({
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="h-4 w-4">
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
@@ -984,7 +1001,7 @@ export function LeftSidebar({
)}
{/* Delete Account Confirmation Sheet */}
<Sheet open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SheetContent side="left" className="w-[400px]">
<SheetHeader className="pb-0">
<SheetTitle>Delete Account</SheetTitle>

View File

@@ -92,5 +92,28 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
);
}
if (tabType === "admin") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || "Admin"}
</Button>
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
</ButtonGroup>
);
}
return null;
}

View File

@@ -31,6 +31,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
return (
<div>
@@ -53,12 +54,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isTerminal = tab.type === 'terminal';
const isServer = tab.type === 'server';
const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager') && isSplitScreenActive);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit;
return (
<Tab
@@ -67,10 +69,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
title={tab.title}
isActive={isActive}
onActivate={() => handleTabActivate(tab.id)}
onClose={isTerminal || isServer || isSshManager ? () => handleTabClose(tab.id) : undefined}
onClose={isTerminal || isServer || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
canSplit={isSplittable}
canClose={isTerminal || isServer || isSshManager}
canClose={isTerminal || isServer || isSshManager || isAdmin}
disableActivate={disableActivate}
disableSplit={disableSplit}
disableClose={disableClose}

View File

@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
if (onFormSubmit) {
onFormSubmit();
}
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
alert('Failed to save host. Please try again.');
}
@@ -809,7 +811,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port
(Local)</FormLabel>
(Source refers to the Current Connection Details in the General tab)</FormLabel>
<FormControl>
<Input
placeholder="22" {...sourcePortField} />

View File

@@ -75,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
try {
await deleteSSHHost(hostId);
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) {
alert('Failed to delete host');
}
@@ -115,6 +116,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
if (result.success > 0) {
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else {
alert(`Import failed: ${result.errors.join('\n')}`);
}

View File

@@ -1,5 +1,12 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import { Progress } from "@/components/ui/progress"
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx";
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/SSH/ssh-axios";
interface ServerProps {
hostConfig?: any;
@@ -11,10 +18,52 @@ interface ServerProps {
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
const { state: sidebarState } = useSidebar();
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
React.useEffect(() => {
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(hostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
const fetchMetrics = async () => {
if (!hostConfig?.id) return;
try {
const data = await getServerMetricsById(hostConfig.id);
if (!cancelled) setMetrics(data);
} catch {
if (!cancelled) setMetrics(null);
}
};
if (hostConfig?.id) {
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
fetchStatus();
fetchMetrics();
}, 10_000);
}
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [hostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
const bottomMarginPx = 16;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
@@ -33,10 +82,106 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex items-center justify-center">
<div className="text-sm opacity-70 text-center">
<div>{title || 'Server'}</div>
<div className="h-full w-full flex flex-col">
{/* Top Header */}
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<div className="flex items-center gap-4">
<h1 className="font-bold text-lg">
{hostConfig.folder} / {title}
</h1>
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
</div>
<div className="flex items-center">
<Button variant="outline">File Manager</Button>
</div>
</div>
<Separator className="p-0.25 w-full"/>
{/* Stats */}
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
{/* CPU */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
<Cpu/>
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const la = metrics?.cpu?.load;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
const laText = (la && la.length === 3)
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
: 'Avg: N/A';
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
})()}
</h1>
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} />
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Memory */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
<MemoryStick/>
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} />
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* HDD */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
<HardDrive/>
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A';
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} />
</div>
</div>
{/* SSH Tunnels */}
{(hostConfig?.tunnelConnections && hostConfig.tunnelConnections.length > 0) && (
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<SSHTunnel filterHostKey={(hostConfig?.name && hostConfig.name.trim() !== '') ? hostConfig.name : `${hostConfig?.username}@${hostConfig?.ip}`}/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
Have ideas for what should come next for server management? Share them on{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
);

View File

@@ -1,12 +1,7 @@
import React, {useState, useEffect, useCallback} from "react";
import {SSHTunnelSidebar} from "@/ui/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
@@ -49,31 +44,92 @@ interface TunnelStatus {
retryExhausted?: boolean;
}
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
interface SSHTunnelProps {
filterHostKey?: string;
}
export function SSHTunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
// Keep full list for endpoint lookups; keep a separate visible list for UI
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
const fetchHosts = useCallback(async () => {
try {
const hostsData = await getSSHHosts();
setHosts(hostsData);
} catch (err) {
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.sourcePort !== y.sourcePort ||
x.endpointPort !== y.endpointPort ||
x.endpointHost !== y.endpointHost ||
x.maxRetries !== y.maxRetries ||
x.retryInterval !== y.retryInterval ||
x.autoStart !== y.autoStart
) {
return true;
}
}
}, []);
return false;
};
const fetchHosts = useCallback(async () => {
const hostsData = await getSSHHosts();
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter(h => {
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
// Silent update: only set state if meaningful changes
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
} catch (err) {
}
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
const interval = setInterval(fetchHosts, 5000);
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => {
clearInterval(interval);
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
};
}, [fetchHosts]);
useEffect(() => {
@@ -90,7 +146,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
try {
if (action === 'connect') {
const endpointHost = hosts.find(h =>
const endpointHost = allHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
@@ -141,20 +197,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
};
return (
<div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0">
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
</div>
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
hosts={hosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
</div>
</div>
<SSHTunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
}

View File

@@ -75,13 +75,17 @@ interface SSHTunnelObjectProps {
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean; // when true, render without Card wrapper/background
}
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction
onTunnelAction,
compact = false,
bare = false
}: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
@@ -161,26 +165,173 @@ export function SSHTunnelObject({
}
};
if (bare) {
return (
<div className="w-full min-w-0">
{/* Tunnel Connections (bare) */}
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{/* Action Buttons */}
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1"/>
Disconnect
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1"/>
Cancel
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1"/>
Connect
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
Check your Docker logs for the error reason, join the <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div>
<div>
Attempt {status.retryCount} of {status.maxRetries}
{status.nextRetryIn && (
<span> Next retry in {status.nextRetryIn} seconds</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
<p className="text-sm">No tunnel connections configured</p>
</div>
)}
</div>
</div>
);
}
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
</div>
)}
{/* Tags */}
{host.tags && host.tags.length > 0 && (
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
@@ -196,14 +347,16 @@ export function SSHTunnelObject({
</div>
)}
<Separator className="mb-3"/>
{!compact && <Separator className="mb-3"/>}
{/* Tunnel Connections */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
@@ -237,12 +390,6 @@ export function SSHTunnelObject({
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1"/>
Auto
</Badge>
)}
{/* Action Buttons */}
{!isActionLoading && (
<div className="flex flex-col gap-1">

View File

@@ -1,59 +0,0 @@
import React from 'react';
import {
CornerDownLeft,
Settings
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Tunnel
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft className="h-4 w-4 mr-2"/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,9 +1,5 @@
import React from "react";
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Search} from "lucide-react";
interface TunnelConnection {
sourcePort: number;
@@ -56,128 +52,39 @@ export function SSHTunnelViewer({
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
// Single-host view: use first host if present
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler);
}, [searchQuery]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const query = debouncedSearch.trim().toLowerCase();
return hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}, [hosts, debouncedSearch]);
const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host =>
host.enableTunnel &&
host.tunnelConnections &&
host.tunnelConnections.length > 0
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
<p className="text-muted-foreground max-w-md">
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
</p>
</div>
);
}, [filteredHosts]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => {
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
if (!map[folder]) map[folder] = [];
map[folder].push(host);
});
return map;
}, [tunnelHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
}
return (
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
</h1>
<p className="text-muted-foreground">
Manage your SSH tunnel connections
</p>
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<SSHTunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
compact
bare
/>
))}
</div>
<div className="relative mb-3">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels
</h3>
<p className="text-muted-foreground max-w-md">
{searchQuery.trim() ?
"No hosts match your search criteria." :
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
}
</p>
</div>
) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`}
className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
<div className="grid grid-cols-4 gap-6 w-full">
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
<div key={host.id} className="w-full">
<SSHTunnelObject
host={host}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
);

View File

@@ -93,6 +93,18 @@ interface ConfigEditorShortcut {
path: string;
}
export type ServerStatus = {
status: 'online' | 'offline';
lastChecked: string;
};
export type ServerMetrics = {
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
lastChecked: string;
};
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sshHostApi = axios.create({
@@ -116,6 +128,13 @@ const configEditorApi = axios.create({
}
})
const statsApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8085' : '/ssh/stats',
headers: {
'Content-Type': 'application/json',
}
})
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
@@ -130,6 +149,14 @@ sshHostApi.interceptors.request.use((config) => {
return config;
});
statsApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
@@ -531,4 +558,31 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
}
}
export {sshHostApi, tunnelApi, configEditorApi};
export {sshHostApi, tunnelApi, configEditorApi};
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {
try {
const response = await statsApi.get('/status');
return response.data || {};
} catch (error) {
throw error;
}
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
try {
const response = await statsApi.get(`/status/${id}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
try {
const response = await statsApi.get(`/metrics/${id}`);
return response.data;
} catch (error) {
throw error;
}
}