Made server.tsx work with ssh tunnels and server stats. Moved admin settings.
This commit is contained in:
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"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Fund
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {TerminalComponent} from "./TerminalComponent.tsx";
|
||||
import {TerminalComponent} from "../SSH/Terminal/TerminalComponent.tsx";
|
||||
import {Server as ServerView} from "@/ui/SSH/Server/Server.tsx";
|
||||
import {useTabs} from "@/contexts/TabContext";
|
||||
import {useTabs} from "@/contexts/TabContext.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
@@ -12,7 +12,7 @@ interface TerminalViewProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
|
||||
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
|
||||
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
@@ -305,7 +305,7 @@ export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 15;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {useTabs} from "@/contexts/TabContext";
|
||||
import { getServerStatusById } from "@/ui/SSH/ssh-axios";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -34,11 +35,36 @@ interface HostProps {
|
||||
|
||||
export function Host({ host }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
intervalId = window.setInterval(fetchStatus, 60_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: 'terminal', title, hostConfig: host });
|
||||
};
|
||||
@@ -50,7 +76,7 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status status={"online"} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
|
||||
@@ -155,6 +155,16 @@ export function LeftSidebar({
|
||||
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const adminTab = tabList.find((t) => t.type === 'admin');
|
||||
const openAdminTab = () => {
|
||||
if (isSplitScreenActive) return;
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({ type: 'admin', title: 'Admin' } as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
// SSH Hosts state management
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
@@ -253,6 +263,15 @@ export function LeftSidebar({
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
// Immediate refresh when SSH hosts are changed elsewhere in the app
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
}, [fetchHosts]);
|
||||
|
||||
// Search debouncing
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
@@ -536,8 +555,8 @@ export function LeftSidebar({
|
||||
|
||||
{/* Error Display */}
|
||||
{hostsError && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
|
||||
<div className="px-1">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{hostsError}
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,9 +608,7 @@ export function LeftSidebar({
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => {
|
||||
if (isAdmin) {
|
||||
setAdminSheetOpen(true);
|
||||
}
|
||||
if (isAdmin) openAdminTab();
|
||||
}}>
|
||||
<span>Admin Settings</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -619,7 +636,7 @@ export function LeftSidebar({
|
||||
</SidebarFooter>
|
||||
{/* Admin Settings Sheet */}
|
||||
{isAdmin && (
|
||||
<Sheet open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
||||
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
||||
if (open && !isAdmin) return;
|
||||
setAdminSheetOpen(open);
|
||||
}}>
|
||||
@@ -643,7 +660,7 @@ export function LeftSidebar({
|
||||
<Users className="h-4 w-4"/>
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="h-4 w-4">
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Admins
|
||||
</TabsTrigger>
|
||||
@@ -984,7 +1001,7 @@ export function LeftSidebar({
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation Sheet */}
|
||||
<Sheet open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<SheetContent side="left" className="w-[400px]">
|
||||
<SheetHeader className="pb-0">
|
||||
<SheetTitle>Delete Account</SheetTitle>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
|
||||
const currentTabIsHome = currentTabObj?.type === 'home';
|
||||
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
||||
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -53,12 +54,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const isTerminal = tab.type === 'terminal';
|
||||
const isServer = tab.type === 'server';
|
||||
const isSshManager = tab.type === 'ssh_manager';
|
||||
const isAdmin = tab.type === 'admin';
|
||||
// Split availability
|
||||
const isSplittable = isTerminal || isServer;
|
||||
// Disable split entirely when on Home or SSH Manager
|
||||
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager;
|
||||
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager') && isSplitScreenActive);
|
||||
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
||||
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||
return (
|
||||
<Tab
|
||||
@@ -67,10 +69,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
title={tab.title}
|
||||
isActive={isActive}
|
||||
onActivate={() => handleTabActivate(tab.id)}
|
||||
onClose={isTerminal || isServer || isSshManager ? () => handleTabClose(tab.id) : undefined}
|
||||
onClose={isTerminal || isServer || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
|
||||
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
|
||||
canSplit={isSplittable}
|
||||
canClose={isTerminal || isServer || isSshManager}
|
||||
canClose={isTerminal || isServer || isSshManager || isAdmin}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
disableClose={disableClose}
|
||||
|
||||
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (error) {
|
||||
alert('Failed to save host. Please try again.');
|
||||
}
|
||||
@@ -809,7 +811,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port
|
||||
(Local)</FormLabel>
|
||||
(Source refers to the Current Connection Details in the General tab)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
|
||||
@@ -75,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
@@ -115,6 +116,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
if (result.success > 0) {
|
||||
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} else {
|
||||
alert(`Import failed: ${result.errors.join('\n')}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx";
|
||||
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/SSH/ssh-axios";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
@@ -11,10 +18,52 @@ interface ServerProps {
|
||||
|
||||
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(hostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!hostConfig?.id) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(hostConfig.id);
|
||||
if (!cancelled) setMetrics(data);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (hostConfig?.id) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||
const bottomMarginPx = 16;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
|
||||
@@ -33,10 +82,106 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="text-sm opacity-70 text-center">
|
||||
<div>{title || 'Server'}</div>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="font-bold text-lg">
|
||||
{hostConfig.folder} / {title}
|
||||
</h1>
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button variant="outline">File Manager</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
||||
{/* CPU */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<Cpu/>
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const la = metrics?.cpu?.load;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||
const laText = (la && la.length === 3)
|
||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||
: 'Avg: N/A';
|
||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} />
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
|
||||
{/* Memory */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<MemoryStick/>
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} />
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
|
||||
{/* HDD */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<HardDrive/>
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{(hostConfig?.tunnelConnections && hostConfig.tunnelConnections.length > 0) && (
|
||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<SSHTunnel filterHostKey={(hostConfig?.name && hostConfig.name.trim() !== '') ? hostConfig.name : `${hostConfig?.username}@${hostConfig?.ip}`}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for server management? Share them on{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {SSHTunnelSidebar} from "@/ui/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
@@ -49,31 +44,92 @@ interface TunnelStatus {
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
interface SSHTunnelProps {
|
||||
filterHostKey?: string;
|
||||
}
|
||||
|
||||
export function SSHTunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
// Keep full list for endpoint lookups; keep a separate visible list for UI
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
try {
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
} catch (err) {
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
|
||||
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter(h => {
|
||||
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
// Silent update: only set state if meaningful changes
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
} catch (err) {
|
||||
}
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,7 +146,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
||||
|
||||
try {
|
||||
if (action === 'connect') {
|
||||
const endpointHost = hosts.find(h =>
|
||||
const endpointHost = allHosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
@@ -141,20 +197,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
hosts={hosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SSHTunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -75,13 +75,17 @@ interface SSHTunnelObjectProps {
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
compact?: boolean;
|
||||
bare?: boolean; // when true, render without Card wrapper/background
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
@@ -161,26 +165,173 @@ export function SSHTunnelObject({
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
{/* Tunnel Connections (bare) */}
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
||||
const isConnected = statusValue === 'CONNECTED';
|
||||
const isConnecting = statusValue === 'CONNECTING';
|
||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
||||
const isRetrying = statusValue === 'RETRYING';
|
||||
const isWaiting = statusValue === 'WAITING';
|
||||
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
{/* Tunnel Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{/* Action Buttons */}
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">Error:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
Check your Docker logs for the error reason, join the <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
create a <a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
||||
issue</a> for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry Info */}
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
|
||||
</div>
|
||||
<div>
|
||||
Attempt {status.retryCount} of {status.maxRetries}
|
||||
{status.nextRetryIn && (
|
||||
<span> • Next retry in {status.nextRetryIn} seconds</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">No tunnel connections configured</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{/* Host Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
@@ -196,14 +347,16 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-3"/>
|
||||
{!compact && <Separator className="mb-3"/>}
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
@@ -237,12 +390,6 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{tunnel.autoStart && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||
<Zap className="h-3 w-3 mr-1"/>
|
||||
Auto
|
||||
</Badge>
|
||||
)}
|
||||
{/* Action Buttons */}
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -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 {SSHTunnelObject} from "./SSHTunnelObject.tsx";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Search} from "lucide-react";
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
@@ -56,128 +52,39 @@ export function SSHTunnelViewer({
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
// Single-host view: use first host if present
|
||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
|
||||
const query = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
const tunnelHosts = React.useMemo(() => {
|
||||
return filteredHosts.filter(host =>
|
||||
host.enableTunnel &&
|
||||
host.tunnelConnections &&
|
||||
host.tunnelConnections.length > 0
|
||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}, [filteredHosts]);
|
||||
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
tunnelHosts.forEach(host => {
|
||||
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(host);
|
||||
});
|
||||
return map;
|
||||
}, [tunnelHosts]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [hostsByFolder]);
|
||||
|
||||
const getSortedHosts = (arr: SSHHost[]) => {
|
||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
|
||||
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
SSH Tunnels
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your SSH tunnel connections
|
||||
</p>
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<SSHTunnelObject
|
||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tunnelHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No SSH Tunnels
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{searchQuery.trim() ?
|
||||
"No hosts match your search criteria." :
|
||||
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`}
|
||||
className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
|
||||
style={{marginTop: idx === 0 ? 0 : undefined}}>
|
||||
{folder}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
<div className="grid grid-cols-4 gap-6 w-full">
|
||||
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
|
||||
<div key={host.id} className="w-full">
|
||||
<SSHTunnelObject
|
||||
host={host}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={onTunnelAction}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -93,6 +93,18 @@ interface ConfigEditorShortcut {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type ServerStatus = {
|
||||
status: 'online' | 'offline';
|
||||
lastChecked: string;
|
||||
};
|
||||
|
||||
export type ServerMetrics = {
|
||||
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
||||
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||
lastChecked: string;
|
||||
};
|
||||
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
const sshHostApi = axios.create({
|
||||
@@ -116,6 +128,13 @@ const configEditorApi = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
const statsApi = axios.create({
|
||||
baseURL: isLocalhost ? 'http://localhost:8085' : '/ssh/stats',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
@@ -130,6 +149,14 @@ sshHostApi.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
statsApi.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
tunnelApi.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
@@ -531,4 +558,31 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
|
||||
}
|
||||
}
|
||||
|
||||
export {sshHostApi, tunnelApi, configEditorApi};
|
||||
export {sshHostApi, tunnelApi, configEditorApi};
|
||||
|
||||
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {
|
||||
try {
|
||||
const response = await statsApi.get('/status');
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
try {
|
||||
const response = await statsApi.get(`/status/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user