diff --git a/.env b/.env index 07925e7a..8c58d0d4 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.3 \ No newline at end of file +VERSION=1.3.0 \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 0e88425f..c82aa3e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,7 +72,7 @@ RUN chown -R node:node /app VOLUME ["/app/data"] -EXPOSE ${PORT} 8081 8082 8083 8084 +EXPOSE ${PORT} 8081 8082 8083 8084 8085 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/docker/nginx.conf b/docker/nginx.conf index c332661a..4fca1184 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -94,6 +94,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/stats/ { + proxy_pass http://127.0.0.1:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/package-lock.json b/package-lock.json index 96ec5142..7510bd54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -2178,6 +2179,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", diff --git a/package.json b/package.json index 1edabae8..792975c5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", diff --git a/src/App.tsx b/src/App.tsx index 3856a26b..649d4965 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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", }} > - + {/* Always render Homepage to keep it mounted */} @@ -202,28 +204,23 @@ function AppContent() { > + + {/* Admin Settings tab */} +
+ +
- {/* Legacy views - keep for compatibility (exclude homepage to avoid duplicate mounts) */} - {mountedViews.has("ssh_manager") && ( -
- -
- )} - {mountedViews.has("terminal") && ( -
- -
- )} - {mountedViews.has("tunnel") && ( -
- -
- )} - {mountedViews.has("config_editor") && ( -
- -
- )} + {/* Legacy views removed; tab system controls main content */} )} diff --git a/src/backend/config_editor/config_editor.ts b/src/backend/ssh/config_editor.ts similarity index 100% rename from src/backend/config_editor/config_editor.ts rename to src/backend/ssh/config_editor.ts diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts new file mode 100644 index 00000000..56aa9ca3 --- /dev/null +++ b/src/backend/ssh/server-stats.ts @@ -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 = new Map(); + +// Fetch all hosts from the database service (internal endpoint, no JWT) +async function fetchAllHosts(): Promise { + 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 { + 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(host: HostRecord, fn: (client: Client) => Promise): Promise { + return new Promise((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 { + 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 { + 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 = {}; + 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); + diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh/ssh_tunnel.ts similarity index 100% rename from src/backend/ssh_tunnel/ssh_tunnel.ts rename to src/backend/ssh/ssh_tunnel.ts diff --git a/src/backend/starter.ts b/src/backend/starter.ts index b76bdf2f..83fb86c3 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -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 = '🚀'; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 00000000..10af7e63 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -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) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 6906f5b2..1a6ea1e2 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -34,7 +34,7 @@ function SheetOverlay({ ; @@ -41,8 +41,9 @@ export function TabProvider({ children }: TabProviderProps) { const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); 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(); 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): 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, diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx new file mode 100644 index 00000000..954cc7ed --- /dev/null +++ b/src/ui/Admin/AdminSettings.tsx @@ -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(null); + const [oidcSuccess, setOidcSuccess] = React.useState(null); + + // Users/admins + const [users, setUsers] = React.useState>([]); + const [usersLoading, setUsersLoading] = React.useState(false); + const [newAdminUsername, setNewAdminUsername] = React.useState(""); + const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); + const [makeAdminError, setMakeAdminError] = React.useState(null); + const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(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 ( +
+
+
+

Admin Settings

+
+ + +
+ + + + + General + + + + OIDC + + + + Users + + + + Admins + + + + +
+

User Registration

+ +
+
+ + +
+

External Authentication (OIDC)

+

Configure external identity provider for OIDC/OAuth2 authentication.

+ + {oidcError && ( + + Error + {oidcError} + + )} + +
+
+ + handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required /> +
+
+ + handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required /> +
+
+ + handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required /> +
+
+ + handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required /> +
+
+ + handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required /> +
+
+ + handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required /> +
+
+ + handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required /> +
+
+ + handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required /> +
+
+ + +
+ + {oidcSuccess && ( + + Success + {oidcSuccess} + + )} +
+
+
+ + +
+
+

User Management

+ +
+ {usersLoading ? ( +
Loading users...
+ ) : ( +
+ + + + Username + Type + Actions + + + + {users.map((user) => ( + + + {user.username} + {user.is_admin && ( + Admin + )} + + {user.is_oidc ? "External" : "Local"} + + + + + ))} + +
+
+ )} +
+
+ + +
+

Admin Management

+
+

Make User Admin

+
+
+ +
+ setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required /> + +
+
+ {makeAdminError && ( + + Error + {makeAdminError} + + )} + {makeAdminSuccess && ( + + Success + {makeAdminSuccess} + + )} +
+
+ +
+

Current Admins

+
+ + + + Username + Type + Actions + + + + {users.filter(u => u.is_admin).map((admin) => ( + + + {admin.username} + Admin + + {admin.is_oidc ? "External" : "Local"} + + + + + ))} + +
+
+
+
+
+
+
+
+
+ ); +} + +export default AdminSettings; \ No newline at end of file diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx index 61bf8900..111ff4d3 100644 --- a/src/ui/Homepage/Homepage.tsx +++ b/src/ui/Homepage/Homepage.tsx @@ -138,7 +138,7 @@ export function Homepage({ className="text-sm" onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')} > - Fund + Donate diff --git a/src/ui/SSH/Terminal/TerminalView.tsx b/src/ui/Navigation/AppView.tsx similarity index 98% rename from src/ui/SSH/Terminal/TerminalView.tsx rename to src/ui/Navigation/AppView.tsx index 2be3c6ab..8c03e230 100644 --- a/src/ui/SSH/Terminal/TerminalView.tsx +++ b/src/ui/Navigation/AppView.tsx @@ -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 (
('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 (
- +

diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 95ee6dd7..f22a430a 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -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([]); @@ -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 && ( -

-
+
+
{hostsError}
@@ -589,9 +608,7 @@ export function LeftSidebar({ { - if (isAdmin) { - setAdminSheetOpen(true); - } + if (isAdmin) openAdminTab(); }}> Admin Settings @@ -619,7 +636,7 @@ export function LeftSidebar({ {/* Admin Settings Sheet */} {isAdmin && ( - { + { if (open && !isAdmin) return; setAdminSheetOpen(open); }}> @@ -643,7 +660,7 @@ export function LeftSidebar({ Users - + Admins @@ -984,7 +1001,7 @@ export function LeftSidebar({ )} {/* Delete Account Confirmation Sheet */} - + Delete Account diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx index 161bb28c..558136f5 100644 --- a/src/ui/Navigation/Tabs/Tab.tsx +++ b/src/ui/Navigation/Tabs/Tab.tsx @@ -92,5 +92,28 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can ); } + if (tabType === "admin") { + return ( + + + + + ); + } + return null; } diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index 64c03788..ef39e5c3 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -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 (
@@ -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 ( 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} diff --git a/src/ui/SSH/Manager/SSHManagerHostEditor.tsx b/src/ui/SSH/Manager/SSHManagerHostEditor.tsx index 20705df9..73c6d426 100644 --- a/src/ui/SSH/Manager/SSHManagerHostEditor.tsx +++ b/src/ui/SSH/Manager/SSHManagerHostEditor.tsx @@ -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}) => ( Source Port - (Local) + (Source refers to the Current Connection Details in the General tab) diff --git a/src/ui/SSH/Manager/SSHManagerHostViewer.tsx b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx index 76da3e66..3723ecb9 100644 --- a/src/ui/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx @@ -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')}`); } diff --git a/src/ui/SSH/Server/Server.tsx b/src/ui/SSH/Server/Server.tsx index 2885b9f3..72019f6d 100644 --- a/src/ui/SSH/Server/Server.tsx +++ b/src/ui/SSH/Server/Server.tsx @@ -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(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 (
-
-
-
{title || 'Server'}
+
+ + {/* Top Header */} +
+
+

+ {hostConfig.folder} / {title} +

+ + + +
+
+ +
+ + + {/* Stats */} +
+ {/* 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})`; + })()} +

+ + +
+ + + + {/* Memory */} +
+

+ + {(() => { + 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})`; + })()} +

+ + +
+ + + + {/* HDD */} +
+

+ + {(() => { + 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})`; + })()} +

+ + +
+
+ + {/* SSH Tunnels */} + {(hostConfig?.tunnelConnections && hostConfig.tunnelConnections.length > 0) && ( +
+ +
+ )} + +

+ Have ideas for what should come next for server management? Share them on{" "} + + GitHub + + ! +

); diff --git a/src/ui/SSH/Tunnel/SSHTunnel.tsx b/src/ui/SSH/Tunnel/SSHTunnel.tsx index fe9cc13a..a8d757e9 100644 --- a/src/ui/SSH/Tunnel/SSHTunnel.tsx +++ b/src/ui/SSH/Tunnel/SSHTunnel.tsx @@ -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([]); +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([]); + const [visibleHosts, setVisibleHosts] = useState([]); const [tunnelStatuses, setTunnelStatuses] = useState>({}); const [tunnelActions, setTunnelActions] = useState>({}); - const fetchHosts = useCallback(async () => { - try { - const hostsData = await getSSHHosts(); - setHosts(hostsData); - } catch (err) { + const prevVisibleHostRef = React.useRef(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 ( -
-
- -
-
- -
-
+ ); } \ No newline at end of file diff --git a/src/ui/SSH/Tunnel/SSHTunnelObject.tsx b/src/ui/SSH/Tunnel/SSHTunnelObject.tsx index 298bb418..9da741e1 100644 --- a/src/ui/SSH/Tunnel/SSHTunnelObject.tsx +++ b/src/ui/SSH/Tunnel/SSHTunnelObject.tsx @@ -75,13 +75,17 @@ interface SSHTunnelObjectProps { tunnelStatuses: Record; tunnelActions: Record; onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; + 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 ( +
+ {/* Tunnel Connections (bare) */} +
+ {host.tunnelConnections && host.tunnelConnections.length > 0 ? ( +
+ {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 ( +
+ {/* Tunnel Header */} +
+
+ + {statusDisplay.icon} + +
+
+ Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} +
+
+ {statusDisplay.text} +
+
+
+
+ {/* Action Buttons */} + {!isActionLoading ? ( +
+ {isConnected ? ( + <> + + + ) : isRetrying || isWaiting ? ( + + ) : ( + + )} +
+ ) : ( + + )} +
+
+ + {/* Error/Status Reason */} + {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( +
+
Error:
+ {status.reason} + {status.reason && status.reason.includes('Max retries exhausted') && ( + <> +
+ Check your Docker logs for the error reason, join the Discord or + create a GitHub + issue for help. +
+ + )} +
+ )} + + {/* Retry Info */} + {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( +
+
+ {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} +
+
+ Attempt {status.retryCount} of {status.maxRetries} + {status.nextRetryIn && ( + • Next retry in {status.nextRetryIn} seconds + )} +
+
+ )} +
+ ); + })} +
+ ) : ( +
+ +

No tunnel connections configured

+
+ )} +
+
+ ); + } + return (
{/* Host Header */} -
-
- {host.pin && } -
-

- {host.name || `${host.username}@${host.ip}`} -

-

- {host.ip}:{host.port} • {host.username} -

+ {!compact && ( +
+
+ {host.pin && } +
+

+ {host.name || `${host.username}@${host.ip}`} +

+

+ {host.ip}:{host.port} • {host.username} +

+
-
+ )} {/* Tags */} - {host.tags && host.tags.length > 0 && ( + {!compact && host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 3).map((tag, index) => ( @@ -196,14 +347,16 @@ export function SSHTunnelObject({
)} - + {!compact && } {/* Tunnel Connections */}
-

- - Tunnel Connections ({host.tunnelConnections.length}) -

+ {!compact && ( +

+ + Tunnel Connections ({host.tunnelConnections.length}) +

+ )} {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
{host.tunnelConnections.map((tunnel, tunnelIndex) => { @@ -237,12 +390,6 @@ export function SSHTunnelObject({
- {tunnel.autoStart && ( - - - Auto - - )} {/* Action Buttons */} {!isActionLoading && (
diff --git a/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx b/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx deleted file mode 100644 index 342935e9..00000000 --- a/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx +++ /dev/null @@ -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 ( - - - - - - Termix / Tunnel - - - - - - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx b/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx index 0b6a18ed..d48e1d75 100644 --- a/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx +++ b/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx @@ -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 ( +
+

No SSH Tunnels

+

+ Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections. +

+
); - }, [filteredHosts]); - - const hostsByFolder = React.useMemo(() => { - const map: Record = {}; - 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 ( -
-
-
-

- SSH Tunnels -

-

- Manage your SSH tunnel connections -

+
+
+

SSH Tunnels

+
+
+
+ {activeHost.tunnelConnections.map((t, idx) => ( + onTunnelAction(action, activeHost, idx)} + compact + bare + /> + ))}
- -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- - {tunnelHosts.length === 0 ? ( -
-

- No SSH Tunnels -

-

- {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." - } -

-
- ) : ( - - {sortedFolders.map((folder, idx) => ( - - - {folder} - - -
- {getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => ( -
- -
- ))} -
-
-
- ))} -
- )}
); diff --git a/src/ui/SSH/ssh-axios.ts b/src/ui/SSH/ssh-axios.ts index 1c7e39d9..4701a06c 100644 --- a/src/ui/SSH/ssh-axios.ts +++ b/src/ui/SSH/ssh-axios.ts @@ -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}; \ No newline at end of file +export {sshHostApi, tunnelApi, configEditorApi}; + +export async function getAllServerStatuses(): Promise> { + try { + const response = await statsApi.get('/status'); + return response.data || {}; + } catch (error) { + throw error; + } +} + +export async function getServerStatusById(id: number): Promise { + try { + const response = await statsApi.get(`/status/${id}`); + return response.data; + } catch (error) { + throw error; + } +} + +export async function getServerMetricsById(id: number): Promise { + try { + const response = await statsApi.get(`/metrics/${id}`); + return response.data; + } catch (error) { + throw error; + } +} \ No newline at end of file