import { getErrorMessage } from "@/lib/error-message.js"; /* eslint-disable react-hooks/exhaustive-deps */ import React from "react"; import { Separator } from "@/components/separator.tsx"; import { Button } from "@/components/button.tsx"; import { getServerStatusById, getServerMetricsById, startMetricsPolling, stopMetricsPolling, submitMetricsTOTP, executeSnippet, getSnippets, logActivity, sendMetricsHeartbeat, getSSHHosts, type ServerMetrics, } from "@/main-axios.ts"; import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog"; import { useAreaPreferences } from "@/contexts/UiPreferencesContext"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { hasSnippetInputs } from "@/lib/snippet-variables.ts"; import type { Snippet } from "@/types/ui-types.ts"; import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { useTabsSafe } from "@/shell/TabContext.tsx"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { type StatsConfig, DEFAULT_STATS_CONFIG, } from "@/types/stats-widgets.ts"; import { defaultLayoutFromWidgets, type HostMetricsLayout, } from "@/types/host-metrics.ts"; import { RefreshCw, Server, LayoutDashboard } from "lucide-react"; import { ConnectionLogProvider, useConnectionLog, } from "@/ssh/connection-log/ConnectionLogContext.tsx"; import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx"; import { useConnectionRetry } from "@/lib/useConnectionRetry.ts"; import { runAdaptivePolling } from "@/lib/adaptive-polling.ts"; import type { LogEntry } from "@/types/connection-log.ts"; import { CardGridCanvas, ColumnCountStepper, } from "@/components/card-grid/CardGridCanvas.tsx"; import type { GridCardCatalogEntry } from "@/components/card-grid/types.ts"; import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts"; import { CARD_DEFINITIONS, IMPLEMENTED_CARD_IDS, getCardDefinition, defaultColSpanFor, defaultHeightFor, type MetricCardHistories, } from "./cards"; import { appendGpuHistories } from "./cards/gpu-history"; import { metricsChangeKey } from "./metrics-change-key"; const HISTORY_LEN = 30; interface QuickAction { name: string; snippetId: number; } type ConnectionLogPayload = Omit; type ConnectionLogError = Error & { connectionLogs?: ConnectionLogPayload[]; }; interface HostConfig { id: number; name: string; ip: string; username: string; quickActions?: QuickAction[]; statsConfig?: string | StatsConfig; authType?: string; port?: number; [key: string]: unknown; } interface HostMetricsProps { hostConfig?: HostConfig; title?: string; isVisible?: boolean; isTopbarOpen?: boolean; embedded?: boolean; } function parseStatsConfig(raw?: string | StatsConfig): StatsConfig { if (!raw) return DEFAULT_STATS_CONFIG; try { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; return { ...DEFAULT_STATS_CONFIG, ...parsed }; } catch { return DEFAULT_STATS_CONFIG; } } function HostMetricsInner({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false, }: HostMetricsProps): React.ReactElement { const { t } = useTranslation(); const { addLog, clearLogs } = useConnectionLog(); const { currentTab, removeTab } = useTabsSafe(); const [serverStatus, setServerStatus] = React.useState<"online" | "offline">( "offline", ); const [metrics, setMetrics] = React.useState(null); const [histories, setHistories] = React.useState({ cpu: [], memory: [], disk: [], gpu: {}, }); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [isRefreshing, setIsRefreshing] = React.useState(false); const [executingActions, setExecutingActions] = React.useState>( new Set(), ); const [totpRequired, setTotpRequired] = React.useState(false); const [totpSessionId, setTotpSessionId] = React.useState(null); const [totpPrompt, setTotpPrompt] = React.useState(""); const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden); const [totpVerified, setTotpVerified] = React.useState(false); const [viewerSessionId, setViewerSessionId] = React.useState( null, ); const [editMode, setEditMode] = React.useState(false); const [runningAction, setRunningAction] = React.useState<{ action: QuickAction; snippet: Snippet; } | null>(null); const { confirmWithToast } = useConfirmation(); const activityLoggedRef = React.useRef(false); const activityLoggingRef = React.useRef(false); const snippetsCacheRef = React.useRef(null); const statsConfig = React.useMemo( () => parseStatsConfig(currentHostConfig?.statsConfig), [currentHostConfig?.statsConfig], ); const metricsEnabled = statsConfig.metricsEnabled !== false; const statusCheckEnabled = statsConfig.statusCheckEnabled !== false; const hostId = currentHostConfig?.id ?? null; const { layout, setLayout } = useHostMetricsPreferences(hostId); const metricsPrefs = useAreaPreferences("hostMetrics"); const effectiveLayout: HostMetricsLayout = React.useMemo(() => { // A saved layout is user-authored, so the preset never rewrites it -- it // only decides the shape of the first layout a host gets. if (layout) return layout; return defaultLayoutFromWidgets( statsConfig.enabledWidgets ?? [], metricsPrefs.columns, ); }, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]); // Only render/keep cards that are implemented (metric cards in Phase A). const visibleSlots = React.useMemo( () => effectiveLayout.slots.filter((s) => getCardDefinition(s.id)), [effectiveLayout.slots], ); const cardCatalog: GridCardCatalogEntry[] = React.useMemo( () => IMPLEMENTED_CARD_IDS.map((id) => ({ id, label: t(CARD_DEFINITIONS[id].labelKey), defaultColSpan: defaultColSpanFor(id), defaultHeight: defaultHeightFor(id), })), [t], ); const cardLabel = React.useCallback( (id: string) => { const def = getCardDefinition(id); return def ? t(def.labelKey) : id; }, [t], ); const renderCard = React.useCallback( (id: string) => { const def = getCardDefinition(id); if (!def) return null; return def.render({ metrics, histories, hostId }); }, [metrics, histories, hostId], ); React.useEffect(() => { const onVis = () => setIsPageVisible(!document.hidden); document.addEventListener("visibilitychange", onVis); return () => document.removeEventListener("visibilitychange", onVis); }, []); const isActuallyVisible = isVisible && isPageVisible; React.useEffect(() => { if (!viewerSessionId || !isActuallyVisible) return; const interval = setInterval(() => { sendMetricsHeartbeat(viewerSessionId).catch(() => {}); }, 30000); return () => clearInterval(interval); }, [viewerSessionId, isActuallyVisible]); React.useEffect(() => { if (hostConfig?.id !== currentHostConfig?.id) { setServerStatus("offline"); setMetrics(null); setHistories({ cpu: [], memory: [], disk: [], gpu: {} }); } setCurrentHostConfig(hostConfig); }, [hostConfig?.id]); const logServerActivity = async () => { if ( !currentHostConfig?.id || activityLoggedRef.current || activityLoggingRef.current ) { return; } activityLoggingRef.current = true; activityLoggedRef.current = true; try { const hostName = currentHostConfig.name || `${currentHostConfig.username}@${currentHostConfig.ip}`; await logActivity("server_stats", currentHostConfig.id, hostName); } catch { activityLoggedRef.current = false; } finally { activityLoggingRef.current = false; } }; const pushHistory = React.useCallback((data: ServerMetrics) => { setHistories((prev) => { const add = (arr: number[], v: number | null | undefined) => [...arr, v ?? 0].slice(-HISTORY_LEN); return { cpu: add(prev.cpu, data.cpu?.percent), memory: add(prev.memory, data.memory?.percent), disk: add(prev.disk, data.disk?.percent), gpu: appendGpuHistories(prev.gpu, data.gpu?.gpus, HISTORY_LEN), }; }); }, []); const handleTOTPSubmit = async (totpCode: string) => { if (!totpSessionId || !currentHostConfig) return; try { const result = await submitMetricsTOTP(totpSessionId, totpCode); if (result.success) { setTotpRequired(false); setTotpSessionId(null); setTotpVerified(true); if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId); } else { toast.error(t("hostMetrics.totpFailed")); } } catch { toast.error(t("hostMetrics.totpFailed")); } }; const handleTOTPCancel = async () => { setTotpRequired(false); if (currentHostConfig?.id) { await stopMetricsPolling(currentHostConfig.id).catch(() => {}); } if (currentTab !== null) removeTab(currentTab); }; React.useEffect(() => { const fetchLatest = async () => { if (!hostConfig?.id) return; try { const hosts = await getSSHHosts(); const updated = hosts.find((h) => h.id === hostConfig.id); if (updated) setCurrentHostConfig(updated as unknown as HostConfig); } catch { toast.error(t("hostMetrics.failedToFetchHostConfig")); } }; fetchLatest(); const onChanged = () => fetchLatest(); window.addEventListener("ssh-hosts:changed", onChanged); return () => window.removeEventListener("ssh-hosts:changed", onChanged); }, [hostConfig?.id]); React.useEffect(() => { if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) { if (!statusCheckEnabled) setServerStatus("offline"); return; } let cancelled = false; let lastStatus = serverStatus; const fetchStatus = async () => { try { const res = await getServerStatusById(currentHostConfig.id); const nextStatus = res?.status === "online" ? "online" : "offline"; if (!cancelled) { setServerStatus(nextStatus); } const changed = nextStatus !== lastStatus; lastStatus = nextStatus; return changed; } catch { if (!cancelled) setServerStatus("offline"); throw new Error("Host status check failed"); } }; const minIntervalMs = statsConfig.statusCheckInterval * 1000; const stop = runAdaptivePolling(fetchStatus, { minIntervalMs, maxIntervalMs: Math.min(120_000, minIntervalMs * 6), stablePollsPerStep: 3, }); return () => { cancelled = true; stop(); }; }, [ currentHostConfig?.id, statusCheckEnabled, statsConfig.statusCheckInterval, isActuallyVisible, ]); const stopMetricsPollingRef = React.useRef<(() => void) | null>(null); const fetchMetrics = React.useCallback(async (): Promise => { if (!currentHostConfig?.id) return; if (currentHostConfig.authType === "none") { toast.error(t("hostMetrics.noneAuthNotSupported")); if (currentTab !== null) removeTab(currentTab); throw new Error(t("hostMetrics.noneAuthNotSupported")); } if (!totpVerified) { addLog({ type: "info", stage: "stats_connecting", message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`, }); const result = await startMetricsPolling(currentHostConfig.id); result?.connectionLogs?.forEach((log) => addLog(log as ConnectionLogPayload), ); if (result.requires_totp) { setTotpRequired(true); setTotpSessionId(result.sessionId || null); setTotpPrompt(result.prompt || "Verification code"); // The TOTP dialog gates further progress; retry loop pauses via `enabled`. return; } if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId); } // The connect above only opens the SSH session; the first real sample is // collected asynchronously on the backend (status check, then metrics // exec), so it isn't ready the instant the connection succeeds. Give it // a few short retries before treating the initial fetch as a failure. let data = await getServerMetricsById(currentHostConfig.id); for (let i = 0; !data && i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 1500)); data = await getServerMetricsById(currentHostConfig.id); } if (!data) { throw new Error(t("hostMetrics.connectionFailed")); } setMetrics(data); pushHistory(data); setServerStatus("online"); logServerActivity(); addLog({ type: "success", stage: "connected", message: t("terminal.connected"), }); let signature = metricsChangeKey(data); const minIntervalMs = statsConfig.metricsInterval * 1000; stopMetricsPollingRef.current?.(); stopMetricsPollingRef.current = runAdaptivePolling( async () => { const next = await getServerMetricsById(currentHostConfig.id); if (!next) throw new Error(t("hostMetrics.connectionFailed")); const nextSignature = metricsChangeKey(next); const changed = nextSignature !== signature; signature = nextSignature; setMetrics(next); pushHistory(next); return changed; }, { minIntervalMs, maxIntervalMs: Math.min(120_000, minIntervalMs * 6), stablePollsPerStep: 3, }, { runImmediately: false }, ); }, [ currentHostConfig, totpVerified, statsConfig.metricsInterval, addLog, t, currentTab, removeTab, pushHistory, ]); const metricsRetry = useConnectionRetry({ connect: async () => { try { await fetchMetrics(); if (!totpRequired) metricsRetry.markConnected(); } catch (error: unknown) { const logError = error as ConnectionLogError; if (logError.connectionLogs) { logError.connectionLogs.forEach((log) => addLog(log)); } else { addLog({ type: "error", stage: "connection", message: error instanceof Error ? error.message : t("hostMetrics.connectionFailed"), }); } metricsRetry.markFailed(); } }, enabled: isPageVisible && metricsEnabled && !totpRequired && !!currentHostConfig?.id, autoStart: false, }); const metricsRetryRef = React.useRef(metricsRetry); metricsRetryRef.current = metricsRetry; // Connects once per host and stays connected while this tab exists, even // when the user switches to another tab and back. Only the browser tab // going into the background (isPageVisible) pauses/resumes it -- switching // between Termix tabs must not tear down and reconnect the session. React.useEffect(() => { if (!metricsEnabled || !currentHostConfig?.id) return; let cancelled = false; const stopMetrics = async () => { stopMetricsPollingRef.current?.(); stopMetricsPollingRef.current = null; if (currentHostConfig?.id) { await stopMetricsPolling( currentHostConfig.id, viewerSessionId || undefined, ).catch(() => {}); } }; const debounce = setTimeout(() => { if (cancelled) return; if (isPageVisible) { clearLogs(); metricsRetryRef.current.reset(); metricsRetryRef.current.retryNow(); } else { stopMetrics(); } }, 500); return () => { cancelled = true; clearTimeout(debounce); stopMetricsPollingRef.current?.(); stopMetricsPollingRef.current = null; if (currentHostConfig?.id) { stopMetricsPolling(currentHostConfig.id).catch(() => {}); } }; }, [currentHostConfig?.id, isPageVisible, metricsEnabled]); // After a successful TOTP submit, resume the connect flow immediately. React.useEffect(() => { if (totpVerified && !totpRequired) { metricsRetryRef.current.reset(); metricsRetryRef.current.retryNow(); } }, [totpVerified, totpRequired]); const wrapperStyle: React.CSSProperties = embedded ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" } : { opacity: isVisible ? 1 : 0, margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px", height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)", }; const handleRefresh = async () => { if (!currentHostConfig?.id) return; if (metricsRetry.status !== "connected") { metricsRetry.retryNow(); return; } try { setIsRefreshing(true); const res = await getServerStatusById(currentHostConfig.id); setServerStatus(res?.status === "online" ? "online" : "offline"); const data = await getServerMetricsById(currentHostConfig.id); if (data) { setMetrics(data); pushHistory(data); } } catch { setServerStatus("offline"); } finally { setIsRefreshing(false); } }; async function executeQuickAction( action: QuickAction, inputValues: Record, ) { if (!currentHostConfig) return; setExecutingActions((prev) => new Set(prev).add(action.snippetId)); toast.loading( t("hostMetrics.executingQuickAction", { name: action.name }), { id: `quick-action-${action.snippetId}`, }, ); try { const result = await executeSnippet( action.snippetId, currentHostConfig.id, Object.keys(inputValues).length > 0 ? inputValues : undefined, ); if (result.success) { toast.success( t("hostMetrics.quickActionSuccess", { name: action.name }), { id: `quick-action-${action.snippetId}`, description: result.output?.substring(0, 200), duration: 5000, }, ); } else { toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), { id: `quick-action-${action.snippetId}`, description: result.error || result.output, duration: 5000, }); } } catch (error) { toast.error(t("hostMetrics.quickActionError", { name: action.name }), { id: `quick-action-${action.snippetId}`, description: getErrorMessage(error), duration: 5000, }); } finally { setExecutingActions((prev) => { const next = new Set(prev); next.delete(action.snippetId); return next; }); } } function runQuickActionWithConfirm( action: QuickAction, inputValues: Record, ) { const shouldConfirm = localStorage.getItem("confirmSnippetExecution") === "true"; const run = () => void executeQuickAction(action, inputValues); if (!shouldConfirm) { run(); return; } confirmWithToast( t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }), run, t("newUi.sidebar.snippets.confirmRunButton"), t("newUi.sidebar.snippets.cancel"), { confirmOnEnter: true, duration: 6000 }, ); } async function runQuickAction(action: QuickAction) { if (!currentHostConfig) return; try { if (!snippetsCacheRef.current) { snippetsCacheRef.current = (await getSnippets()) as unknown as Snippet[]; } const snippet = snippetsCacheRef.current?.find( (s) => s.id === action.snippetId, ); if (snippet && hasSnippetInputs(snippet.content)) { setRunningAction({ action, snippet }); return; } } catch { // fall through and run with no inputs if the snippet lookup fails } runQuickActionWithConfirm(action, {}); } const showCards = metricsEnabled && metricsRetry.status === "connected" && metrics; const showOffline = metricsEnabled && metricsRetry.status !== "connecting" && !metrics && serverStatus === "offline"; return (
{!totpRequired && (metricsRetry.status === "connected" || showOffline) && (

{title}

{currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0 && ( <>
{currentHostConfig.quickActions.map( (action, index) => { const isExecuting = executingActions.has( action.snippetId, ); return ( ); }, )}
)} {editMode && ( <> setLayout({ ...effectiveLayout, columns }) } /> )}
)} {editMode && (
{t("hostMetrics.editModeInstructions")}
)} {showCards && (
setLayout({ slots: slots as HostMetricsLayout["slots"], columns, }) } />
)}
{metricsEnabled && !totpRequired && (

{t("hostMetrics.serverOffline")}

{t("hostMetrics.cannotFetchMetrics")}

) : undefined } /> )}
{runningAction && ( setRunningAction(null)} onConfirm={(_resolvedContent, inputValues) => { const action = runningAction.action; setRunningAction(null); runQuickActionWithConfirm(action, inputValues); }} /> )} ); } export function HostMetricsTab(props: HostMetricsProps): React.ReactElement { return ( ); }