+
diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx
index 95ee6dd7..f22a430a 100644
--- a/src/ui/Navigation/LeftSidebar.tsx
+++ b/src/ui/Navigation/LeftSidebar.tsx
@@ -155,6 +155,16 @@ export function LeftSidebar({
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
setCurrentTab(id);
};
+ const adminTab = tabList.find((t) => t.type === 'admin');
+ const openAdminTab = () => {
+ if (isSplitScreenActive) return;
+ if (adminTab) {
+ setCurrentTab(adminTab.id);
+ return;
+ }
+ const id = addTab({ type: 'admin', title: 'Admin' } as any);
+ setCurrentTab(id);
+ };
// SSH Hosts state management
const [hosts, setHosts] = useState([]);
@@ -253,6 +263,15 @@ export function LeftSidebar({
return () => clearInterval(interval);
}, [fetchHosts]);
+ // Immediate refresh when SSH hosts are changed elsewhere in the app
+ React.useEffect(() => {
+ const handleHostsChanged = () => {
+ fetchHosts();
+ };
+ window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+ return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+ }, [fetchHosts]);
+
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -536,8 +555,8 @@ export function LeftSidebar({
{/* Error Display */}
{hostsError && (
-
-
+
@@ -589,9 +608,7 @@ export function LeftSidebar({
{
- if (isAdmin) {
- setAdminSheetOpen(true);
- }
+ if (isAdmin) openAdminTab();
}}>
Admin Settings
@@ -619,7 +636,7 @@ export function LeftSidebar({
{/* Admin Settings Sheet */}
{isAdmin && (
-
{
+ {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
@@ -643,7 +660,7 @@ export function LeftSidebar({
Users
-
+
Admins
@@ -984,7 +1001,7 @@ export function LeftSidebar({
)}
{/* Delete Account Confirmation Sheet */}
-
+
Delete Account
diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx
index 161bb28c..558136f5 100644
--- a/src/ui/Navigation/Tabs/Tab.tsx
+++ b/src/ui/Navigation/Tabs/Tab.tsx
@@ -92,5 +92,28 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
);
}
+ if (tabType === "admin") {
+ return (
+
+
+ {title || "Admin"}
+
+
+
+
+
+ );
+ }
+
return null;
}
diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx
index 64c03788..ef39e5c3 100644
--- a/src/ui/Navigation/TopNavbar.tsx
+++ b/src/ui/Navigation/TopNavbar.tsx
@@ -31,6 +31,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
+ const currentTabIsAdmin = currentTabObj?.type === 'admin';
return (
@@ -53,12 +54,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isTerminal = tab.type === 'terminal';
const isServer = tab.type === 'server';
const isSshManager = tab.type === 'ssh_manager';
+ const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
- const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager;
- const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager') && isSplitScreenActive);
+ const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
+ const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit;
return (
handleTabActivate(tab.id)}
- onClose={isTerminal || isServer || isSshManager ? () => handleTabClose(tab.id) : undefined}
+ onClose={isTerminal || isServer || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
canSplit={isSplittable}
- canClose={isTerminal || isServer || isSshManager}
+ canClose={isTerminal || isServer || isSshManager || isAdmin}
disableActivate={disableActivate}
disableSplit={disableSplit}
disableClose={disableClose}
diff --git a/src/ui/SSH/Manager/SSHManagerHostEditor.tsx b/src/ui/SSH/Manager/SSHManagerHostEditor.tsx
index 20705df9..73c6d426 100644
--- a/src/ui/SSH/Manager/SSHManagerHostEditor.tsx
+++ b/src/ui/SSH/Manager/SSHManagerHostEditor.tsx
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
if (onFormSubmit) {
onFormSubmit();
}
+
+ window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
alert('Failed to save host. Please try again.');
}
@@ -809,7 +811,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
render={({field: sourcePortField}) => (
Source Port
- (Local)
+ (Source refers to the Current Connection Details in the General tab)
diff --git a/src/ui/SSH/Manager/SSHManagerHostViewer.tsx b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx
index 76da3e66..3723ecb9 100644
--- a/src/ui/SSH/Manager/SSHManagerHostViewer.tsx
+++ b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx
@@ -75,6 +75,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
try {
await deleteSSHHost(hostId);
await fetchHosts();
+ window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) {
alert('Failed to delete host');
}
@@ -115,6 +116,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
if (result.success > 0) {
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
await fetchHosts();
+ window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else {
alert(`Import failed: ${result.errors.join('\n')}`);
}
diff --git a/src/ui/SSH/Server/Server.tsx b/src/ui/SSH/Server/Server.tsx
index 2885b9f3..72019f6d 100644
--- a/src/ui/SSH/Server/Server.tsx
+++ b/src/ui/SSH/Server/Server.tsx
@@ -1,5 +1,12 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar";
+import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
+import {Separator} from "@/components/ui/separator.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import { Progress } from "@/components/ui/progress"
+import {Cpu, HardDrive, MemoryStick} from "lucide-react";
+import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx";
+import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/SSH/ssh-axios";
interface ServerProps {
hostConfig?: any;
@@ -11,10 +18,52 @@ interface ServerProps {
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
const { state: sidebarState } = useSidebar();
+ const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
+ const [metrics, setMetrics] = React.useState(null);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ let intervalId: number | undefined;
+
+ const fetchStatus = async () => {
+ try {
+ const res = await getServerStatusById(hostConfig?.id);
+ if (!cancelled) {
+ setServerStatus(res?.status === 'online' ? 'online' : 'offline');
+ }
+ } catch {
+ if (!cancelled) setServerStatus('offline');
+ }
+ };
+
+ const fetchMetrics = async () => {
+ if (!hostConfig?.id) return;
+ try {
+ const data = await getServerMetricsById(hostConfig.id);
+ if (!cancelled) setMetrics(data);
+ } catch {
+ if (!cancelled) setMetrics(null);
+ }
+ };
+
+ if (hostConfig?.id) {
+ fetchStatus();
+ fetchMetrics();
+ intervalId = window.setInterval(() => {
+ fetchStatus();
+ fetchMetrics();
+ }, 10_000);
+ }
+
+ return () => {
+ cancelled = true;
+ if (intervalId) window.clearInterval(intervalId);
+ };
+ }, [hostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
- const bottomMarginPx = 16;
+ const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
@@ -33,10 +82,106 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
return (
-
-
-
{title || 'Server'}
+
+
+ {/* Top Header */}
+
+
+
+ {hostConfig.folder} / {title}
+
+
+
+
+
+
+ File Manager
+
+
+
+ {/* Stats */}
+
+ {/* CPU */}
+
+
+
+ {(() => {
+ const pct = metrics?.cpu?.percent;
+ const cores = metrics?.cpu?.cores;
+ const la = metrics?.cpu?.load;
+ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
+ const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
+ const laText = (la && la.length === 3)
+ ? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
+ : 'Avg: N/A';
+ return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
+ })()}
+
+
+
+
+
+
+
+ {/* Memory */}
+
+
+
+ {(() => {
+ const pct = metrics?.memory?.percent;
+ const used = metrics?.memory?.usedGiB;
+ const total = metrics?.memory?.totalGiB;
+ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
+ const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
+ const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
+ return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
+ })()}
+
+
+
+
+
+
+
+ {/* HDD */}
+
+
+
+ {(() => {
+ const pct = metrics?.disk?.percent;
+ const used = metrics?.disk?.usedHuman;
+ const total = metrics?.disk?.totalHuman;
+ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
+ const usedText = used ?? 'N/A';
+ const totalText = total ?? 'N/A';
+ return `HD Space - ${pctText} (${usedText} of ${totalText})`;
+ })()}
+
+
+
+
+
+
+ {/* SSH Tunnels */}
+ {(hostConfig?.tunnelConnections && hostConfig.tunnelConnections.length > 0) && (
+
+
+
+ )}
+
+
+ Have ideas for what should come next for server management? Share them on{" "}
+
+ GitHub
+
+ !
+
);
diff --git a/src/ui/SSH/Tunnel/SSHTunnel.tsx b/src/ui/SSH/Tunnel/SSHTunnel.tsx
index fe9cc13a..a8d757e9 100644
--- a/src/ui/SSH/Tunnel/SSHTunnel.tsx
+++ b/src/ui/SSH/Tunnel/SSHTunnel.tsx
@@ -1,12 +1,7 @@
import React, {useState, useEffect, useCallback} from "react";
-import {SSHTunnelSidebar} from "@/ui/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios";
-interface ConfigEditorProps {
- onSelectView: (view: string) => void;
-}
-
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
@@ -49,31 +44,92 @@ interface TunnelStatus {
retryExhausted?: boolean;
}
-export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
- const [hosts, setHosts] = useState
([]);
+interface SSHTunnelProps {
+ filterHostKey?: string;
+}
+
+export function SSHTunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
+ // Keep full list for endpoint lookups; keep a separate visible list for UI
+ const [allHosts, setAllHosts] = useState([]);
+ const [visibleHosts, setVisibleHosts] = useState([]);
const [tunnelStatuses, setTunnelStatuses] = useState>({});
const [tunnelActions, setTunnelActions] = useState>({});
- const fetchHosts = useCallback(async () => {
- try {
- const hostsData = await getSSHHosts();
- setHosts(hostsData);
- } catch (err) {
+ const prevVisibleHostRef = React.useRef(null);
+
+ const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
+ if (a.length !== b.length) return true;
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i];
+ const y = b[i];
+ if (
+ x.sourcePort !== y.sourcePort ||
+ x.endpointPort !== y.endpointPort ||
+ x.endpointHost !== y.endpointHost ||
+ x.maxRetries !== y.maxRetries ||
+ x.retryInterval !== y.retryInterval ||
+ x.autoStart !== y.autoStart
+ ) {
+ return true;
+ }
}
- }, []);
+ return false;
+ };
+
+ const fetchHosts = useCallback(async () => {
+ const hostsData = await getSSHHosts();
+ setAllHosts(hostsData);
+ const nextVisible = filterHostKey
+ ? hostsData.filter(h => {
+ const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
+ return key === filterHostKey;
+ })
+ : hostsData;
+
+ // Silent update: only set state if meaningful changes
+ const prev = prevVisibleHostRef.current;
+ const curr = nextVisible[0] ?? null;
+ let changed = false;
+ if (!prev && curr) changed = true;
+ else if (prev && !curr) changed = true;
+ else if (prev && curr) {
+ if (
+ prev.id !== curr.id ||
+ prev.name !== curr.name ||
+ prev.ip !== curr.ip ||
+ prev.port !== curr.port ||
+ prev.username !== curr.username ||
+ haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
+ ) {
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ setVisibleHosts(nextVisible);
+ prevVisibleHostRef.current = curr;
+ }
+ }, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
- try {
- const statusData = await getTunnelStatuses();
- setTunnelStatuses(statusData);
- } catch (err) {
- }
+ const statusData = await getTunnelStatuses();
+ setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
- const interval = setInterval(fetchHosts, 10000);
- return () => clearInterval(interval);
+ const interval = setInterval(fetchHosts, 5000);
+
+ // Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
+ const handleHostsChanged = () => {
+ fetchHosts();
+ };
+ window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+
+ return () => {
+ clearInterval(interval);
+ window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+ };
}, [fetchHosts]);
useEffect(() => {
@@ -90,7 +146,7 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
try {
if (action === 'connect') {
- const endpointHost = hosts.find(h =>
+ const endpointHost = allHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
@@ -141,20 +197,11 @@ export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement
};
return (
-
+
);
}
\ No newline at end of file
diff --git a/src/ui/SSH/Tunnel/SSHTunnelObject.tsx b/src/ui/SSH/Tunnel/SSHTunnelObject.tsx
index 298bb418..9da741e1 100644
--- a/src/ui/SSH/Tunnel/SSHTunnelObject.tsx
+++ b/src/ui/SSH/Tunnel/SSHTunnelObject.tsx
@@ -75,13 +75,17 @@ interface SSHTunnelObjectProps {
tunnelStatuses: Record;
tunnelActions: Record;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise;
+ compact?: boolean;
+ bare?: boolean; // when true, render without Card wrapper/background
}
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
- onTunnelAction
+ onTunnelAction,
+ compact = false,
+ bare = false
}: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
@@ -161,26 +165,173 @@ export function SSHTunnelObject({
}
};
+ if (bare) {
+ return (
+
+ {/* Tunnel Connections (bare) */}
+
+ {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
+
+ {host.tunnelConnections.map((tunnel, tunnelIndex) => {
+ const status = getTunnelStatus(tunnelIndex);
+ const statusDisplay = getTunnelStatusDisplay(status);
+ const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
+ const isActionLoading = tunnelActions[tunnelName];
+ const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
+ const isConnected = statusValue === 'CONNECTED';
+ const isConnecting = statusValue === 'CONNECTING';
+ const isDisconnecting = statusValue === 'DISCONNECTING';
+ const isRetrying = statusValue === 'RETRYING';
+ const isWaiting = statusValue === 'WAITING';
+
+ return (
+
+ {/* Tunnel Header */}
+
+
+
+ {statusDisplay.icon}
+
+
+
+ Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
+
+
+ {statusDisplay.text}
+
+
+
+
+ {/* Action Buttons */}
+ {!isActionLoading ? (
+
+ {isConnected ? (
+ <>
+
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"
+ >
+
+ Disconnect
+
+ >
+ ) : isRetrying || isWaiting ? (
+
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"
+ >
+
+ Cancel
+
+ ) : (
+
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"
+ >
+
+ Connect
+
+ )}
+
+ ) : (
+
+
+ {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
+
+ )}
+
+
+
+ {/* Error/Status Reason */}
+ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
+
+
Error:
+ {status.reason}
+ {status.reason && status.reason.includes('Max retries exhausted') && (
+ <>
+
+ >
+ )}
+
+ )}
+
+ {/* Retry Info */}
+ {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
+
+
+ {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
+
+
+ Attempt {status.retryCount} of {status.maxRetries}
+ {status.nextRetryIn && (
+ • Next retry in {status.nextRetryIn} seconds
+ )}
+
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
+
+
No tunnel connections configured
+
+ )}
+
+
+ );
+ }
+
return (
{/* Host Header */}
-
-
- {host.pin &&
}
-
-
- {host.name || `${host.username}@${host.ip}`}
-
-
- {host.ip}:{host.port} • {host.username}
-
+ {!compact && (
+
+
+ {host.pin &&
}
+
+
+ {host.name || `${host.username}@${host.ip}`}
+
+
+ {host.ip}:{host.port} • {host.username}
+
+
-
+ )}
{/* Tags */}
- {host.tags && host.tags.length > 0 && (
+ {!compact && host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 3).map((tag, index) => (
@@ -196,14 +347,16 @@ export function SSHTunnelObject({
)}
-
+ {!compact &&
}
{/* Tunnel Connections */}
-
-
- Tunnel Connections ({host.tunnelConnections.length})
-
+ {!compact && (
+
+
+ Tunnel Connections ({host.tunnelConnections.length})
+
+ )}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
@@ -237,12 +390,6 @@ export function SSHTunnelObject({
- {tunnel.autoStart && (
-
-
- Auto
-
- )}
{/* Action Buttons */}
{!isActionLoading && (
diff --git a/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx b/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx
deleted file mode 100644
index 342935e9..00000000
--- a/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-
-import {
- CornerDownLeft,
- Settings
-} from "lucide-react"
-
-import {
- Button
-} from "@/components/ui/button.tsx"
-
-import {
- Sidebar,
- SidebarContent,
- SidebarGroup,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuItem, SidebarProvider,
-} from "@/components/ui/sidebar.tsx"
-
-import {
- Separator,
-} from "@/components/ui/separator.tsx"
-
-interface SidebarProps {
- onSelectView: (view: string) => void;
-}
-
-export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
- return (
-
-
-
-
-
- Termix / Tunnel
-
-
-
-
-
-
- onSelectView("homepage")}
- variant="outline">
-
- Return
-
-
-
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx b/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx
index 0b6a18ed..d48e1d75 100644
--- a/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx
+++ b/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx
@@ -1,9 +1,5 @@
import React from "react";
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
-import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
-import {Separator} from "@/components/ui/separator.tsx";
-import {Input} from "@/components/ui/input.tsx";
-import {Search} from "lucide-react";
interface TunnelConnection {
sourcePort: number;
@@ -56,128 +52,39 @@ export function SSHTunnelViewer({
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
- const [searchQuery, setSearchQuery] = React.useState("");
- const [debouncedSearch, setDebouncedSearch] = React.useState("");
+ // Single-host view: use first host if present
+ const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
- React.useEffect(() => {
- const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
- return () => clearTimeout(handler);
- }, [searchQuery]);
-
- const filteredHosts = React.useMemo(() => {
- if (!debouncedSearch.trim()) return hosts;
-
- const query = debouncedSearch.trim().toLowerCase();
- return hosts.filter(host => {
- const searchableText = [
- host.name || '',
- host.username,
- host.ip,
- host.folder || '',
- ...(host.tags || []),
- host.authType,
- host.defaultPath || ''
- ].join(' ').toLowerCase();
- return searchableText.includes(query);
- });
- }, [hosts, debouncedSearch]);
-
- const tunnelHosts = React.useMemo(() => {
- return filteredHosts.filter(host =>
- host.enableTunnel &&
- host.tunnelConnections &&
- host.tunnelConnections.length > 0
+ if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
+ return (
+
+
No SSH Tunnels
+
+ Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
+
+
);
- }, [filteredHosts]);
-
- const hostsByFolder = React.useMemo(() => {
- const map: Record
= {};
- tunnelHosts.forEach(host => {
- const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
- if (!map[folder]) map[folder] = [];
- map[folder].push(host);
- });
- return map;
- }, [tunnelHosts]);
-
- const sortedFolders = React.useMemo(() => {
- const folders = Object.keys(hostsByFolder);
- folders.sort((a, b) => {
- if (a === 'Uncategorized') return -1;
- if (b === 'Uncategorized') return 1;
- return a.localeCompare(b);
- });
- return folders;
- }, [hostsByFolder]);
-
- const getSortedHosts = (arr: SSHHost[]) => {
- const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
- const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
- return [...pinned, ...rest];
- };
+ }
return (
-
-
-
-
- SSH Tunnels
-
-
- Manage your SSH tunnel connections
-
+
+
+
SSH Tunnels
+
+
+
+ {activeHost.tunnelConnections.map((t, idx) => (
+ onTunnelAction(action, activeHost, idx)}
+ compact
+ bare
+ />
+ ))}
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-10"
- />
-
-
- {tunnelHosts.length === 0 ? (
-
-
- No SSH Tunnels
-
-
- {searchQuery.trim() ?
- "No hosts match your search criteria." :
- "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
- }
-
-
- ) : (
-
- {sortedFolders.map((folder, idx) => (
-
-
- {folder}
-
-
-
- {getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
-
-
-
- ))}
-
-
-
- ))}
-
- )}
);
diff --git a/src/ui/SSH/ssh-axios.ts b/src/ui/SSH/ssh-axios.ts
index 1c7e39d9..4701a06c 100644
--- a/src/ui/SSH/ssh-axios.ts
+++ b/src/ui/SSH/ssh-axios.ts
@@ -93,6 +93,18 @@ interface ConfigEditorShortcut {
path: string;
}
+export type ServerStatus = {
+ status: 'online' | 'offline';
+ lastChecked: string;
+};
+
+export type ServerMetrics = {
+ cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
+ memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
+ disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
+ lastChecked: string;
+};
+
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sshHostApi = axios.create({
@@ -116,6 +128,13 @@ const configEditorApi = axios.create({
}
})
+const statsApi = axios.create({
+ baseURL: isLocalhost ? 'http://localhost:8085' : '/ssh/stats',
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+})
+
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
@@ -130,6 +149,14 @@ sshHostApi.interceptors.request.use((config) => {
return config;
});
+statsApi.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
@@ -531,4 +558,31 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
}
}
-export {sshHostApi, tunnelApi, configEditorApi};
\ No newline at end of file
+export {sshHostApi, tunnelApi, configEditorApi};
+
+export async function getAllServerStatuses(): Promise
> {
+ try {
+ const response = await statsApi.get('/status');
+ return response.data || {};
+ } catch (error) {
+ throw error;
+ }
+}
+
+export async function getServerStatusById(id: number): Promise {
+ try {
+ const response = await statsApi.get(`/status/${id}`);
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+}
+
+export async function getServerMetricsById(id: number): Promise {
+ try {
+ const response = await statsApi.get(`/metrics/${id}`);
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+}
\ No newline at end of file