Compare commits

...
30 changed files with 2644 additions and 316 deletions

No files matched your search

+8 -3
View File
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search name, address or group",
"monitor.search": "Search hosts, groups, tags or notes",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
@@ -34,6 +34,13 @@ export const enMonitorMessages = {
"monitor.sort.name": "Name",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.noMatches": "No matching hosts",
"monitor.latency": "Latency",
"monitor.responseLatency": "Response latency (includes remote processing)",
"monitor.tags": "Tags",
"monitor.notes": "Notes",
"monitor.visibleColumns": "Visible columns",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Resize {column} column",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
"monitor.status": "Status",
@@ -53,8 +60,6 @@ export const enMonitorMessages = {
"monitor.memoryTrend": "Memory trend",
"monitor.downloadTrend": "Download trend",
"monitor.uploadTrend": "Upload trend",
"monitor.memoryOnlyHint":
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
"monitor.name": "Panel name",
"monitor.description": "Description (optional)",
"monitor.chooseHosts": "Choose hosts",
+8 -3
View File
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar nombre, dirección o grupo",
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
@@ -34,6 +34,13 @@ export const esMonitorMessages = {
"monitor.sort.name": "Nombre",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.latency": "Latencia",
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
"monitor.tags": "Etiquetas",
"monitor.notes": "Notas",
"monitor.visibleColumns": "Columnas visibles",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Ajustar la columna {column}",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
"monitor.status": "Estado",
@@ -53,8 +60,6 @@ export const esMonitorMessages = {
"monitor.memoryTrend": "Tendencia de memoria",
"monitor.downloadTrend": "Tendencia de descarga",
"monitor.uploadTrend": "Tendencia de subida",
"monitor.memoryOnlyHint":
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
"monitor.name": "Nombre del panel",
"monitor.description": "Descripción (opcional)",
"monitor.chooseHosts": "Elegir equipos",
+8 -3
View File
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
"monitor.stopAll": "Остановить всё",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Имя, адрес или группа",
"monitor.search": "Поиск узлов, групп, тегов и заметок",
"monitor.interval": "Интервал обновления",
"monitor.range": "Период графика",
"monitor.seconds": "Каждые {count} с",
@@ -33,6 +33,13 @@ export const ruMonitorMessages = {
"monitor.sort.name": "Имя",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.latency": "Задержка",
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
"monitor.tags": "Теги",
"monitor.notes": "Заметки",
"monitor.visibleColumns": "Видимые столбцы",
"monitor.terminal": "Терминал",
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
"monitor.status": "Состояние",
@@ -52,8 +59,6 @@ export const ruMonitorMessages = {
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.memoryOnlyHint":
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
+8 -3
View File
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索名称、地址或分组",
"monitor.search": "搜索主机、分组、标签或备注",
"monitor.interval": "更新频率",
"monitor.range": "趋势范围",
"monitor.seconds": "每 {count} 秒",
@@ -32,6 +32,13 @@ export const zhCNMonitorMessages = {
"monitor.sort.name": "名称",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.noMatches": "没有符合条件的主机",
"monitor.latency": "延迟",
"monitor.responseLatency": "响应延迟(含远端处理)",
"monitor.tags": "标签",
"monitor.notes": "备注",
"monitor.visibleColumns": "显示的列",
"monitor.terminal": "终端",
"monitor.resizeColumn": "调整「{column}」列宽",
"monitor.host": "主机",
"monitor.hosts": "主机",
"monitor.status": "状态",
@@ -51,8 +58,6 @@ export const zhCNMonitorMessages = {
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.memoryOnlyHint":
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
+8 -3
View File
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋名稱、位址或群組",
"monitor.search": "搜尋主機、群組、標籤或備註",
"monitor.interval": "更新頻率",
"monitor.range": "趨勢範圍",
"monitor.seconds": "每 {count} 秒",
@@ -32,6 +32,13 @@ export const zhTWMonitorMessages = {
"monitor.sort.name": "名稱",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.latency": "延遲",
"monitor.responseLatency": "回應延遲(含遠端處理)",
"monitor.tags": "標籤",
"monitor.notes": "備註",
"monitor.visibleColumns": "顯示的欄位",
"monitor.terminal": "終端",
"monitor.resizeColumn": "調整「{column}」欄寬",
"monitor.host": "主機",
"monitor.hosts": "主機",
"monitor.status": "狀態",
@@ -51,8 +58,6 @@ export const zhTWMonitorMessages = {
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.memoryOnlyHint":
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
publishMonitorSnapshot,
getMonitorHistoryCursor,
} from "./monitorRuntimeStore";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "gpu-cursor-test",
epoch: 1,
takenAt: 1000,
running: ["panel"],
count: 1,
hostCount: 1,
activeSessions: [],
startedAt: { panel: 0 },
hosts: {
host: {
historyId: "history",
status: "live",
at: 1000,
statsAt: 1001,
points: [
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
],
},
},
};
publishMonitorSnapshot(snapshot);
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 1001,
historyId: "history",
});
publishMonitorSnapshot({
...snapshot,
historyMode: "append",
takenAt: 2000,
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
});
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 2001,
historyId: "history",
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
return {
runtimeId: snapshot.runtimeId,
epoch: snapshot.epoch,
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState, type PointerEvent } from "react";
import {
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
} from "../../infrastructure/config/storageKeys";
export function useMonitorTableColumns() {
const [widths, setWidths] = useState(() =>
normalizeMonitorColumnWidths(
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
),
);
const [hidden, setHidden] = useState(() =>
normalizeMonitorHiddenColumns(
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
),
);
const currentHidden = useRef(hidden);
const setVisible = (key: MonitorColumn, visible: boolean) => {
const previous = currentHidden.current;
const next = normalizeMonitorHiddenColumns(
visible ? previous.filter((item) => item !== key) : [...previous, key],
);
currentHidden.current = next;
setHidden(next);
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
};
const current = useRef(widths);
const cleanup = useRef<(() => void) | null>(null);
useEffect(() => () => cleanup.current?.(), []);
const resize = (key: MonitorColumn, width: number, persist = true) => {
current.current = {
...current.current,
[key]: clampMonitorColumnWidth(key, width),
};
setWidths(current.current);
if (persist)
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
};
const startResize = (
key: MonitorColumn,
event: PointerEvent<HTMLElement>,
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
cleanup.current?.();
const pointerId = event.pointerId;
const handle = event.currentTarget;
handle.setPointerCapture(pointerId);
const start = event.clientX;
const width = current.current[key];
const cursor = document.body.style.cursor;
const userSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const move = (next: globalThis.PointerEvent) => {
if (next.pointerId === pointerId)
resize(key, width + next.clientX - start, false);
};
const finish = (next: globalThis.PointerEvent) => {
if (next.pointerId !== pointerId) return;
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
const blur = () => {
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
cleanup.current = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
window.removeEventListener("blur", blur);
if (handle.hasPointerCapture(pointerId))
handle.releasePointerCapture(pointerId);
document.body.style.cursor = cursor;
document.body.style.userSelect = userSelect;
cleanup.current = null;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
window.addEventListener("blur", blur);
};
return { widths, resize, startResize, hidden, setVisible };
}
+7 -1
View File
@@ -145,10 +145,16 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
};
// Content Component (wraps children with scroll)
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
children,
className,
scrollMode = "custom",
}) => {
if (scrollMode === "native") return (
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
</div>
);
return (
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
+275
View File
@@ -0,0 +1,275 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
import {
MONITOR_CARD_HEIGHT,
MONITOR_COLUMNS,
monitorGridColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuCheckboxItem,
ContextMenuLabel,
} from "../ui/context-menu";
export function MonitorHostCollection<T extends { id: string }>({
items,
view,
layoutKey,
pinnedId,
renderCard,
renderCells,
rowProps,
renderMenu,
}: {
items: T[];
view: "list" | "charts";
layoutKey: string;
pinnedId?: string;
renderCard: (item: T) => React.ReactNode;
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
renderMenu: (item: T) => React.ReactNode;
}) {
const { t } = useI18n();
const scrollRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1248);
const { widths, resize, startResize, hidden, setVisible } =
useMonitorTableColumns();
const visibleColumns = MONITOR_COLUMNS.filter(
({ key }) => !hidden.includes(key),
);
const columns =
view === "charts" ? monitorGridColumns(width, items.length) : 1;
const scrollMargin = view === "charts" ? 16 : 56;
const pinnedIndex = pinnedId
? items.findIndex((item) => item.id === pinnedId)
: -1;
const getItemKey = useCallback(
(index: number) => items[index * columns]?.id ?? index,
[items, columns],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
count: Math.ceil(items.length / columns),
getScrollElement: () => scrollRef.current,
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
gap: view === "charts" ? 16 : 8,
overscan: view === "charts" ? 2 : 5,
getItemKey,
scrollMargin,
initialRect: { width: 1280, height: 800 },
rangeExtractor: useCallback(
(range) => {
const visible = defaultRangeExtractor(range);
const pinned = Math.floor(pinnedIndex / columns);
return pinnedIndex < 0 || visible.includes(pinned)
? visible
: [...visible, pinned].sort((a, b) => a - b);
},
[pinnedIndex, columns],
),
});
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element) return;
const measure = () => setWidth(Math.max(0, element.clientWidth));
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
virtualizer.measure();
}, [columns, view, hidden, virtualizer]);
useLayoutEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
virtualizer.scrollToOffset(0);
}, [layoutKey, virtualizer]);
const template = visibleColumns
.map((column) => `${widths[column.key]}px`)
.join(" ");
const tableWidth = visibleColumns.reduce(
(sum, column) => sum + widths[column.key],
0,
);
return (
<div className="min-h-0 min-w-0 flex-1 px-4">
<div
ref={scrollRef}
className="h-full min-w-0 overflow-auto"
data-section="vault-monitor-list"
data-monitor-virtual={view}
>
<div className="py-4">
{view === "charts" ? (
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
data-monitor-virtual-row={row.index}
className="absolute left-0 top-0 grid w-full gap-4"
style={{
height: MONITOR_CARD_HEIGHT,
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{items
.slice(row.index * columns, (row.index + 1) * columns)
.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger asChild>
{renderCard(item)}
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(item)}
</ContextMenuContent>
</ContextMenu>
))}
</div>
))}
</div>
) : (
<table
className="block text-xs"
style={{ width: tableWidth }}
aria-rowcount={items.length + 1}
>
<ContextMenu>
<ContextMenuTrigger asChild>
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
<tr
className="grid h-10"
style={{ gridTemplateColumns: template }}
>
{visibleColumns.map((column) => (
<th
key={column.key}
data-monitor-column={column.key}
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
>
<span
className="truncate"
title={t(`monitor.${column.key}`)}
>
{column.key === "terminal" ? (
<span className="sr-only">
{t("monitor.openTerminal")}
</span>
) : (
t(`monitor.${column.key}`)
)}
</span>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("monitor.resizeColumn", {
column: t(`monitor.${column.key}`),
})}
aria-valuenow={widths[column.key]}
aria-valuemin={column.min}
aria-valuemax={
column.key === "terminal" ? 200 : 640
}
tabIndex={0}
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
onPointerDown={(event) =>
startResize(column.key, event)
}
onDoubleClick={() =>
resize(column.key, column.width)
}
onKeyDown={(event) => {
if (
event.key === "ArrowLeft" ||
event.key === "ArrowRight"
) {
event.preventDefault();
resize(
column.key,
widths[column.key] +
(event.key === "ArrowRight" ? 1 : -1) *
(event.shiftKey ? 32 : 8),
);
}
}}
/>
</th>
))}
</tr>
</thead>
</ContextMenuTrigger>
<ContextMenuContent data-monitor-column-menu>
<ContextMenuLabel>
{t("monitor.visibleColumns")}
</ContextMenuLabel>
{MONITOR_COLUMNS.map(({ key }) => (
<ContextMenuCheckboxItem
key={key}
data-monitor-toggle-column={key}
checked={!hidden.includes(key)}
disabled={key === "host" || key === "terminal"}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) =>
setVisible(key, checked === true)
}
>
{t(`monitor.${key}`)}
</ContextMenuCheckboxItem>
))}
</ContextMenuContent>
</ContextMenu>
<tbody
className="relative block"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => {
const cells = renderCells(items[row.index]);
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
<tr
{...rowProps(items[row.index])}
ref={virtualizer.measureElement}
data-index={row.index}
aria-rowindex={row.index + 2}
style={{
display: "grid",
gridTemplateColumns: template,
minHeight: 72,
position: "absolute",
top: 0,
left: 0,
width: tableWidth,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{visibleColumns.map(({ key }) => (
<React.Fragment key={key}>
{cells[key]}
</React.Fragment>
))}
</tr>
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(items[row.index])}
</ContextMenuContent>
</ContextMenu>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Badge } from "../ui/badge";
export function MonitorHostLatency({
value,
responseValue,
}: {
value?: number | null;
responseValue?: number | null;
}) {
const { t } = useI18n();
const isValid = (n: number | null | undefined): n is number =>
typeof n === "number" && Number.isFinite(n) && n >= 0;
const usesResponse = !isValid(value) && isValid(responseValue);
const latency = usesResponse ? responseValue : value;
const known = isValid(latency);
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
const tone = !known
? "text-muted-foreground"
: bars === 4
? "text-emerald-600 dark:text-emerald-400"
: bars === 2
? "text-amber-500 dark:text-amber-400"
: "text-red-600 dark:text-red-400";
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
data-monitor-latency
data-monitor-signal-bars={bars}
>
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
{[4, 7, 10, 13].map((height, index) => (
<rect
key={height}
x={1 + index * 4}
y={14 - height}
width="3"
height={height}
rx="0.8"
fill="currentColor"
opacity={index < bars ? 1 : 0.18}
/>
))}
</svg>
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
</Tooltip>
);
}
const tooltipClass =
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
if (!tags.length)
return (
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
--
</span>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
data-monitor-tags
>
<Badge
variant="secondary"
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
>
{tags[0]}
</Badge>
{tags.length > 1 && (
<span className="shrink-0 text-[10px] text-muted-foreground">
+{tags.length - 1}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="max-w-full break-all text-[10px] font-normal"
>
{tag}
</Badge>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
export function MonitorHostNotes({
notes,
focusable = false,
}: {
notes?: string;
focusable?: boolean;
}) {
if (!notes?.trim())
return <span className="text-[10px] text-muted-foreground">--</span>;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-monitor-notes
tabIndex={focusable ? 0 : undefined}
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
>
{notes}
</span>
</TooltipTrigger>
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
{notes}
</TooltipContent>
</Tooltip>
);
}
+21 -1
View File
@@ -10,6 +10,26 @@ import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorHostLatency } from "./MonitorHostMetadata";
import { TooltipProvider } from "../ui/tooltip";
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
for (const [ping, response, bars, label] of [
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
[0, 500, "4", "Latency: 0 ms"],
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
[null, null, "0", "Latency: --"],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><TooltipProvider>
<MonitorHostLatency value={ping} responseValue={response} />
</TooltipProvider></I18nProvider>,
)).window.document;
const signal = document.querySelector("[data-monitor-latency]")!;
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
assert.equal(signal.getAttribute("aria-label"), label);
}
});
const now = 1_800_000_000_000;
const point = (ago: number, cpu: number | null): MonitorPoint => ({
@@ -161,7 +181,7 @@ for (const view of ["list", "charts"] as const) {
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const network = document.querySelector("tbody tr")!.children[5];
const network = document.querySelector("[data-monitor-cell=network]")!;
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
+370 -261
View File
@@ -6,6 +6,7 @@ import {
Copy,
Cpu,
Gauge,
Gpu,
HardDrive,
LayoutGrid,
List,
@@ -38,6 +39,7 @@ import type { Host } from "../../domain/models";
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { ContextMenuItem } from "../ui/context-menu";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
@@ -70,6 +72,9 @@ import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { toast } from "../ui/toast";
import { TooltipProvider } from "../ui/tooltip";
import { MonitorHostCollection } from "./MonitorHostCollection";
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
const emptyMonitorPoints: MonitorPoint[] = [];
const percent = (n: number | null | undefined) =>
@@ -214,22 +219,15 @@ export function MonitorPanels({
} | null>(null);
const [selecting, setSelecting] = useState(false);
const [detail, setDetail] = useState<string | null>(null);
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [login, setLogin] = useState({
hostId: "",
username: "",
password: "",
});
useEffect(
() => setLogin({ hostId: "", username: "", password: "" }),
[detail],
);
const panel = panels.find((p) => p.id === selected) ?? panels[0];
const running = !!panel && runtime.running.includes(panel.id);
const startEdit = (base: MonitorPanel | null) => {
setDetail(null);
setRecovery(null);
setSelecting(false);
setError(null);
setEditor({
@@ -305,28 +303,39 @@ export function MonitorPanels({
toast.error(t("monitor.saveFailed"));
}
};
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const orderedHostIds = useMemo(() => {
if (!panel) return [];
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
);
}, [panel, hosts]);
return panel.sort === "manual"
? panel.hostIds
: [...panel.hostIds].sort((a, b) =>
(hostById.get(a)?.label ?? a).localeCompare(
hostById.get(b)?.label ?? b,
),
);
}, [panel, hostById]);
const rows = useMemo(() => {
if (!panel) return [];
const q = search.trim().toLocaleLowerCase();
const rows = orderedHostIds.map((id) => ({
id,
host: hosts.find((h) => h.id === id),
host: hostById.get(id),
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
}));
return rows.filter(
({ host }) => !q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
({ host }) =>
!q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
.toLocaleLowerCase()
.includes(q),
);
}, [panel, orderedHostIds, hosts, runtime, search]);
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
}, [panel, orderedHostIds, hostById, runtime, search]);
const dragProps = (
hostId: string,
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
draggable: true,
"data-monitor-host": hostId,
onDragStart: (event) => {
@@ -336,7 +345,12 @@ export function MonitorPanels({
setDragging({ panelId: panel.id, hostId });
},
onDragOver: (event) => {
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
if (
!panel ||
dragging?.panelId !== panel.id ||
dragging.hostId === hostId
)
return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropTarget(hostId);
@@ -344,10 +358,11 @@ export function MonitorPanels({
onDrop: (event) => {
if (!panel || dragging?.panelId !== panel.id) return;
event.preventDefault();
if (dragging.hostId !== hostId) void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
if (dragging.hostId !== hostId)
void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
setDragging(null);
setDropTarget(null);
},
@@ -362,18 +377,24 @@ export function MonitorPanels({
: !running
? "stopped"
: state?.status === "live" &&
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
Date.now() - state.at >
Math.max(
5000,
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
)
? "stale"
: (state?.status ?? "queued");
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
<span
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
status(host, state) === "live"
? "text-emerald-700 dark:text-emerald-400"
: ["error", "disconnected", "missing"].includes(status(host, state))
? "text-destructive"
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
: ["login", "stale", "unsupported", "limit"].includes(
status(host, state),
)
? "text-amber-700 dark:text-amber-400"
: "text-muted-foreground",
)}
@@ -382,7 +403,9 @@ export function MonitorPanels({
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-current"
/>
{t(`monitor.status.${status(host, state)}`)}
<span className="truncate">
{t(`monitor.status.${status(host, state)}`)}
</span>
</span>
);
const renderMetrics = (
@@ -445,6 +468,10 @@ export function MonitorPanels({
};
const hostDetail = hosts.find((h) => h.id === detail);
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
const detailAvailable = status(hostDetail, detailState) === "live";
useEffect(() => {
if (detail && !detailAvailable) setDetail(null);
}, [detail, detailAvailable]);
return (
<section
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
@@ -647,35 +674,76 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
<div className="p-4">
{!rows.length ? (
<p className="p-8 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</p>
) : panel.view === "charts" ? (
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
{rows.map(({ id, host, state }) => (
{!rows.length ? (
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</div>
) : (
<TooltipProvider delayDuration={180}>
<MonitorHostCollection
items={rows}
view={panel.view}
layoutKey={`${panel.id}:${panel.view}:${search}`}
pinnedId={
dragging?.panelId === panel.id ? dragging.hostId : undefined
}
renderMenu={({ id, host, state }) => (
<>
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
{t("monitor.openTerminal")}
</ContextMenuItem>
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
{t("monitor.retry")}
</ContextMenuItem>
)}
</>
)}
rowProps={({ id, host, state }) => ({
...dragProps(id),
className: cn(
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" && "text-muted-foreground opacity-50",
),
})}
renderCard={({ id, host, state }) => (
<article
{...dragProps(id)}
className={cn(
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" && "opacity-50",
)}
>
<button
key={id}
{...dragProps(id)}
className={cn(
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
type="button"
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
aria-disabled={status(host, state) !== "live"}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
<div className="mb-5 flex items-center gap-3">
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
{host ? (
<DistroAvatar host={host} fallback={host.label} size="log" />
<DistroAvatar
host={host}
fallback={host.label}
size="log"
/>
) : (
<Server size={24} className="shrink-0 text-muted-foreground" />
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-foreground">
@@ -685,20 +753,39 @@ export function MonitorPanels({
{host?.hostname ?? id}
</p>
</div>
{stateBadge(host, state)}
</div>
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
<div className="min-w-0 flex-1">
<MonitorHostTags tags={host?.tags} />
</div>
<div className="min-w-0 max-w-[40%]">
{stateBadge(host, state)}
</div>
</div>
<div
className={cn(
"w-full min-w-0",
status(host, state) !== "live" && "opacity-60",
)}
>
{renderMetrics(state)}
<div className="mt-4">
<div className="mt-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
<HardDrive
size={13}
className="text-amber-600 dark:text-amber-400"
/>
<span>{t("monitor.disks")}</span>
</div>
<MonitorDiskUsage stats={state?.stats} limit={2} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<span className="flex items-center gap-1.5 py-1">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
@@ -706,7 +793,10 @@ export function MonitorPanels({
)}
</span>
<span className="flex items-center gap-1.5 py-1">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
@@ -715,127 +805,169 @@ export function MonitorPanels({
</span>
</div>
</div>
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
<Clock3 size={11} />
{state?.at
? t("monitor.updatedAt", {
time: new Date(state.at).toLocaleTimeString(),
})
: t("monitor.noSamples")}
</p>
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<div className="min-w-0 flex-1">
<MonitorHostNotes notes={host?.notes} />
</div>
<span className="inline-flex shrink-0 items-center gap-1">
<Clock3 size={11} />
{state?.at
? new Date(state.at).toLocaleTimeString()
: t("monitor.noSamples")}
</span>
</div>
</button>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
<thead className="text-muted-foreground">
<tr>
{[
"host",
"status",
"cpu",
"memory",
"disks",
"network",
"updated",
].map((k) => (
<th
key={k}
className="px-3 py-3 text-left font-medium"
>
{t(`monitor.${k}`)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ id, host, state }) => {
const s = state?.stats;
return (
<tr
key={id}
{...dragProps(id)}
className={cn(
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
>
<td className="px-3 py-3">
<button
draggable
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
onClick={() => {
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar host={host} fallback={host.label} size="md" />
) : (
<Server size={24} className="shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 max-w-52">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>
<td className="px-3">
{stateBadge(host, state)}
</td>
<td className="px-3 tabular-nums">
<ResourceBar label="" value={s?.cpu} />
</td>
<td className="px-3 tabular-nums">
<ResourceBar
label=""
value={
s?.memTotal && s.memUsed != null
? (s.memUsed / s.memTotal) * 100
: null
}
/>
</td>
<td className="min-w-[220px] px-3 py-3">
<MonitorDiskUsage stats={s} />
</td>
<td className="px-3 tabular-nums whitespace-nowrap">
<div className="flex items-center gap-1.5">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.download")}: </span>
{rate(s?.networkReady ? s.netRxSpeed : null)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.upload")}: </span>
{rate(s?.networkReady ? s.netTxSpeed : null)}
</div>
</td>
<td className="px-3 whitespace-nowrap">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
{t("monitor.memoryOnlyHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</article>
)}
renderCells={({ id, host, state }) => ({
host: (<td>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
aria-disabled={status(host, state) !== "live"}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar
host={host}
fallback={host.label}
size="md"
/>
) : (
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>),
latency: (<td>
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
</td>),
tags: (<td>
<MonitorHostTags tags={host?.tags} />
</td>),
notes: (<td>
<div className="min-w-0 w-full">
<MonitorHostNotes notes={host?.notes} focusable />
</div>
</td>),
cpu: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={state?.stats?.cpu}
/>
</td>),
memory: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={
state?.stats?.memTotal &&
state.stats.memUsed != null
? (state.stats.memUsed / state.stats.memTotal) *
100
: null
}
/>
</td>),
disks: (<td>
<div className="w-full min-w-0">
<MonitorDiskUsage stats={state?.stats} />
</div>
</td>),
network: (<td
data-monitor-cell="network"
className="tabular-nums whitespace-nowrap"
>
<div>
<div className="flex items-center gap-1.5">
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
<span className="sr-only">
{t("monitor.download")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
: null,
)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
<span className="sr-only">
{t("monitor.upload")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
: null,
)}
</div>
</div>
</td>),
updated: (<td className="whitespace-nowrap tabular-nums">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>),
status: (<td>{stateBadge(host, state)}</td>),
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</td>)
})}
/>
</TooltipProvider>
)}
</>
)}
</div>
@@ -1006,8 +1138,9 @@ export function MonitorPanels({
className={monitorAsideClass}
/>
)}
{detail && (
{detail && detailAvailable && (
<AsidePanel
dataSection="vault-monitor-detail"
open
onClose={() => setDetail(null)}
title={hostDetail?.label ?? t("monitor.missingHost")}
@@ -1019,7 +1152,7 @@ export function MonitorPanels({
width="w-[380px]"
className={monitorAsideClass}
>
<AsidePanelContent>
<AsidePanelContent scrollMode="native">
<div className="flex items-center gap-3">
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
<div className="min-w-0 flex-1">
@@ -1027,73 +1160,41 @@ export function MonitorPanels({
{stateBadge(hostDetail, detailState)}
</div>
</div>
{["login", "error", "disconnected"].includes(
detailState?.status ?? "",
) && (
<>
<p className="text-xs text-muted-foreground">
{t("monitor.loginHint")}
</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">
{t("monitor.username")}
</Label>
<Input
id="monitor-login-user"
autoComplete="off"
value={
login.hostId === detail
? login.username
: (hostDetail?.username ?? "")
}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username: e.target.value,
password: prev.hostId === detail ? prev.password : "",
}))
}
/>
<Label htmlFor="monitor-login-password">
{t("monitor.password")}
</Label>
<Input
id="monitor-login-password"
type="password"
autoComplete="new-password"
value={login.hostId === detail ? login.password : ""}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username:
prev.hostId === detail
? prev.username
: (hostDetail?.username ?? ""),
password: e.target.value,
}))
}
/>
</div>
<Button
variant="outline"
onClick={() => {
monitorActions.retry(
detail,
login.hostId === detail && login.password
? {
username: login.username,
password: login.password,
}
: undefined,
);
setLogin({ hostId: "", username: "", password: "" });
}}
>
{t("monitor.retry")}
</Button>
</>
)}
{renderMetrics(detailState, true)}
{detailState?.stats && (
detailState.stats.gpu != null ||
detailState.stats.gpuName ||
(detailState.stats.gpuMemTotal ?? 0) > 0
) && (
<section className="space-y-3" data-monitor-gpu>
<h3 className="flex items-center gap-1.5 text-sm font-medium">
<Gpu size={15} className="text-muted-foreground" />
{t("systemManager.tabs.gpu")}
</h3>
{detailState.stats.gpuName && (
<p className="break-words text-xs text-muted-foreground">
{detailState.stats.gpuName}
</p>
)}
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
<ResourceBar label="" value={detailState.stats.gpu} />
</div>
<div className="space-y-1.5">
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
<span>{t("systemManager.gpu.memory")}</span>
<span className="tabular-nums">
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
</span>
</div>
<ResourceBar label="" value={
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
: null
} />
</div>
</section>
)}
<p className="text-xs text-muted-foreground">
{t("monitor.observed", {
count: detailState?.points.length
@@ -1188,24 +1289,6 @@ export function MonitorPanels({
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
<p>{detailState?.stats?.hostname ?? "--"}</p>
</div>
{detailState?.status === "unsupported" && (
<p className="text-xs text-muted-foreground">
{t("monitor.unsupportedHint")}
</p>
)}
{!hostDetail && panel && (
<Button
variant="outline"
onClick={() => {
void patchPanel({
hostIds: panel.hostIds.filter((id) => id !== detail),
});
setDetail(null);
}}
>
{t("monitor.removeHost")}
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<Button
@@ -1219,6 +1302,32 @@ export function MonitorPanels({
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("monitor.retry")}</DialogTitle>
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => {
event.preventDefault();
if (!recovery) return;
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
setRecovery(null);
}} className="space-y-4">
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
<Button type="submit">{t("monitor.retry")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!deleting}
onOpenChange={(open) => !open && setDeleting(null)}
+2
View File
@@ -22,6 +22,8 @@ export type MonitorHostState = {
| "disconnected"
| "limit";
at: number;
/** GPU publication can advance independently of the primary sample/history. */
statsAt?: number;
/** Omitted by incremental replies when the receiver already has this sample. */
stats?: MonitorStats | null;
points: MonitorPoint[];
+63
View File
@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MONITOR_COLUMNS,
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
monitorGridColumns,
} from "./monitorTableLayout";
test("saved column sizes cannot hide data or create unbounded table widths", () => {
const widths = normalizeMonitorColumnWidths({
host: -1,
latency: Infinity,
notes: 100000,
terminal: NaN,
unknown: 400,
});
assert.equal(widths.host, 160);
assert.equal(widths.latency, 90);
assert.equal(widths.notes, 640);
assert.equal(widths.terminal, 56);
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
assert.deepEqual(
normalizeMonitorColumnWidths(null),
normalizeMonitorColumnWidths({}),
);
for (const column of MONITOR_COLUMNS) {
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
assert.equal(
clampMonitorColumnWidth(column.key, 10000),
column.key === "terminal" ? 200 : 640,
);
}
});
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
assert.equal(monitorGridColumns(200, 1000), 1);
assert.equal(monitorGridColumns(575, 1000), 1);
assert.equal(monitorGridColumns(576, 1000), 2);
assert.equal(monitorGridColumns(1248, 1000), 4);
assert.equal(monitorGridColumns(1248, 2), 2);
assert.equal(monitorGridColumns(0, 1000), 1);
});
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
assert.deepEqual(
normalizeMonitorHiddenColumns([
"host",
"terminal",
"cpu",
"cpu",
"unknown",
"status",
]),
["cpu", "status"],
);
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
assert.equal(
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
MONITOR_COLUMNS.length - 2,
);
});
+64
View File
@@ -0,0 +1,64 @@
export const MONITOR_COLUMNS = [
{ key: "host", width: 220, min: 160 },
{ key: "latency", width: 90, min: 80 },
{ key: "tags", width: 150, min: 90 },
{ key: "notes", width: 180, min: 110 },
{ key: "cpu", width: 120, min: 120 },
{ key: "memory", width: 120, min: 120 },
{ key: "disks", width: 240, min: 220 },
{ key: "network", width: 150, min: 140 },
{ key: "updated", width: 110, min: 95 },
{ key: "status", width: 100, min: 90 },
{ key: "terminal", width: 56, min: 56 },
] as const;
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
export type MonitorColumnWidths = Record<MonitorColumn, number>;
export const MONITOR_CARD_HEIGHT = 368;
export function clampMonitorColumnWidth(
key: MonitorColumn,
width: number,
): number {
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
return Number.isFinite(width)
? Math.round(
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
)
: column.width;
}
export function normalizeMonitorColumnWidths(
value: unknown,
): MonitorColumnWidths {
const stored =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return Object.fromEntries(
MONITOR_COLUMNS.map((column) => [
column.key,
clampMonitorColumnWidth(
column.key,
typeof stored[column.key] === "number"
? (stored[column.key] as number)
: column.width,
),
]),
) as MonitorColumnWidths;
}
export function monitorGridColumns(width: number, count: number): number {
return Math.max(
1,
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
);
}
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
return MONITOR_COLUMNS.filter(
({ key }) =>
key !== "host" &&
key !== "terminal" &&
Array.isArray(value) &&
value.includes(key),
).map(({ key }) => key);
}
+2 -1
View File
@@ -352,7 +352,8 @@ function createMonitorService({
historyId,
status: entry?.status ?? host.status ?? "limit",
at: shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
statsAt: shared?.statsAt ?? shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
? {}
: { stats: shared?.result?.success ? shared.result.stats : null }),
points: append && last?.historyId === historyId
+21 -1
View File
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
memTotal: 100,
memUsed: null,
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
gpu: ctx._monitorStats ? null : 75,
gpu: 75,
},
};
};
@@ -752,3 +752,23 @@ test("snapshot deltas send new points only, even with 256 full retained historie
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
f.service.dispose();
});
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
const f=fixture();
try {
f.service.reconcile(event(1),[{...demand(),interval:60}]);
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
const initial=f.service.snapshot(event(1));
const host=initial.hosts.h;
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
const next=f.service.snapshot(event(1),cursor).hosts.h;
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
assert.ok(next.statsAt>host.statsAt);
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
} finally {f.service.dispose();}
});
@@ -91,6 +91,54 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
]);
});
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
const handlers = register();
const e = event(93);
const options = {
hostname: "latency-fixture",
username: "fixture",
password: "fixture-only",
keepaliveInterval: 0,
verifyHostKeys: false,
};
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn.config = { keepaliveInterval: 0 };
conn.end = () => {};
conn.exec = (command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = () => true;
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
stream.emit("close", 0);
});
};
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
const holder = {};
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
try {
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
let snapshot;
const deadline = Date.now() + 2000;
do {
await tick();
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
assert.equal(snapshot.hosts.host?.status, "live");
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
} finally {
bridge.stopAllMonitoring();
pool.returnTransport(holder);
}
});
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
const { createWorkerSender } = require("../terminalWorker/process.cjs");
@@ -0,0 +1,120 @@
"use strict";
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
const EMPTY_GPU = {
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
};
const states = new WeakMap();
function stateFor(conn) {
let state = states.get(conn);
if (!state) {
state = {
stats: EMPTY_GPU,
nextAt: 0,
pending: null,
controller: null,
updatedAt: 0,
disabled: false,
};
states.set(conn, state);
}
return state;
}
function updateGpuStats(state, next) {
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
// Publication time is separate from CPU history and strictly advances even
// when a fast GPU reply shares the CPU sample's millisecond.
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
}
state.stats = next;
}
function monitorGpuUpdatedAt(conn) {
return states.get(conn)?.updatedAt ?? 0;
}
function parseGpuStats(output) {
const raw =
String(output || "")
.split("|")
.find((part) => part.startsWith("GPU:"))
?.slice(4)
.trim()
.split(/\s+/) ?? [];
const utilization = parseInt(raw[0], 10);
if (!Number.isFinite(utilization)) return EMPTY_GPU;
const number = (value) =>
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
return {
gpu: Math.max(0, Math.min(100, utilization)),
gpuName: raw.slice(3).join(" ") || null,
gpuMemUsed: number(raw[1]),
gpuMemTotal: number(raw[2]),
};
}
function withCachedMonitorGpu(conn, result) {
const state = states.get(conn);
return state && result?.success
? { ...result, stats: { ...result.stats, ...state.stats } }
: result;
}
function cacheFullGpuStats(conn, stats) {
const state = stateFor(conn);
state.controller?.abort();
updateGpuStats(
state,
Object.fromEntries(
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
),
);
state.nextAt = Date.now() + 10000;
}
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
function startMonitorGpuProbe(conn, command, options = {}) {
const state = stateFor(conn);
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
const controller = new AbortController();
state.controller = controller;
state.nextAt = Date.now() + 30000;
state.pending = Promise.resolve()
.then(() =>
executeBoundedSshCommand(conn, command, {
...options,
signal: controller.signal,
invalidateTransportOnAbort: false,
invalidateOnOpenTimeout: false,
openingTimeoutMs: 2500,
runTimeoutMs: 2500,
maxOutputBytes: 65536,
}),
)
.then(({ stdout }) => {
if (controller.signal.aborted) return;
updateGpuStats(state, parseGpuStats(stdout));
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
})
.catch((error) => {
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
// Keep this optional probe from disconnecting SSH or piling up requests.
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
})
.finally(() => {
state.pending = null;
if (state.controller === controller) state.controller = null;
});
}
function cancelMonitorGpuProbe(conn) {
states.get(conn)?.controller?.abort();
}
module.exports = {
GPU_ASSIGNMENT,
monitorGpuUpdatedAt,
parseGpuStats,
withCachedMonitorGpu,
cacheFullGpuStats,
startMonitorGpuProbe,
cancelMonitorGpuProbe,
};
@@ -0,0 +1,166 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
startMonitorGpuProbe,
withCachedMonitorGpu,
cacheFullGpuStats,
cancelMonitorGpuProbe,
} = require("./monitorGpuStats.cjs");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function stream() {
const s = new EventEmitter();
s.stderr = new EventEmitter();
s.close = () => {};
s.destroy = () => {};
return s;
}
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
let now = 100000,
calls = 0,
current;
t.mock.method(Date, "now", () => now);
const conn = {
exec(_command, cb) {
calls++;
current = stream();
cb(null, current);
},
};
t.after(() => cancelMonitorGpuProbe(conn));
startMonitorGpuProbe(conn, "gpu");
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
now += 9999;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
now += 1;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2);
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
now += 29000;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2, "missing GPU probes back off");
});
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
let current;
const conn = {
exec(_command, cb) {
current = stream();
cb(null, current);
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA full",
gpuMemUsed: 512,
gpuMemTotal: 8192,
});
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
cancelMonitorGpuProbe(conn);
t.after(() => cancelMonitorGpuProbe(conn));
});
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
const conn = {};
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
const before = readSharedStats(conn);
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA cached",
gpuMemUsed: 1,
gpuMemTotal: 2,
});
const after = readSharedStats(conn);
assert.equal(after.result.stats.gpu, 42);
assert.equal(after.result.stats.cpu, 12);
assert.equal(after.at, before.at);
assert.deepEqual(after.points, before.points);
});
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
let callback,
closed = 0,
disconnected = 0;
const conn = {
exec(_command, cb) {
callback = cb;
},
end() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
require("./sharedStats.cjs").cancelSharedStats(conn);
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
assert.equal(disconnected, 0);
});
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
let callback,
fireTimer,
calls = 0,
disconnected = 0,
closed = 0;
const conn = {
exec(_command, cb) {
calls++;
callback = cb;
},
end() {
disconnected++;
},
destroy() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu", {
setTimeoutFn: (fn) => {
fireTimer = fn;
return 1;
},
clearTimeoutFn: () => {},
});
await tick();
fireTimer();
await tick();
assert.equal(
disconnected,
0,
"optional channel timeout must leave the shared transport intact",
);
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
});
+30 -25
View File
@@ -1,5 +1,6 @@
/* eslint-disable no-undef */
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
function extraExecUnsupportedError(session) {
@@ -1066,7 +1067,18 @@ function createSessionOpsApi(ctx) {
}
async function getStatsForContext(session, event, sessionId) {
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
if (!conn || !result.success || !result.stats) return result;
if (!session._monitorStats) {
cacheFullGpuStats(conn, result.stats);
return result;
}
if (!session.singleChannelSsh) {
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
}
return withCachedMonitorGpu(conn, result);
}
async function collectServerStats(session, event, sessionId) {
@@ -1199,7 +1211,7 @@ function createSessionOpsApi(ctx) {
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
// Prefer df's filesystem type column so FUSE mounts can be identified
// even when their source name looks like an ordinary remote path.
@@ -1302,7 +1314,7 @@ function createSessionOpsApi(ctx) {
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
GPU_ASSIGNMENT,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
@@ -1364,6 +1376,12 @@ function createSessionOpsApi(ctx) {
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
: Promise.resolve(null);
// Observe the exec acknowledgement already needed for this sample. This
// remains available when global-request keepalives are disabled, without
// sending another ping or opening an additional channel/connection.
// Keep it separate from the transport RTT: channel setup also includes
// remote processing, and shared terminal readers must retain that distinction.
let responseLatencyMs = null;
const formatStatsError = (error) => (
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
? "Timeout getting server stats"
@@ -1379,7 +1397,13 @@ function createSessionOpsApi(ctx) {
};
void (async () => {
try {
const { stdout } = await executeStatsCommand(statsCommand);
const responseStartedAt = performance.now();
const { stdout } = await executeStatsCommand(statsCommand, {
...statsExecOptions,
onStream: () => {
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
},
});
if (settled) return;
let combinedStdout = String(stdout || '').trim();
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
@@ -1555,27 +1579,7 @@ function createSessionOpsApi(ctx) {
}
}
} else if (part.startsWith('GPU:')) {
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
const gpuStr = part.substring(4).trim();
if (gpuStr && gpuStr !== '') {
const gpuFields = gpuStr.split(/\s+/);
const util = parseInt(gpuFields[0], 10);
if (!isNaN(util)) {
gpu = Math.min(100, Math.max(0, util));
if (gpuFields.length >= 2) {
const memUsed = parseInt(gpuFields[1], 10);
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
}
if (gpuFields.length >= 3) {
const memTotal = parseInt(gpuFields[2], 10);
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
}
if (gpuFields.length >= 4) {
const name = gpuFields.slice(3).join(' ').trim();
if (name) gpuName = name;
}
}
}
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
} else if (part.startsWith('HOST:')) {
hostname = part.substring(5).trim();
} else if (part.startsWith('OS:')) {
@@ -1744,6 +1748,7 @@ function createSessionOpsApi(ctx) {
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { spawn, spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
const { join } = require("node:path");
@@ -682,7 +682,8 @@ for (const scenario of [
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
]) {
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
for (const monitorOnly of [false, true]) {
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
return;
@@ -690,11 +691,17 @@ for (const scenario of [
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
let finishGpu;
const gpuDone = new Promise(resolve => {finishGpu = resolve;});
let gpuCalls = 0;
const sessions = new Map([["sid", {
type: "ssh",
_monitorStats: monitorOnly,
conn: {
exec(command, cb) {
const execution = spawnSync("sh", ["-c", [
const gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
if(gpuOnly) gpuCalls++;
const child = spawn("sh", ["-c", [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 4; }",
"top() { return 1; }",
@@ -703,22 +710,39 @@ for (const scenario of [
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
: unwrapExecShC(command),
].join("\n")], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
timeout: 7000,
});
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
let stdout="", stderr="";
child.stdout.on("data", data => {stdout+=data;});
child.stderr.on("data", data => {stderr+=data;});
child.on("error", cb);
child.on("close", code => {
assert.equal(code,0,stderr);
cb(null,fakeStream(stdout));
if(gpuOnly)finishGpu();
});
},
},
}]]);
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
const api=makeSessionOps(sessions);
const sampleStarted=Date.now();
let result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
if(monitorOnly){
if(scenario.name === "blocked GPU query")assert.ok(Date.now()-sampleStarted<1500,"a hung GPU must not add its two-second timeout to CPU samples");
// Main readings arrive independently, before the optional GPU query ends.
assert.equal(result.stats.gpu,null);
await gpuDone;
await new Promise(resolve=>setImmediate(()=>setImmediate(resolve)));
result=await api.getServerStats({sender:{}},{sessionId:"sid"});
assert.equal(gpuCalls,1,"cached GPU fields must not open another probe");
}
assert.equal(result.success, true);
assert.equal(result.stats.cpuCores, 4);
assert.equal(result.stats.gpu, scenario.gpu);
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
});
}
}
test("getServerStats measures latency by pinging the stats connection", async () => {
@@ -1406,6 +1430,7 @@ for (const keepaliveInterval of [0, 10000]) {
assert.equal(result.success, true);
assert.equal(pingCalls, keepaliveInterval === 0 ? 0 : 1);
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
assert.equal(Number.isFinite(result.stats.responseLatencyMs), true);
});
}
@@ -1449,3 +1474,25 @@ test("real macOS stats shell parses the root disk without broken awk escaping",
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
assert.equal(result.stats.diskPercent, 50);
});
test("monitor CPU samples return while an optional GPU driver is still blocked", async () => {
let finishGpu, gpuCalls = 0, result;
const c = {exec(command, callback) {
if (command.includes('nvidia-smi')) {
gpuCalls++;
finishGpu = () => callback(null, fakeStream('GPU:50 1024 8192 NVIDIA test'));
} else callback(null, fakeStream(selectServerStatsFixtureOutput(command, LINUX_STATS)));
}};
const api = makeSessionOps(new Map());
const pending=api.getStatsForContext({conn:c,_monitorStats:true,_monitorIntervalMs:1}).then(value=>{result=value;});
try {
for(let i=0;i<10;i++)await new Promise(resolve=>setImmediate(resolve));
assert.ok(result?.success, 'CPU and memory must not wait for the GPU probe');
assert.equal(result.stats.memTotal,8000);
assert.equal(gpuCalls,1);
} finally {
finishGpu?.();
require("./sharedStats.cjs").cancelSharedStats(c);
await pending;
}
});
+4 -2
View File
@@ -1,5 +1,6 @@
"use strict";
// One in-memory sampler per physical authenticated transport. No persistence/logging.
const { withCachedMonitorGpu, cancelMonitorGpuProbe, monitorGpuUpdatedAt } = require("./monitorGpuStats.cjs");
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
let states = new WeakMap();
const identities = new Map();
@@ -100,7 +101,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
appendPoint(state, result, state.at, session._monitorFreshAfter);
return result;
}
// A terminal needs process/GPU fields omitted from a monitor-only query.
// A terminal needs process and GPU fields omitted from a monitor-only query.
// Finish the shared lightweight query, then upgrade once for all full readers.
await state.pending;
return sampleSharedStats(session, collect);
@@ -156,9 +157,10 @@ function readSharedStats(conn) {
const state = stateFor(conn);
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
return { result: state.latest, at: state.at, points: state.points };
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
}
function cancelSharedStats(conn) {
cancelMonitorGpuProbe(conn);
const state = states.get(conn);
if (state && !state.terminalJoined) state.controller?.abort();
}
@@ -86,6 +86,7 @@ for (const keepaliveInterval of [10000, 0]) {
assert.equal(forwardPort, 43210);
if (keepaliveInterval > 0) assert.equal(Number.isFinite(stats.stats.latencyMs), true);
else assert.equal(stats.stats.latencyMs, null);
assert.equal(Number.isFinite(stats.stats.responseLatencyMs), true);
await new Promise((resolve, reject) => conn.unforwardIn("127.0.0.1", forwardPort,
(err) => err ? reject(err) : resolve()));
}
+3
View File
@@ -321,3 +321,6 @@ export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
// Portable monitor configuration. Runtime samples never use storage.
export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./monitor-panels.tsx"></script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
import {
monitorActions,
publishMonitorSnapshot,
} from "../../application/state/monitorRuntimeStore";
import { MonitorPanels } from "../../components/vault/MonitorPanels";
import type { Host } from "../../domain/models";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
import "../../index.css";
const tokens = {
background: "210 25% 18%",
foreground: "210 20% 90%",
card: "210 23% 21%",
secondary: "210 20% 24%",
muted: "210 20% 22%",
"muted-foreground": "210 15% 65%",
border: "210 20% 30%",
primary: "170 70% 42%",
"primary-foreground": "210 20% 12%",
popover: "210 25% 20%",
"popover-foreground": "210 20% 90%",
destructive: "0 70% 60%",
input: "210 20% 30%",
ring: "170 70% 42%",
};
for (const [key, value] of Object.entries(tokens))
document.documentElement.style.setProperty(`--${key}`, value);
document.documentElement.classList.add("dark");
document.documentElement.style.setProperty("--radius", "8px");
const root = createRoot(document.getElementById("root")!);
let connections: string[] = [];
let retries: {
id: string;
credentials?: { username: string; password: string };
}[] = [];
monitorActions.retry = (id, credentials) => {
retries.push({ id, credentials });
};
let revision = 0;
let latestSnapshot: MonitorSnapshot;
function seed(
count = 2,
view: "list" | "charts" = "charts",
locale = "zh-CN",
pointCount = 12,
) {
const now = Date.now();
const hosts: Host[] = Array.from({ length: count }, (_, i) => ({
id: `host-${i}`,
label: `Production server ${String(i + 1).padStart(4, "0")}`,
hostname: `10.24.${Math.floor(i / 250)}.${(i % 250) + 1}`,
username: "fixture",
port: 22,
os: "linux",
distro: "ubuntu",
group: "Production",
tags:
i === 1
? []
: [
"production",
"database",
"critical",
"east-region",
"long-tag-value-for-overflow",
],
notes:
i === 1
? ""
: "Primary database cluster. Contact the on-call engineer before restarting.\nMaintenance window: Sunday 02:00–04:00.",
createdAt: 1,
}));
writeMonitorPanels([
{
id: "fixture",
name: "Fleet overview",
description: "",
hostIds: hosts.map((h) => h.id),
view,
interval: 5,
range: 15,
sort: "manual",
},
]);
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "monitor-ui-fixture",
epoch: now,
takenAt: now,
running: ["fixture"],
count: 1,
hostCount: count,
activeSessions: [],
startedAt: { fixture: now - Math.max(60000, pointCount * 1000) },
hosts: Object.fromEntries(
hosts.map((host, i) => [
host.id,
{
historyId: host.id,
status: "live",
at: now,
stats: {
cpuPerCore: [],
memFree: 12288,
memBuffers: 0,
memCached: 0,
swapTotal: 0,
swapUsed: 0,
topProcesses: [],
netInterfaces: [],
cpu: 23 + (i % 60),
cpuCores: 8,
memUsed: 4096,
memTotal: 16384,
diskPercent: 35,
diskUsed: 35,
diskTotal: 100,
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
mountPoint: j ? `/data-${j}` : "/",
used: 35,
total: 100 + j * 50,
percent: 35,
})),
netRxSpeed: 240000,
netTxSpeed: 72000,
networkReady: true,
latencyMs:
i === 1 || i === 5
? null
: i === 2
? 180
: i === 3
? 450
: i === 4
? 0
: 12 + (i % 30),
responseLatencyMs: i === 5 ? 145 : null,
gpu: i === 0 ? 45 : i === 2 ? 0 : null,
gpuName:
i === 0
? "NVIDIA GeForce RTX 4090"
: i === 2
? "NVIDIA Tesla T4"
: null,
gpuMemUsed: i === 0 ? 8192 : i === 2 ? 0 : null,
gpuMemTotal: i === 0 ? 24576 : i === 2 ? 16384 : null,
},
points: Array.from({ length: pointCount }, (_, j) => ({
at: now - (pointCount - 1 - j) * (pointCount === 12 ? 5000 : 1000),
cpu: 20 + ((i + j) % 40),
memory: 25,
disk: 35,
rx: 200000 + j * 1000,
tx: 70000 + j * 800,
})),
},
]),
),
};
latestSnapshot = snapshot;
publishMonitorSnapshot(snapshot);
connections = [];
retries = [];
root.render(
<I18nProvider locale={locale}>
<div className="flex h-screen min-w-0 bg-background text-foreground">
<MonitorPanels
key={++revision}
hosts={hosts}
customGroups={["Production"]}
onConnect={(host) => {
connections.push(host.id);
}}
/>
</div>
</I18nProvider>,
);
}
Object.assign(window, {
monitorFixture: {
seed,
connections: () => connections,
retries: () => retries,
status: (id: string, status: string, age = 0) => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: {
...latestSnapshot.hosts,
[id]: {
...latestSnapshot.hosts[id],
at: Date.now() - age,
status: status as MonitorSnapshot["hosts"][string]["status"],
},
},
};
publishMonitorSnapshot(latestSnapshot);
},
refresh: () => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: Object.fromEntries(
Object.entries(latestSnapshot.hosts).map(([id, state]) => [
id,
{ ...state, at: Date.now(), points: [...state.points] },
]),
),
};
publishMonitorSnapshot(latestSnapshot);
},
},
});
seed(Number(new URLSearchParams(location.search).get("count")) || 2);
+840
View File
@@ -0,0 +1,840 @@
"use strict";
// Real production monitoring components, isolated storage and deterministic readings.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const profile = fs.mkdtempSync(temp.getTempFilePath("monitor-ui-"));
app.setPath("userData", profile);
let owner, vite;
const errors = [];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const run = (code) => owner.webContents.executeJavaScript(code);
async function until(read, label) {
for (let i = 0; i < 150; i++) {
if (await read()) return;
await wait(30);
}
assert.ok(await read(), label);
}
async function click(selector) {
const rect = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e?.scrollIntoView({block:'nearest'});const r=e?.getBoundingClientRect();return r&&{x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
assert.ok(rect, selector);
owner.webContents.focus();
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "left",
clickCount: 1,
x: Math.round(rect.x),
y: Math.round(rect.y),
});
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
}
const watchdog = setTimeout(() => {
console.error("Monitoring UI acceptance timed out");
app.exit(1);
}, 90000);
async function main() {
const { createServer } = await import("vite");
vite = await createServer({
root: path.resolve(__dirname, ".."),
server: { host: "127.0.0.1", port: 0 },
logLevel: "error",
});
await vite.listen();
owner = new BrowserWindow({ width: 1280, height: 900, show: true });
owner.webContents.on("console-message", (event) => {
if (event.level === "error" || event.level >= 3) errors.push(event.message);
});
await owner.loadURL(
`http://127.0.0.1:${vite.httpServer.address().port}/scripts/fixtures/monitor-panels.html`,
);
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
"monitoring renders",
);
const screenshot = async (name) => {
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await wait(200);
await run(
"document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))",
);
fs.writeFileSync(
path.join(profile, name),
(await owner.webContents.capturePage()).toPNG(),
);
};
const count = () =>
run('document.querySelectorAll("[data-monitor-host]").length');
const scroll = (selector) =>
run(
`(()=>{const e=document.querySelector('[data-section="vault-monitor-list"]');e.scrollTop=${selector};})()`,
);
const hover = async (selector) => {
owner.focus();
owner.webContents.focus();
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x),
y: Math.round(point.y),
});
// Tooltip grace areas update on movement; model the following mouse event
// that a real pointer produces rather than teleporting between triggers.
await run("new Promise(r=>requestAnimationFrame(r))");
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 1),
y: Math.round(point.y),
});
};
const checkActionContrast = async (view) => {
const keys = ["accent", "accent-foreground"];
const saved = await run(
`Object.fromEntries(${JSON.stringify(["accent", "accent-foreground"])}.map(k=>[k,document.documentElement.style.getPropertyValue('--'+k)]))`,
);
await run(
`document.documentElement.style.setProperty('--accent','170 70% 42%');document.documentElement.style.setProperty('--accent-foreground','210 20% 12%')`,
);
await hover("[data-monitor-terminal=host-0]");
await wait(200);
const colors = await run(
`(()=>{const e=document.querySelector('[data-monitor-terminal=host-0]');return {background:getComputedStyle(e).backgroundColor,icon:getComputedStyle(e.querySelector('svg')).color}})()`,
);
const luminance = (color) =>
color
.match(/[\d.]+/g)
.slice(0, 3)
.map(Number)
.map((v) => v / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((n, v, i) => n + v * [0.2126, 0.7152, 0.0722][i], 0);
const a = luminance(colors.background),
b = luminance(colors.icon);
assert.ok(
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) >= 3,
`${view} terminal icon stays visible on accent hover: ${JSON.stringify(colors)}`,
);
fs.writeFileSync(
path.join(profile, `monitor-${view}-action-hover.png`),
(await owner.webContents.capturePage()).toPNG(),
);
for (const key of keys)
await run(
`document.documentElement.style.setProperty('--'+${JSON.stringify(key)},${JSON.stringify(saved[key])})`,
);
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
};
app.focus({ steal: true });
owner.focus();
owner.webContents.focus();
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).map(e=>e.getBoundingClientRect().height)',
),
[368, 368],
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-0] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: 12 ms",
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-1] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: --",
);
assert.equal(
await run('document.body.textContent.includes("读数只保留在内存")'),
false,
);
await hover("[data-monitor-host=host-0] [data-monitor-notes]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("Maintenance window"))',
),
"full notes appear on hover",
);
await screenshot("notes-tooltip.png");
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await checkActionContrast("charts");
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"terminal action does not open details",
);
await click("[data-monitor-host=host-0] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"GPU details appear",
);
const gpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
for (const expected of [
"NVIDIA GeForce RTX 4090",
"45.0%",
"8.0 GiB",
"24.0 GiB",
])
assert.ok(gpu.includes(expected), expected);
await screenshot("monitor-gpu.png");
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await click("[data-monitor-host=host-1] > button:first-child");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"non-GPU details open",
);
assert.equal(
await run('!!document.querySelector("[data-monitor-gpu]")'),
false,
"hosts without GPU have no empty GPU section",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(6,'charts')");
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length===6'),
"signal examples render",
);
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-latency]")).map(e=>Number(e.dataset.monitorSignalBars))',
),
[4, 0, 2, 1, 4, 2],
);
assert.ok(
await run(
`(()=>{const row=document.querySelector('[data-monitor-host=host-0]');const a=row.querySelector('[data-monitor-latency]').getBoundingClientRect();const b=row.querySelector('[data-monitor-tags]').getBoundingClientRect();return Math.abs((a.top+a.bottom)/2-(b.top+b.bottom)/2)<1})()`,
),
"latency and tags are vertically centered",
);
await hover("[data-monitor-host=host-2] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("180 ms"))',
),
"signal tooltip has exact latency",
);
await hover("[data-monitor-host=host-5] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("响应延迟(含远端处理): 145 ms"))',
),
"disabled keepalive still shows response latency",
);
await screenshot("monitor-signals.png");
await click("[data-monitor-host=host-2] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"idle GPU details open",
);
const idleGpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
assert.ok(
idleGpu.includes("0.0%") && idleGpu.includes("0 MiB"),
"zero GPU readings remain visible",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(1200,'charts')");
await until(
async () => (await count()) > 2 && (await count()) < 80,
"charts render a bounded visible subset",
);
assert.equal(
await run('document.querySelectorAll("[data-monitor-terminal]").length'),
await count(),
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last card can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled card opens the correct host",
);
await click("button:has(.lucide-list)");
await until(
() => run('!!document.querySelector("table")'),
"list view opens",
);
assert.ok((await count()) < 50, "list renders only visible rows");
for (const position of [0, 450, 100000]) {
await run(
`document.querySelector('[data-section=vault-monitor-list]').scrollLeft=${position}`,
);
await run("new Promise(r=>requestAnimationFrame(r))");
assert.ok(
await run(
`(()=>{const viewport=document.querySelector('[data-section=vault-monitor-list]');const action=document.querySelector('tbody tr td:last-child');return window.innerWidth-viewport.getBoundingClientRect().right>=15 && window.innerWidth-action.getBoundingClientRect().right>=15})()`,
),
"sticky actions retain the right page gutter at every horizontal scroll position",
);
const geometry = await run(
`(()=>{const r=document.querySelector('tbody tr td:last-child').getBoundingClientRect();return {width:innerWidth,height:innerHeight,right:r.right,top:r.top,bottom:r.bottom}})()`,
);
const captured = await owner.webContents.capturePage();
const bitmap = captured.toBitmap();
const scale = Math.sqrt(
bitmap.length / (4 * geometry.width * geometry.height),
);
const pixelWidth = Math.round(geometry.width * scale);
const pixel = (x, y) => {
const start =
4 * (Math.floor(y * scale) * pixelWidth + Math.floor(x * scale));
return [...bitmap.subarray(start, start + 4)];
};
const backdrop = pixel(geometry.width - 4, geometry.top + 1);
assert.deepEqual(
pixel(geometry.right - 1, geometry.top + 1),
backdrop,
`top-right corner stays visibly rounded at scroll ${position}`,
);
assert.deepEqual(
pixel(geometry.right - 1, geometry.bottom - 2),
backdrop,
`bottom-right corner stays visibly rounded at scroll ${position}`,
);
assert.notDeepEqual(
pixel(geometry.right - 2, (geometry.top + geometry.bottom) / 2),
backdrop,
"rounded action still paints the row background",
);
if (position === 0) await screenshot("monitor-rounded-row-start.png");
}
await scroll("0");
await checkActionContrast("list");
const columnOrder = await run(
'Array.from(document.querySelectorAll("th")).map(e=>e.dataset.monitorColumn)',
);
assert.deepEqual(columnOrder.slice(-2), ["status", "terminal"]);
assert.equal(
await run(
`Array.from(document.querySelectorAll('th [role=separator]')).every(e=>getComputedStyle(e,'::after').backgroundColor==='rgba(0, 0, 0, 0)')`,
),
true,
"column dividers are hidden at rest",
);
assert.equal(
await run('document.querySelectorAll("th [role=separator]").length'),
11,
"every column can be resized",
);
await scroll("0");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-2]")'),
"variable-height row is rendered",
);
await until(
() =>
run(
'document.querySelector("[data-monitor-host=host-2]").getBoundingClientRect().height>150',
),
"all partitions determine row height",
);
assert.ok(
await run(
`(()=>{const rows=[...document.querySelectorAll('tbody tr')];return rows.every((r,i)=>!i||r.getBoundingClientRect().top>=rows[i-1].getBoundingClientRect().bottom)})()`,
),
"variable-height rows never overlap",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-1199",
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0",
);
const widths = {};
for (const key of [
"host",
"status",
"latency",
"tags",
"notes",
"cpu",
"memory",
"disks",
"network",
"updated",
"terminal",
]) {
const selector = `[data-monitor-column=${key}] [role=separator]`;
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'center'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,width:Number(e.getAttribute('aria-valuenow'))}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseDown",
button: "left",
clickCount: 1,
x: Math.round(point.x),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseUp",
button: "left",
clickCount: 1,
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
await until(
() =>
run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))>=${point.width + 27}`,
),
`${key} width responds to dragging`,
);
widths[key] = await run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))`,
);
assert.ok(
await run(
`(()=>{const h=document.querySelector('[data-monitor-column=${key}]');const i=[...h.parentElement.children].indexOf(h);const cell=document.querySelector('tbody tr').children[i];return Math.abs(h.getBoundingClientRect().width-cell.getBoundingClientRect().width)<1})()`,
),
"header and body column widths stay aligned",
);
}
await run(
"document.querySelector('[data-monitor-column=host] [role=separator]').focus()",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
widths.host += 8;
await until(
() =>
run(
`Number(document.querySelector('[data-monitor-column=host] [role=separator]').getAttribute('aria-valuenow'))===${widths.host}`,
),
"column size can also be adjusted by keyboard",
);
await run("window.monitorFixture.seed(1200,'list')");
await until(() => run('!!document.querySelector("table")'), "list remounts");
for (const [key, width] of Object.entries(widths))
assert.equal(
await run(
`Number(document.querySelector('[data-monitor-column=${key}] [role=separator]').getAttribute('aria-valuenow'))`,
),
width,
"column widths survive remounting",
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last table row can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled row opens the correct host",
);
await run(
`(()=>{const e=document.querySelector('input[placeholder="搜索主机、分组、标签或备注"]');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(e,'1199');e.dispatchEvent(new Event('input',{bubbles:true}));})()`,
);
await until(
async () => (await count()) === 1,
"filtering at the bottom does not leave a blank virtual viewport",
);
assert.ok(
await run('document.body.textContent.includes("Production server 1199")'),
);
await run("window.monitorFixture.seed(40,'list')");
await until(
() => run('document.querySelectorAll("tbody tr").length>2'),
"unfiltered list restores",
);
await screenshot("monitor-list.png");
for (const width of [460, 1000]) {
owner.setSize(width, 900);
await wait(100);
await run("window.monitorFixture.seed(40,'charts')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"cards fit resized viewport",
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
"card height is fixed at all widths",
);
assert.ok(
await run(
"(()=>{const e=document.querySelector('[data-section=vault-monitor-list]');return e.scrollWidth<=e.clientWidth+1})()",
),
"cards do not overflow horizontally",
);
}
await screenshot("monitor-charts.png");
for (const locale of ["en", "zh-TW", "es", "ru"]) {
await run(
`window.monitorFixture.seed(40,'list',${JSON.stringify(locale)})`,
);
await until(
() => run('!!document.querySelector("table")'),
"translated table renders",
);
assert.equal(
await run('document.body.textContent.includes("monitor.")'),
false,
"no missing translation keys",
);
}
const openColumnMenu = async () => {
const point = await run(
`(()=>{document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0;const r=document.querySelector('thead').getBoundingClientRect();return {x:Math.round(r.left+40),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...point,
});
await until(
() => run('!!document.querySelector("[data-monitor-column-menu]")'),
"header right-click opens column choices",
);
};
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===11'),
"all columns start visible",
);
await openColumnMenu();
assert.equal(
await run(
`['host','terminal'].every(k=>{const e=document.querySelector('[data-monitor-toggle-column='+k+']');return e.getAttribute('aria-checked')==='true'&&e.getAttribute('aria-disabled')==='true'})`,
),
true,
"host and terminal cannot be hidden",
);
const optional = columnOrder.filter((k) => !["host", "terminal"].includes(k));
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
assert.deepEqual(
await run(
'[...document.querySelectorAll("th")].map(e=>e.dataset.monitorColumn)',
),
["host", "terminal"],
);
assert.ok(
await run(
'[...document.querySelectorAll("tbody tr")].every(e=>e.children.length===2)',
),
"hidden headings and row cells stay aligned",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===2'),
"column visibility survives reopening",
);
const hostWidthBefore = await run(
'document.querySelector("tbody tr td").getBoundingClientRect().width',
);
await run(
'document.querySelector("[data-monitor-column=host] [role=separator]").focus()',
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Right" });
await until(
() =>
run(
`document.querySelector('tbody tr td').getBoundingClientRect().width===${hostWidthBefore + 8}`,
),
"host visibly resizes after other columns are hidden",
);
await openColumnMenu();
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await until(
() => run('document.querySelectorAll("th").length===11'),
"hidden columns can be restored",
);
for (const view of ["list", "charts"]) {
await run(`window.monitorFixture.seed(3,'${view}','zh-CN')`);
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"status fixtures render",
);
for (const status of [
"login",
"error",
"disconnected",
"unsupported",
"queued",
"connecting",
"limit",
]) {
await run(`window.monitorFixture.status('host-0','${status}')`);
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"unavailable host has disabled detail action",
);
assert.equal(
await run(
"getComputedStyle(document.querySelector('[data-monitor-host=host-0]')).opacity",
),
"0.5",
"unavailable host is dimmed",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"unavailable host does not open details",
);
}
await run("window.monitorFixture.status('host-0','live',60000)");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"stale readings cannot open details",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"stale host stays unavailable",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-0"],
"login host can still open a terminal",
);
await run("window.monitorFixture.status('host-0','login')");
const recoveryPoint = await run(
`(()=>{const e=document.querySelector('[data-monitor-host=host-0]');e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:Math.round(r.left+30),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...recoveryPoint,
});
await until(
() => run('!!document.querySelector("[data-monitor-retry=host-0]")'),
"unavailable host context menu offers recovery",
);
await click("[data-monitor-retry=host-0]");
await until(
() => run('!!document.querySelector("#monitor-login-password")'),
"recovery dialog opens separately from details",
);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await click("#monitor-login-password");
owner.webContents.insertText("fixture-password");
await click("[role=dialog] button[type=submit]");
await until(
() => run('!document.querySelector("#monitor-login-password")'),
"recovery clears password inputs",
);
assert.deepEqual(
await run("window.monitorFixture.retries()"),
[
{
id: "host-0",
credentials: { username: "fixture", password: "fixture-password" },
},
],
"recovery forwards one-time credentials to the existing monitor retry path",
);
await run("window.monitorFixture.status('host-0','live')");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='false'`,
),
"recovered host enables details",
);
await click("[data-monitor-host=host-0] button");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"live host opens details",
);
await run("window.monitorFixture.status('host-0','login')");
await until(
() =>
run('!document.querySelector("[data-section=vault-monitor-detail]")'),
"unavailable details close immediately",
);
}
owner.setSize(1280, 650);
await run("window.monitorFixture.seed(3,'charts','zh-CN',1800)");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"long monitoring history renders",
);
await click("[data-monitor-host=host-0] button");
await until(
() => run('!!document.querySelector("[data-aside-scroll-viewport]")'),
"native monitoring detail scroll opens",
);
await until(
() =>
run(
`document.querySelector('[data-section=vault-monitor-detail]').getAnimations().every(a=>a.playState==='finished')`,
),
"sidebar opening animation finishes before wheel input",
);
const wheelPoint = await run(
`(()=>{const v=document.querySelector('[data-aside-scroll-viewport]');const r=v.getBoundingClientRect();window.monitorRefreshTimer=setInterval(()=>window.monitorFixture.refresh(),200);window.monitorWheelProbe={events:0,blocking:0};window.monitorWheelListener=e=>{window.monitorWheelProbe.events++;if(e.cancelable)window.monitorWheelProbe.blocking++;};document.addEventListener('wheel',window.monitorWheelListener,{passive:true});return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+180)}})()`,
);
for (let i = 0; i < 60; i++) {
owner.webContents.sendInputEvent({
type: "mouseWheel",
...wheelPoint,
deltaY: -12,
deltaX: 0,
canScroll: true,
});
await wait(17);
}
await until(
() =>
run(
"document.querySelector('[data-aside-scroll-viewport]').scrollTop>200",
),
"wheel reaches lower detail sections with a half-hour history",
);
const wheelResult = await run("window.monitorWheelProbe");
assert.ok(
wheelResult.events > 40,
"real wheel input reached monitoring details",
);
assert.equal(
wheelResult.blocking,
0,
"detail wheel input does not wait for a cancellable handler",
);
assert.equal(
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollTop",
),
0,
"sidebar scroll leaves host collection in place",
);
await screenshot("monitor-native-detail-scroll.png");
await run(
"clearInterval(window.monitorRefreshTimer);document.removeEventListener('wheel',window.monitorWheelListener)",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
owner.setSize(1280, 900);
await run(
`(()=>{document.documentElement.classList.remove('dark');for(const [k,v] of Object.entries({background:'0 0% 100%',foreground:'210 20% 15%',card:'210 20% 97%',secondary:'210 20% 94%',muted:'210 20% 95%','muted-foreground':'210 10% 45%',border:'210 20% 88%',popover:'0 0% 100%','popover-foreground':'210 20% 15%'}))document.documentElement.style.setProperty('--'+k,v)})()`,
);
await run("window.monitorFixture.seed(40,'charts','zh-CN')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"light cards render",
);
await screenshot("monitor-light.png");
assert.deepEqual(errors, [], "no renderer errors");
console.log(
JSON.stringify({
ok: true,
virtualHosts: 1200,
fixedCardHeight: 368,
terminalActions: true,
notesTooltip: true,
latencySignals: true,
gpuDetails: true,
statusBeforeAction: true,
allColumnsResize: true,
persistedWidths: true,
visibleColumns: true,
unavailableHosts: true,
nativeDetailScroll: true,
variableRowHeights: true,
filterAfterScroll: true,
locales: 5,
profile,
}),
);
}
app
.whenReady()
.then(main)
.catch(async (e) => {
console.error(e);
process.exitCode = 1;
if (owner && !owner.isDestroyed())
fs.writeFileSync(
path.join(profile, "failure.png"),
(await owner.webContents.capturePage()).toPNG(),
);
})
.finally(async () => {
clearTimeout(watchdog);
owner?.destroy();
await vite?.close();
app.exit(process.exitCode || 0);
});
+1
View File
@@ -327,6 +327,7 @@ declare global {
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
responseLatencyMs?: number | null; // Existing stats exec response, including remote channel setup
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes