Made server.tsx work with ssh tunnels and server stats. Moved admin settings.
This commit is contained in:
@@ -72,7 +72,7 @@ RUN chown -R node:node /app
|
|||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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;
|
error_page 500 502 503 504 /50x.html;
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@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-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@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": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@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-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|||||||
47
src/App.tsx
47
src/App.tsx
@@ -1,13 +1,14 @@
|
|||||||
import React, {useState, useEffect} from "react"
|
import React, {useState, useEffect} from "react"
|
||||||
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
||||||
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
||||||
import {TerminalView} from "@/ui/SSH/Terminal/TerminalView.tsx"
|
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
||||||
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"
|
// import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"
|
||||||
import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx"
|
// import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx"
|
||||||
import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx"
|
import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx"
|
||||||
import {TabProvider, useTabs} from "@/contexts/TabContext"
|
import {TabProvider, useTabs} from "@/contexts/TabContext"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
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 apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||||
const API = axios.create({baseURL: apiBase});
|
const API = axios.create({baseURL: apiBase});
|
||||||
@@ -90,6 +91,7 @@ function AppContent() {
|
|||||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server';
|
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server';
|
||||||
const showHome = currentTabData?.type === 'home';
|
const showHome = currentTabData?.type === 'home';
|
||||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||||
|
const showAdmin = currentTabData?.type === 'admin';
|
||||||
|
|
||||||
console.log('Current tab:', currentTab);
|
console.log('Current tab:', currentTab);
|
||||||
console.log('Current tab data:', currentTabData);
|
console.log('Current tab data:', currentTabData);
|
||||||
@@ -164,7 +166,7 @@ function AppContent() {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TerminalView isTopbarOpen={isTopbarOpen} />
|
<AppView isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always render Homepage to keep it mounted */}
|
{/* Always render Homepage to keep it mounted */}
|
||||||
@@ -203,27 +205,22 @@ function AppContent() {
|
|||||||
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legacy views - keep for compatibility (exclude homepage to avoid duplicate mounts) */}
|
{/* Admin Settings tab */}
|
||||||
{mountedViews.has("ssh_manager") && (
|
<div
|
||||||
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
|
className="h-screen w-full"
|
||||||
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
|
style={{
|
||||||
</div>
|
visibility: showAdmin ? "visible" : "hidden",
|
||||||
)}
|
pointerEvents: showAdmin ? "auto" : "none",
|
||||||
{mountedViews.has("terminal") && (
|
height: showAdmin ? "100vh" : 0,
|
||||||
<div style={{display: view === "terminal" ? "block" : "none"}}>
|
width: showAdmin ? "100%" : 0,
|
||||||
<Terminal onSelectView={handleSelectView}/>
|
position: showAdmin ? "static" : "absolute",
|
||||||
</div>
|
overflow: "hidden",
|
||||||
)}
|
}}
|
||||||
{mountedViews.has("tunnel") && (
|
>
|
||||||
<div style={{display: view === "tunnel" ? "block" : "none"}}>
|
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||||
<SSHTunnel onSelectView={handleSelectView}/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{/* Legacy views removed; tab system controls main content */}
|
||||||
{mountedViews.has("config_editor") && (
|
|
||||||
<div style={{display: view === "config_editor" ? "block" : "none"}}>
|
|
||||||
<ConfigEditor onSelectView={handleSelectView}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||||
</LeftSidebar>
|
</LeftSidebar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
416
src/backend/ssh/server-stats.ts
Normal file
416
src/backend/ssh/server-stats.ts
Normal 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);
|
||||||
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
import './database/database.js'
|
import './database/database.js'
|
||||||
import './ssh/ssh.js';
|
import './ssh/ssh.js';
|
||||||
import './ssh_tunnel/ssh_tunnel.js';
|
import './ssh/ssh_tunnel.js';
|
||||||
import './config_editor/config_editor.js';
|
import './ssh/config_editor.js';
|
||||||
|
import './ssh/server-stats.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const fixedIconSymbol = '🚀';
|
const fixedIconSymbol = '🚀';
|
||||||
|
|||||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal 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 }
|
||||||
@@ -34,7 +34,7 @@ function SheetOverlay({
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -56,7 +56,7 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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" &&
|
side === "left" &&
|
||||||
|
|||||||
@@ -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 {
|
export interface Tab {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'home' | 'terminal' | 'ssh_manager' | 'server';
|
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin';
|
||||||
title: string;
|
title: string;
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
terminalRef?: React.RefObject<any>;
|
terminalRef?: React.RefObject<any>;
|
||||||
@@ -41,8 +41,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||||
const nextTabId = useRef(2);
|
const nextTabId = useRef(2);
|
||||||
|
|
||||||
function computeUniqueTerminalTitle(desiredTitle: string | undefined): string {
|
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||||
const baseTitle = (desiredTitle || 'Terminal').trim();
|
const defaultTitle = tabType === 'server' ? 'Server' : 'Terminal';
|
||||||
|
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||||
// Extract base name without trailing " (n)"
|
// Extract base name without trailing " (n)"
|
||||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||||
const root = match ? match[1] : baseTitle;
|
const root = match ? match[1] : baseTitle;
|
||||||
@@ -50,12 +51,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const usedNumbers = new Set<number>();
|
const usedNumbers = new Set<number>();
|
||||||
let rootUsed = false;
|
let rootUsed = false;
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
if (t.type !== 'terminal' || !t.title) return;
|
if (t.type !== tabType || !t.title) return;
|
||||||
if (t.title === root) {
|
if (t.title === root) {
|
||||||
rootUsed = true;
|
rootUsed = true;
|
||||||
return;
|
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) {
|
if (m) {
|
||||||
const n = parseInt(m[1], 10);
|
const n = parseInt(m[1], 10);
|
||||||
if (!isNaN(n)) usedNumbers.add(n);
|
if (!isNaN(n)) usedNumbers.add(n);
|
||||||
@@ -71,7 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
|
|
||||||
const addTab = (tabData: Omit<Tab, 'id'>): number => {
|
const addTab = (tabData: Omit<Tab, 'id'>): number => {
|
||||||
const id = nextTabId.current++;
|
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 = {
|
const newTab: Tab = {
|
||||||
...tabData,
|
...tabData,
|
||||||
id,
|
id,
|
||||||
|
|||||||
396
src/ui/Admin/AdminSettings.tsx
Normal file
396
src/ui/Admin/AdminSettings.tsx
Normal 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;
|
||||||
@@ -138,7 +138,7 @@ export function Homepage({
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||||
>
|
>
|
||||||
Fund
|
Donate
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
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 {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 {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
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 {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ interface TerminalViewProps {
|
|||||||
isTopbarOpen?: boolean;
|
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 {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.
|
|||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
const bottomMarginPx = 15;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {useTabs} from "@/contexts/TabContext";
|
import {useTabs} from "@/contexts/TabContext";
|
||||||
|
import { getServerStatusById } from "@/ui/SSH/ssh-axios";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,11 +35,36 @@ interface HostProps {
|
|||||||
|
|
||||||
export function Host({ host }: HostProps): React.ReactElement {
|
export function Host({ host }: HostProps): React.ReactElement {
|
||||||
const { addTab } = useTabs();
|
const { addTab } = useTabs();
|
||||||
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
const hasTags = tags.length > 0;
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
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 = () => {
|
const handleTerminalClick = () => {
|
||||||
addTab({ type: 'terminal', title, hostConfig: host });
|
addTab({ type: 'terminal', title, hostConfig: host });
|
||||||
};
|
};
|
||||||
@@ -50,7 +76,7 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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/>
|
<StatusIndicator/>
|
||||||
</Status>
|
</Status>
|
||||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
|
|||||||
@@ -155,6 +155,16 @@ export function LeftSidebar({
|
|||||||
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
|
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
|
||||||
setCurrentTab(id);
|
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
|
// SSH Hosts state management
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
@@ -253,6 +263,15 @@ export function LeftSidebar({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHosts]);
|
}, [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
|
// Search debouncing
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
@@ -536,8 +555,8 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{hostsError && (
|
{hostsError && (
|
||||||
<div className="px-4 pb-2">
|
<div className="px-1">
|
||||||
<div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
|
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
{hostsError}
|
{hostsError}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -589,9 +608,7 @@ export function LeftSidebar({
|
|||||||
<DropdownMenuItem
|
<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"
|
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={() => {
|
onSelect={() => {
|
||||||
if (isAdmin) {
|
if (isAdmin) openAdminTab();
|
||||||
setAdminSheetOpen(true);
|
|
||||||
}
|
|
||||||
}}>
|
}}>
|
||||||
<span>Admin Settings</span>
|
<span>Admin Settings</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -619,7 +636,7 @@ export function LeftSidebar({
|
|||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
{/* Admin Settings Sheet */}
|
{/* Admin Settings Sheet */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Sheet open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
||||||
if (open && !isAdmin) return;
|
if (open && !isAdmin) return;
|
||||||
setAdminSheetOpen(open);
|
setAdminSheetOpen(open);
|
||||||
}}>
|
}}>
|
||||||
@@ -643,7 +660,7 @@ export function LeftSidebar({
|
|||||||
<Users className="h-4 w-4"/>
|
<Users className="h-4 w-4"/>
|
||||||
Users
|
Users
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="admins" className="h-4 w-4">
|
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||||
<Shield className="h-4 w-4"/>
|
<Shield className="h-4 w-4"/>
|
||||||
Admins
|
Admins
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -984,7 +1001,7 @@ export function LeftSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Account Confirmation Sheet */}
|
{/* Delete Account Confirmation Sheet */}
|
||||||
<Sheet open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||||
<SheetContent side="left" className="w-[400px]">
|
<SheetContent side="left" className="w-[400px]">
|
||||||
<SheetHeader className="pb-0">
|
<SheetHeader className="pb-0">
|
||||||
<SheetTitle>Delete Account</SheetTitle>
|
<SheetTitle>Delete Account</SheetTitle>
|
||||||
|
|||||||
@@ -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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
|
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
|
||||||
const currentTabIsHome = currentTabObj?.type === 'home';
|
const currentTabIsHome = currentTabObj?.type === 'home';
|
||||||
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
||||||
|
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -53,12 +54,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const isTerminal = tab.type === 'terminal';
|
const isTerminal = tab.type === 'terminal';
|
||||||
const isServer = tab.type === 'server';
|
const isServer = tab.type === 'server';
|
||||||
const isSshManager = tab.type === 'ssh_manager';
|
const isSshManager = tab.type === 'ssh_manager';
|
||||||
|
const isAdmin = tab.type === 'admin';
|
||||||
// Split availability
|
// Split availability
|
||||||
const isSplittable = isTerminal || isServer;
|
const isSplittable = isTerminal || isServer;
|
||||||
// Disable split entirely when on Home or SSH Manager
|
// Disable split entirely when on Home or SSH Manager
|
||||||
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||||
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager;
|
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
||||||
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager') && isSplitScreenActive);
|
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
||||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||||
return (
|
return (
|
||||||
<Tab
|
<Tab
|
||||||
@@ -67,10 +69,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
title={tab.title}
|
title={tab.title}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onActivate={() => handleTabActivate(tab.id)}
|
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}
|
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
|
||||||
canSplit={isSplittable}
|
canSplit={isSplittable}
|
||||||
canClose={isTerminal || isServer || isSshManager}
|
canClose={isTerminal || isServer || isSshManager || isAdmin}
|
||||||
disableActivate={disableActivate}
|
disableActivate={disableActivate}
|
||||||
disableSplit={disableSplit}
|
disableSplit={disableSplit}
|
||||||
disableClose={disableClose}
|
disableClose={disableClose}
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
if (onFormSubmit) {
|
if (onFormSubmit) {
|
||||||
onFormSubmit();
|
onFormSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to save host. Please try again.');
|
alert('Failed to save host. Please try again.');
|
||||||
}
|
}
|
||||||
@@ -809,7 +811,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
|||||||
render={({field: sourcePortField}) => (
|
render={({field: sourcePortField}) => (
|
||||||
<FormItem className="col-span-4">
|
<FormItem className="col-span-4">
|
||||||
<FormLabel>Source Port
|
<FormLabel>Source Port
|
||||||
(Local)</FormLabel>
|
(Source refers to the Current Connection Details in the General tab)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="22" {...sourcePortField} />
|
placeholder="22" {...sourcePortField} />
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
try {
|
try {
|
||||||
await deleteSSHHost(hostId);
|
await deleteSSHHost(hostId);
|
||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete host');
|
alert('Failed to delete host');
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} else {
|
} else {
|
||||||
alert(`Import failed: ${result.errors.join('\n')}`);
|
alert(`Import failed: ${result.errors.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
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 {
|
interface ServerProps {
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
@@ -11,10 +18,52 @@ interface ServerProps {
|
|||||||
|
|
||||||
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
|
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
|
||||||
const { state: sidebarState } = useSidebar();
|
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 topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||||
const bottomMarginPx = 16;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
const wrapperStyle: React.CSSProperties = embedded
|
const wrapperStyle: React.CSSProperties = embedded
|
||||||
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
|
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
|
||||||
@@ -33,10 +82,106 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
<div className="h-full w-full flex flex-col">
|
||||||
<div className="text-sm opacity-70 text-center">
|
|
||||||
<div>{title || 'Server'}</div>
|
{/* 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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import React, {useState, useEffect, useCallback} from "react";
|
import React, {useState, useEffect, useCallback} from "react";
|
||||||
import {SSHTunnelSidebar} from "@/ui/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
|
||||||
import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx";
|
import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios";
|
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios";
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelConnection {
|
interface TunnelConnection {
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
@@ -49,31 +44,92 @@ interface TunnelStatus {
|
|||||||
retryExhausted?: boolean;
|
retryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
interface SSHTunnelProps {
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
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 [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const fetchHosts = useCallback(async () => {
|
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||||
try {
|
|
||||||
const hostsData = await getSSHHosts();
|
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
||||||
setHosts(hostsData);
|
if (a.length !== b.length) return true;
|
||||||
} catch (err) {
|
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 () => {
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
try {
|
const statusData = await getTunnelStatuses();
|
||||||
const statusData = await getTunnelStatuses();
|
setTunnelStatuses(statusData);
|
||||||
setTunnelStatuses(statusData);
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchHosts, 10000);
|
const interval = setInterval(fetchHosts, 5000);
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
// 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]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,7 +146,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'connect') {
|
if (action === 'connect') {
|
||||||
const endpointHost = hosts.find(h =>
|
const endpointHost = allHosts.find(h =>
|
||||||
h.name === tunnel.endpointHost ||
|
h.name === tunnel.endpointHost ||
|
||||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||||
);
|
);
|
||||||
@@ -141,20 +197,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full">
|
<SSHTunnelViewer
|
||||||
<div className="w-64 flex-shrink-0">
|
hosts={visibleHosts}
|
||||||
<SSHTunnelSidebar
|
tunnelStatuses={tunnelStatuses}
|
||||||
onSelectView={onSelectView}
|
tunnelActions={tunnelActions}
|
||||||
/>
|
onTunnelAction={handleTunnelAction}
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<SSHTunnelViewer
|
|
||||||
hosts={hosts}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
|
||||||
tunnelActions={tunnelActions}
|
|
||||||
onTunnelAction={handleTunnelAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,13 +75,17 @@ interface SSHTunnelObjectProps {
|
|||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
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({
|
export function SSHTunnelObject({
|
||||||
host,
|
host,
|
||||||
tunnelStatuses,
|
tunnelStatuses,
|
||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction
|
onTunnelAction,
|
||||||
|
compact = false,
|
||||||
|
bare = false
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
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 (
|
return (
|
||||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Host Header */}
|
{/* Host Header */}
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
{!compact && (
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||||
<h3 className="font-semibold text-card-foreground truncate">
|
<div className="flex-1 min-w-0">
|
||||||
{host.name || `${host.username}@${host.ip}`}
|
<h3 className="font-semibold text-card-foreground truncate">
|
||||||
</h3>
|
{host.name || `${host.username}@${host.ip}`}
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
</h3>
|
||||||
{host.ip}:{host.port} • {host.username}
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
</p>
|
{host.ip}:{host.port} • {host.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{host.tags && host.tags.length > 0 && (
|
{!compact && host.tags && host.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
{host.tags.slice(0, 3).map((tag, index) => (
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||||
@@ -196,14 +347,16 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator className="mb-3"/>
|
{!compact && <Separator className="mb-3"/>}
|
||||||
|
|
||||||
{/* Tunnel Connections */}
|
{/* Tunnel Connections */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
{!compact && (
|
||||||
<Network className="h-4 w-4"/>
|
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||||
Tunnel Connections ({host.tunnelConnections.length})
|
<Network className="h-4 w-4"/>
|
||||||
</h4>
|
Tunnel Connections ({host.tunnelConnections.length})
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
@@ -237,12 +390,6 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<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 */}
|
{/* Action Buttons */}
|
||||||
{!isActionLoading && (
|
{!isActionLoading && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
|
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 {
|
interface TunnelConnection {
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
@@ -56,128 +52,39 @@ export function SSHTunnelViewer({
|
|||||||
tunnelActions = {},
|
tunnelActions = {},
|
||||||
onTunnelAction
|
onTunnelAction
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
// Single-host view: use first host if present
|
||||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||||
|
|
||||||
React.useEffect(() => {
|
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
return (
|
||||||
return () => clearTimeout(handler);
|
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||||
}, [searchQuery]);
|
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md">
|
||||||
const filteredHosts = React.useMemo(() => {
|
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
</p>
|
||||||
|
</div>
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}, [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 (
|
return (
|
||||||
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
|
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||||
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
|
<div className="w-full flex-shrink-0 mb-2">
|
||||||
<div className="mb-6">
|
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
</div>
|
||||||
SSH Tunnels
|
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||||
</h1>
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
<p className="text-muted-foreground">
|
{activeHost.tunnelConnections.map((t, idx) => (
|
||||||
Manage your SSH tunnel connections
|
<SSHTunnelObject
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ interface ConfigEditorShortcut {
|
|||||||
path: string;
|
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 isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
const sshHostApi = axios.create({
|
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 {
|
function getCookie(name: string): string | undefined {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
@@ -130,6 +149,14 @@ sshHostApi.interceptors.request.use((config) => {
|
|||||||
return 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) => {
|
tunnelApi.interceptors.request.use((config) => {
|
||||||
const token = getCookie('jwt');
|
const token = getCookie('jwt');
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -532,3 +559,30 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user