mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
844 lines
27 KiB
TypeScript
844 lines
27 KiB
TypeScript
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<LogEntry, "id" | "timestamp">;
|
|
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<ServerMetrics | null>(null);
|
|
const [histories, setHistories] = React.useState<MetricCardHistories>({
|
|
cpu: [],
|
|
memory: [],
|
|
disk: [],
|
|
gpu: {},
|
|
});
|
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
|
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
|
new Set(),
|
|
);
|
|
const [totpRequired, setTotpRequired] = React.useState(false);
|
|
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
|
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
|
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
|
const [totpVerified, setTotpVerified] = React.useState(false);
|
|
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
|
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<Snippet[] | null>(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<void> => {
|
|
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<string, string>,
|
|
) {
|
|
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<string, string>,
|
|
) {
|
|
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 (
|
|
<div
|
|
style={wrapperStyle}
|
|
className="relative flex flex-col overflow-hidden"
|
|
>
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
|
|
{!totpRequired &&
|
|
(metricsRetry.status === "connected" || showOffline) && (
|
|
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
|
<Server className="size-5 text-accent-brand" />
|
|
</div>
|
|
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
|
</div>
|
|
<div className="flex items-center gap-0">
|
|
{currentHostConfig?.quickActions &&
|
|
currentHostConfig.quickActions.length > 0 && (
|
|
<>
|
|
<div className="mr-3 flex flex-wrap gap-2">
|
|
{currentHostConfig.quickActions.map(
|
|
(action, index) => {
|
|
const isExecuting = executingActions.has(
|
|
action.snippetId,
|
|
);
|
|
return (
|
|
<Button
|
|
key={index}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-xs font-semibold"
|
|
disabled={isExecuting}
|
|
onClick={() => void runQuickAction(action)}
|
|
>
|
|
{isExecuting ? (
|
|
<>
|
|
<RefreshCw className="mr-1 size-3 animate-spin" />
|
|
{action.name}
|
|
</>
|
|
) : (
|
|
action.name
|
|
)}
|
|
</Button>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-3 h-8"
|
|
/>
|
|
</>
|
|
)}
|
|
{editMode && (
|
|
<>
|
|
<ColumnCountStepper
|
|
columns={effectiveLayout.columns}
|
|
onChange={(columns) =>
|
|
setLayout({ ...effectiveLayout, columns })
|
|
}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-2 text-xs text-muted-foreground"
|
|
onClick={() =>
|
|
setLayout(
|
|
defaultLayoutFromWidgets(
|
|
statsConfig.enabledWidgets ?? [],
|
|
),
|
|
)
|
|
}
|
|
>
|
|
{t("hostMetrics.reset")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant={editMode ? "default" : "ghost"}
|
|
size="icon"
|
|
className="ml-1"
|
|
title={t("hostMetrics.customize")}
|
|
onClick={() => setEditMode((v) => !v)}
|
|
>
|
|
<LayoutDashboard className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="default"
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="ml-1 gap-2 font-semibold"
|
|
>
|
|
<RefreshCw
|
|
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
|
/>
|
|
{t("hostMetrics.refresh")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editMode && (
|
|
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
|
|
<LayoutDashboard className="size-3.5 shrink-0 text-accent-brand" />
|
|
<span className="text-xs font-semibold text-accent-brand">
|
|
{t("hostMetrics.editModeInstructions")}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{showCards && (
|
|
<div className="px-3 pt-3 pb-3">
|
|
<CardGridCanvas
|
|
slots={visibleSlots}
|
|
columns={effectiveLayout.columns}
|
|
editMode={editMode}
|
|
renderCard={renderCard}
|
|
cardCatalog={cardCatalog}
|
|
cardLabel={cardLabel}
|
|
onChange={(slots, columns) =>
|
|
setLayout({
|
|
slots: slots as HostMetricsLayout["slots"],
|
|
columns,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{metricsEnabled && !totpRequired && (
|
|
<ConnectionScreen
|
|
status={showOffline ? "connected" : metricsRetry.status}
|
|
message={t("hostMetrics.connecting")}
|
|
attempt={metricsRetry.attempt}
|
|
maxAttempts={metricsRetry.maxAttempts}
|
|
nextRetryInMs={metricsRetry.nextRetryInMs}
|
|
onManualRetry={metricsRetry.retryNow}
|
|
logPosition={
|
|
metricsRetry.status === "error" ||
|
|
metricsRetry.status === "disconnected"
|
|
? "top"
|
|
: "bottom"
|
|
}
|
|
emptyState={
|
|
showOffline ? (
|
|
<div className="text-center opacity-40">
|
|
<Server className="mx-auto mb-4 size-16" />
|
|
<p className="text-xl font-bold uppercase tracking-widest">
|
|
{t("hostMetrics.serverOffline")}
|
|
</p>
|
|
<p className="text-sm font-semibold">
|
|
{t("hostMetrics.cannotFetchMetrics")}
|
|
</p>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<TOTPDialog
|
|
isOpen={totpRequired}
|
|
prompt={totpPrompt}
|
|
onSubmit={handleTOTPSubmit}
|
|
onCancel={handleTOTPCancel}
|
|
backgroundColor="var(--bg-canvas)"
|
|
/>
|
|
|
|
{runningAction && (
|
|
<SnippetVariablesDialog
|
|
snippet={runningAction.snippet}
|
|
host={
|
|
currentHostConfig
|
|
? {
|
|
ip: currentHostConfig.ip,
|
|
username: currentHostConfig.username,
|
|
port: currentHostConfig.port,
|
|
name: currentHostConfig.name,
|
|
}
|
|
: null
|
|
}
|
|
onCancel={() => setRunningAction(null)}
|
|
onConfirm={(_resolvedContent, inputValues) => {
|
|
const action = runningAction.action;
|
|
setRunningAction(null);
|
|
runQuickActionWithConfirm(action, inputValues);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function HostMetricsTab(props: HostMetricsProps): React.ReactElement {
|
|
return (
|
|
<ConnectionLogProvider>
|
|
<HostMetricsInner {...props} />
|
|
</ConnectionLogProvider>
|
|
);
|
|
}
|