Compare commits

...
Author SHA1 Message Date
bincxz a9e0d029bb ui(host): move connection test action into panel header 2026-10-17 20:17:53 +08:00
陈大猫 c2c4b6ec77 Refine monitoring layout and shared host-picker styling (#3705)
* refine(monitor): compact panel controls into the Vault header

* refine(monitor): simplify navigation and match Vault visuals

* refine(vault): remove active host selection outlines

* refine(monitor): keep default cursor until dragging

* fix(monitor): label network directions for screen readers
2026-10-17 19:46:35 +08:00
陈大猫 3f35872638 Refine monitoring layout, disk details and refresh controls (#3704)
* Polish monitoring panels and preserve host icons

* Simplify monitoring layout and show every disk partition

* Simplify list rows and add resizable monitoring overlays

* feat(monitor): add modal drawers, sampled hover values and faster refresh

* feat(monitor): reset run history and support saved drag ordering

* perf(monitor): stream history deltas and keep fast samples lightweight

* fix(monitor): separate chart histories across connection identities

* refine(monitor): show disk capacity on hover and use inline sidebars

* fix(monitor): omit unchanged stats from polling deltas

* test(monitor): freeze time in full-history delta checks

* perf(monitor): bound chart paths and reuse unchanged histories
2026-10-17 18:59:38 +08:00
34 changed files with 1343 additions and 507 deletions

No files matched your search

+5 -7
View File
@@ -1,4 +1,5 @@
export const enMonitorMessages = {
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
"monitor.password": "Password (this run only)",
@@ -21,8 +22,6 @@ export const enMonitorMessages = {
"monitor.start": "Start monitoring",
"monitor.stop": "Stop",
"monitor.stopAll": "Stop all",
"monitor.runningCount": "Monitoring: {count}",
"monitor.runningSummary": "Panels: {count} · Unique hosts: {hosts}",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search name, address or group",
@@ -33,12 +32,7 @@ export const enMonitorMessages = {
"monitor.sort": "Sort hosts",
"monitor.sort.manual": "Saved order",
"monitor.sort.name": "Name",
"monitor.sort.cpu": "Highest CPU",
"monitor.sort.memory": "Highest memory",
"monitor.sort.disk": "Highest root disk",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.liveCount": "Reporting: {count}",
"monitor.attention": "Needs attention only",
"monitor.noMatches": "No matching hosts",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
@@ -46,8 +40,12 @@ export const enMonitorMessages = {
"monitor.cpu": "CPU",
"monitor.cores": "Cores: {count}",
"monitor.memory": "Memory",
"monitor.diskCapacity": "{used} used / {total} total",
"monitor.diskCapacityUnknown": "Capacity unavailable",
"monitor.rootDisk": "Root partition",
"monitor.network": "Network",
"monitor.download": "Download",
"monitor.upload": "Upload",
"monitor.updated": "Updated",
"monitor.updatedAt": "Updated {time}",
"monitor.noSamples": "Waiting for the first reading",
+5 -7
View File
@@ -1,4 +1,5 @@
export const esMonitorMessages = {
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
"monitor.password": "Contraseña (solo esta ejecución)",
@@ -21,8 +22,6 @@ export const esMonitorMessages = {
"monitor.start": "Iniciar monitorización",
"monitor.stop": "Detener",
"monitor.stopAll": "Detener todo",
"monitor.runningCount": "{count} en ejecución",
"monitor.runningSummary": "Paneles: {count} · Equipos únicos: {hosts}",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar nombre, dirección o grupo",
@@ -33,12 +32,7 @@ export const esMonitorMessages = {
"monitor.sort": "Ordenar equipos",
"monitor.sort.manual": "Orden guardado",
"monitor.sort.name": "Nombre",
"monitor.sort.cpu": "Mayor CPU",
"monitor.sort.memory": "Mayor memoria",
"monitor.sort.disk": "Mayor uso del disco raíz",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.liveCount": "Actualizados: {count}",
"monitor.attention": "Solo requieren atención",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
@@ -46,8 +40,12 @@ export const esMonitorMessages = {
"monitor.cpu": "CPU",
"monitor.cores": "Núcleos: {count}",
"monitor.memory": "Memoria",
"monitor.diskCapacity": "{used} usados / {total} en total",
"monitor.diskCapacityUnknown": "Capacidad no disponible",
"monitor.rootDisk": "Partición raíz",
"monitor.network": "Red",
"monitor.download": "Descarga",
"monitor.upload": "Subida",
"monitor.updated": "Actualizado",
"monitor.updatedAt": "Actualizado: {time}",
"monitor.noSamples": "Esperando la primera lectura",
@@ -1,10 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { enMonitorMessages } from "./en/monitor";
import { esMonitorMessages } from "./es/monitor";
import { ruMonitorMessages } from "./ru/monitor";
import { zhCNMonitorMessages } from "./zh-CN/monitor";
import { zhTWMonitorMessages } from "./zh-TW/monitor";
test("monitoring UI literal message keys exist, including editor-only controls", () => {
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
for (const key of keys)
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
}
});
for (const [locale, messages] of Object.entries({
es: esMonitorMessages,
ru: ruMonitorMessages,
+5 -7
View File
@@ -1,4 +1,5 @@
export const ruMonitorMessages = {
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
"monitor.password": "Пароль (только для этого запуска)",
@@ -20,8 +21,6 @@ export const ruMonitorMessages = {
"monitor.start": "Начать мониторинг",
"monitor.stop": "Остановить",
"monitor.stopAll": "Остановить всё",
"monitor.runningCount": "Работает: {count}",
"monitor.runningSummary": "Панелей: {count} · Серверов: {hosts}",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Имя, адрес или группа",
@@ -32,12 +31,7 @@ export const ruMonitorMessages = {
"monitor.sort": "Сортировка серверов",
"monitor.sort.manual": "Сохранённый порядок",
"monitor.sort.name": "Имя",
"monitor.sort.cpu": "По загрузке ЦП",
"monitor.sort.memory": "По памяти",
"monitor.sort.disk": "По корневому диску",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.liveCount": "Обновлено: {count}",
"monitor.attention": "Требуют внимания",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
@@ -45,8 +39,12 @@ export const ruMonitorMessages = {
"monitor.cpu": "ЦП",
"monitor.cores": "Ядер: {count}",
"monitor.memory": "Память",
"monitor.diskCapacity": "Использовано {used} / всего {total}",
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
"monitor.rootDisk": "Корневой раздел",
"monitor.network": "Сеть",
"monitor.download": "Загрузка",
"monitor.upload": "Отправка",
"monitor.updated": "Обновлено",
"monitor.updatedAt": "Обновлено: {time}",
"monitor.noSamples": "Ожидание первого измерения",
+5 -7
View File
@@ -1,4 +1,5 @@
export const zhCNMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
"monitor.password": "密码(仅本次使用)",
@@ -19,8 +20,6 @@ export const zhCNMonitorMessages = {
"monitor.start": "开始监控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.runningCount": "{count} 个面板运行中",
"monitor.runningSummary": "{count} 个面板 · {hosts} 台不同主机",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索名称、地址或分组",
@@ -31,12 +30,7 @@ export const zhCNMonitorMessages = {
"monitor.sort": "主机排序",
"monitor.sort.manual": "保存的顺序",
"monitor.sort.name": "名称",
"monitor.sort.cpu": "CPU 从高到低",
"monitor.sort.memory": "内存从高到低",
"monitor.sort.disk": "根分区从高到低",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.liveCount": "{count} 台已更新",
"monitor.attention": "只看需关注",
"monitor.noMatches": "没有符合条件的主机",
"monitor.host": "主机",
"monitor.hosts": "主机",
@@ -44,8 +38,12 @@ export const zhCNMonitorMessages = {
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核",
"monitor.memory": "内存",
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
"monitor.diskCapacityUnknown": "暂无容量读数",
"monitor.rootDisk": "根分区",
"monitor.network": "网络",
"monitor.download": "下载",
"monitor.upload": "上传",
"monitor.updated": "更新时间",
"monitor.updatedAt": "更新于 {time}",
"monitor.noSamples": "等待第一次读数",
+5 -7
View File
@@ -1,4 +1,5 @@
export const zhTWMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
"monitor.password": "密碼(僅本次使用)",
@@ -19,8 +20,6 @@ export const zhTWMonitorMessages = {
"monitor.start": "開始監控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.runningCount": "{count} 個面板執行中",
"monitor.runningSummary": "{count} 個面板 · {hosts} 台不同主機",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋名稱、位址或群組",
@@ -31,12 +30,7 @@ export const zhTWMonitorMessages = {
"monitor.sort": "主機排序",
"monitor.sort.manual": "儲存的順序",
"monitor.sort.name": "名稱",
"monitor.sort.cpu": "CPU 從高到低",
"monitor.sort.memory": "記憶體從高到低",
"monitor.sort.disk": "根分割區從高到低",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.liveCount": "{count} 台已更新",
"monitor.attention": "只看需關注",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.host": "主機",
"monitor.hosts": "主機",
@@ -44,8 +38,12 @@ export const zhTWMonitorMessages = {
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核心",
"monitor.memory": "記憶體",
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
"monitor.diskCapacityUnknown": "暫無容量讀數",
"monitor.rootDisk": "根分割區",
"monitor.network": "網路",
"monitor.download": "下載",
"monitor.upload": "上傳",
"monitor.updated": "更新時間",
"monitor.updatedAt": "更新於 {time}",
"monitor.noSamples": "等待第一次讀數",
-1
View File
@@ -31,7 +31,6 @@ const panel: MonitorPanel = {
interval: 10,
range: 15,
sort: "manual",
attentionOnly: false,
};
const vault = {
hosts: [],
+11 -2
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from "react";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
const empty: MonitorSnapshot = {
historyMode: "replace",
running: [],
count: 0,
hostCount: 0,
@@ -8,14 +9,22 @@ const empty: MonitorSnapshot = {
runtimeId: "",
takenAt: 0,
activeSessions: [],
startedAt: {},
hosts: {},
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function publishMonitorSnapshot(next: MonitorSnapshot) {
snapshot = next;
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
for (const l of listeners) l();
}
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 }])),
};
}
export function publishMonitorUnavailable() {
publishMonitorSnapshot({ ...empty, unavailable: true });
}
@@ -101,7 +101,6 @@ test("normal manual and debounced auto-sync upload panel creation, edits and del
interval: 10,
range: 15,
sort: "manual",
attentionOnly: false,
};
let api: ReturnType<typeof useAutoSync> | undefined;
let renderer: ReactTestRenderer | undefined;
+17 -11
View File
@@ -12,6 +12,7 @@ import {
import { useMonitorPanels } from "./monitorPanelsStorage";
import {
monitorActions,
getMonitorHistoryCursor,
publishMonitorSnapshot,
publishMonitorUnavailable,
} from "./monitorRuntimeStore";
@@ -31,7 +32,7 @@ export function useMonitorRuntime() {
} = useAppVaultRuntime();
const { terminalSettings } = useAppSettingsRuntime();
const { locked } = useAppLockChrome();
const [running, setRunning] = useState<string[]>([]);
const [running, setRunning] = useState(() => new Map<string, string>());
const [retryVersion, setRetryVersion] = useState(0);
const epoch = useRef<number | null>(null);
const runtimeId = useRef<string | null>(null);
@@ -40,12 +41,16 @@ export function useMonitorRuntime() {
);
useEffect(() => {
monitorActions.start = (id) =>
setRunning((prev) => (prev.includes(id) ? prev : [...prev, id]));
setRunning((prev) => new Map(prev).set(id, crypto.randomUUID()));
monitorActions.stop = (id) =>
setRunning((prev) => prev.filter((p) => p !== id));
setRunning((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
monitorActions.stopAll = () => {
credentials.current.clear();
setRunning([]);
setRunning(new Map());
void netcattyBridge.get()?.monitorStopAll?.();
};
monitorActions.retry = (id, override) => {
@@ -65,7 +70,7 @@ export function useMonitorRuntime() {
useEffect(() => {
if (locked || !isInitialized) {
credentials.current.clear();
setRunning([]);
setRunning(new Map());
}
}, [locked, isInitialized]);
useEffect(() => {
@@ -78,9 +83,10 @@ export function useMonitorRuntime() {
locked || !isInitialized
? []
: panels
.filter((p) => running.includes(p.id))
.filter((p) => running.has(p.id))
.map((panel) => ({
id: panel.id,
runId: running.get(panel.id)!,
interval: panel.interval,
hosts: panel.hostIds.map((id) => {
const host = hosts.find((h) => h.id === id);
@@ -134,7 +140,7 @@ export function useMonitorRuntime() {
.then(publishMonitorSnapshot)
.catch(() => {
credentials.current.clear();
setRunning((prev) => (prev.length ? [] : prev));
setRunning((prev) => (prev.size ? new Map() : prev));
publishMonitorUnavailable();
});
}, [
@@ -159,7 +165,7 @@ export function useMonitorRuntime() {
if (pending || !bridge?.monitorSnapshot) return;
pending = true;
try {
const snapshot = await bridge.monitorSnapshot();
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
if (disposed) return;
if (
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
@@ -167,14 +173,14 @@ export function useMonitorRuntime() {
runtimeId.current !== snapshot.runtimeId)
) {
credentials.current.clear();
setRunning([]);
setRunning(new Map());
}
epoch.current = snapshot.epoch;
runtimeId.current = snapshot.runtimeId;
publishMonitorSnapshot(snapshot);
} catch {
credentials.current.clear();
setRunning((prev) => (prev.length ? [] : prev));
setRunning((prev) => (prev.size ? new Map() : prev));
publishMonitorUnavailable();
} finally {
pending = false;
@@ -183,7 +189,7 @@ export function useMonitorRuntime() {
void poll();
const timer = setInterval(() => {
void poll();
}, 2000);
}, 1000);
return () => {
disposed = true;
clearInterval(timer);
+37 -22
View File
@@ -4,6 +4,7 @@ import {
EyeOff,
FileText,
FolderPlus,
PlugZap,
Plus,
Settings2,
Tag,
@@ -61,6 +62,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
ChainPanel,
@@ -1015,16 +1017,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
actions={
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!canSaveHost}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
</Button>
<>
{!pluginProtocolActive && form.protocol !== "telnet" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleStartTest}
disabled={!canSaveHost}
aria-label={t("hostConnectionTest.title")}
>
<PlugZap size={16} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("hostConnectionTest.title")}
</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!canSaveHost}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
</Button>
</>
}
>
<AsidePanelContent>
@@ -1346,19 +1371,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter className="flex flex-row gap-2">
{!pluginProtocolActive && form.protocol !== "telnet" && (
<Button
variant="outline"
className="h-10 px-4"
onClick={handleStartTest}
disabled={!canSaveHost}
>
{t("hostConnectionTest.test")}
</Button>
)}
<AsidePanelFooter>
<Button
className="flex-1 h-10"
className="w-full h-10"
onClick={handleSubmit}
disabled={!canSaveHost}
>
+6 -1
View File
@@ -14,6 +14,7 @@ interface SelectHostPanelProps {
onSelect: (host: Host) => void;
onSelectionChange?: (selectedHostIds: string[]) => void;
onBack: () => void;
onClose?: () => void;
onContinue?: () => void;
onNewHost?: () => void;
availableKeys?: SSHKey[];
@@ -25,6 +26,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
headerClassName?: string;
width?: string;
layout?: AsidePanelLayout;
resizable?: boolean;
@@ -42,6 +44,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onSelect,
onSelectionChange,
onBack,
onClose,
onContinue,
onNewHost,
availableKeys = [],
@@ -53,6 +56,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
headerClassName,
width,
layout = "overlay",
resizable = false,
@@ -78,8 +82,9 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<AsidePanel
open={true}
onClose={onBack}
onClose={onClose ?? onBack}
title={panelTitle}
headerClassName={headerClassName}
subtitle={subtitle}
showBackButton={true}
onBack={onBack}
+2 -4
View File
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
data-active={isActive ? 'true' : undefined}
className={cn(
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
@@ -487,9 +487,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
aria-label={t('selectHost.toggleHost', { name: host.label })}
className={cn(
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
// Keep keyboard cursor visible even when the host is already selected.
isActive && 'ring-1 ring-primary/40',
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
-2
View File
@@ -1,4 +1,3 @@
import { MonitorRunningIndicator } from './vault/MonitorPanels';
import { Folder, FolderLock, Lock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
@@ -1238,7 +1237,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<TooltipContent>{t('topTabs.lockApp')}</TooltipContent>
</Tooltip>
)}
<MonitorRunningIndicator />
<TopTabsQuickControls
theme={theme}
themePreference={themePreference}
+3 -7
View File
@@ -34,13 +34,9 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
assert.match(source, /navigableIndexByKey/);
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
assert.doesNotMatch(source, /navigable\.findIndex/);
// Selected hosts must still show the keyboard active ring.
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
assert.doesNotMatch(
source,
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
);
// The keyboard cursor stays visible on selected hosts through background alone.
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
// One listbox tab stop — not per-host tabIndex under virtualization.
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
+11 -4
View File
@@ -83,6 +83,7 @@ interface AsidePanelProps extends AsidePanelResizeProps {
onBack?: () => void;
children: ReactNode;
className?: string;
headerClassName?: string;
width?: string;
layout?: AsidePanelLayout;
/**
@@ -99,6 +100,7 @@ interface AsidePanelHeaderProps {
onBack?: () => void;
onClose: () => void;
showBackButton?: boolean;
className?: string;
}
// Header Component
@@ -109,9 +111,10 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
onBack,
onClose,
showBackButton = false,
className,
}) => {
return (
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
<div className={cn("px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0", className)}>
<div className="flex items-center gap-2 min-w-0">
{showBackButton && onBack && (
<button
@@ -170,16 +173,18 @@ export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: strin
// Action Menu Component (for the ... button)
interface AsideActionMenuProps {
children: ReactNode;
triggerClassName?: string;
ariaLabel?: string;
}
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children, triggerClassName, ariaLabel }) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
<MoreVertical size={18} />
</button>
</PopoverTrigger>
@@ -345,6 +350,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
onBack,
children,
className,
headerClassName,
width = 'w-[380px]',
layout = 'overlay',
resizable = false,
@@ -414,7 +420,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && !usesPixelWidth && width,
isResizing && 'transition-none',
className
className,
)}
style={panelStyle}
data-section={dataSection}>
@@ -435,6 +441,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
{title && (
<AsidePanelHeader
title={title}
className={headerClassName}
subtitle={subtitle}
actions={actions}
onClose={onClose}
+56
View File
@@ -0,0 +1,56 @@
import React from "react";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { ResourceBar } from "../systemManager/ResourceBar";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const diskCapacity = (value: number) => value >= 1024
? `${(value / 1024).toFixed(2)} TiB`
: value > 0 && value < 1
? `${(value * 1024).toFixed(1)} MiB`
: `${value.toFixed(1)} GiB`;
/** Cards show the largest capacities; the list keeps every reported mount. */
export function MonitorDiskUsage({
stats,
limit,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
limit?: number;
}) {
const { t } = useI18n();
const reported = stats?.disks.length
? stats.disks
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
const disks = limit == null
? reported
: [...reported].sort((a, b) =>
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
).slice(0, limit);
return (
<TooltipProvider delayDuration={150}>
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
{disks.map((disk, index) => (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
{disk.mountPoint}
</span>
<ResourceBar label="" value={disk.percent} className="flex-1" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
<div className="mb-1 break-all font-medium">{disk.mountPoint}</div>
<div className="tabular-nums text-muted-foreground">
{typeof disk.used === "number" && Number.isFinite(disk.used) && disk.used >= 0 && typeof disk.total === "number" && Number.isFinite(disk.total) && disk.total > 0
? t("monitor.diskCapacity", { used: diskCapacity(disk.used), total: diskCapacity(disk.total) })
: t("monitor.diskCapacityUnknown")}
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
);
}
+4 -17
View File
@@ -1,7 +1,6 @@
import React from "react";
import { Activity } from "lucide-react";
import { Gauge } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useMonitorSnapshot } from "../../application/state/monitorRuntimeStore";
import { cn } from "../../lib/utils";
import { RippleButton } from "../ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@@ -15,13 +14,13 @@ export function MonitorNavButton({
onClick: () => void;
}) {
const { t } = useI18n();
const { count } = useMonitorSnapshot();
return (
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={active ? "secondary" : "ghost"}
onClick={onClick}
aria-label={t("monitor.title")}
className={cn(
"w-full h-10",
collapsed ? "justify-center p-0" : "justify-start gap-3",
@@ -29,20 +28,8 @@ export function MonitorNavButton({
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
>
<Activity size={16} className="shrink-0" />
{!collapsed && (
<>
<span>{t("monitor.title")}</span>
{count > 0 && (
<span className="ml-auto text-xs text-primary tabular-nums">
{count}
</span>
)}
</>
)}
{collapsed && count > 0 && (
<span className="text-[10px] text-primary">{count}</span>
)}
<Gauge size={16} className="shrink-0" />
{!collapsed && <span>{t("monitor.title")}</span>}
</RippleButton>
</TooltipTrigger>
<TooltipContent side="right">{t("monitor.title")}</TooltipContent>
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { JSDOM } from "jsdom";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
const now = 1_800_000_000_000;
const point = (ago: number, cpu: number | null): MonitorPoint => ({
at: now - ago,
cpu,
memory: null,
disk: null,
rx: null,
tx: null,
});
const trend = (points: MonitorPoint[]) => new JSDOM(renderToStaticMarkup(
<MonitorTrend clock={0} points={points} field="cpu" range={5} interval={10} label="CPU trend" />,
)).window.document;
test("unchanged series reuse geometry but age out when the viewport clock advances", async (t) => {
const dom = new JSDOM("<div id='root'></div>");
const globals = { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
let current = now;
t.mock.method(Date, "now", () => current);
const root = createRoot(dom.window.document.getElementById("root")!);
const samples = [point(1000, 42)];
try {
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU" />));
const path = dom.window.document.querySelector("svg g path")!.getAttribute("d");
current += 1000;
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
assert.equal(dom.window.document.querySelector("svg g path")!.getAttribute("d"), path);
current += 6 * 60000;
await act(async () => root.render(<MonitorTrend clock={1} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
assert.equal(dom.window.document.querySelectorAll("svg g path, svg circle").length, 0);
} finally {
await act(async () => root.unmount());
for (const key of Object.keys(globals)) {
const descriptor = originals[key];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
}
});
test("empty trends do not draw a reading", (t) => {
t.mock.method(Date, "now", () => now);
const document = trend([]);
assert.equal(document.querySelectorAll("svg g path, svg circle").length, 0);
});
test("a first reading is visible at its actual time without invented history", (t) => {
t.mock.method(Date, "now", () => now);
const document = trend([point(1000, 40)]);
const dot = document.querySelector("circle")!;
assert.equal(Number(dot.getAttribute("cx")), 299);
assert.equal(document.querySelector('g path[fill="none"]')!.getAttribute("d"), "M 299.00 36.40");
const area = document.querySelector("g path")!.getAttribute("d")!;
assert.equal(area, "M 299.00 36.40 L 299.00 58 L 299.00 58 Z");
});
test("trend lines and shaded areas break at missing readings and collection gaps", (t) => {
t.mock.method(Date, "now", () => now);
for (const points of [
[point(40000, 20), point(30000, null), point(20000, 30), point(10000, 40)],
[point(50000, 20), point(10000, 30), point(0, 40)],
]) {
const document = trend(points);
const segments = [...document.querySelectorAll("svg g")];
assert.equal(segments.length, 2);
assert.equal(segments[0].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), false);
assert.equal(segments[1].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), true);
// Each area starts at its own segment, rather than extending to the chart origin.
for (const segment of segments) {
const firstX = segment.querySelector("path")!.getAttribute("d")!.split(" ")[1];
assert.ok(Number(firstX) > 0);
assert.ok(segment.querySelector("path")!.getAttribute("d")!.endsWith(`L ${firstX} 58 Z`));
}
}
});
test("multiple host charts use independent gradient references", () => {
const document = new JSDOM(renderToStaticMarkup(<>
<MonitorTrend clock={0} points={[]} field="cpu" range={5} interval={10} label="CPU" />
<MonitorTrend clock={0} points={[]} field="memory" range={5} interval={10} label="Memory" />
</>)).window.document;
const ids = [...document.querySelectorAll("linearGradient")].map((g) => g.id);
assert.equal(new Set(ids).size, 2);
});
const hosts: Host[] = [
{ id: "custom", label: "Cloud host", hostname: "cloud.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, iconMode: "custom", iconId: "cloud", iconColorMode: "manual", iconColorCustom: "#9333EA" },
{ id: "brand", label: "Ubuntu host", hostname: "ubuntu.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, distro: "ubuntu" },
];
test("disk usage shows every reported partition, including healthy and empty disks", () => {
const disks = [
{ mountPoint: "/", used: 25, total: 100, percent: 25 },
{ mountPoint: "/data", used: 95, total: 100, percent: 95 },
{ mountPoint: "/backup", used: 0, total: 100, percent: 0 },
];
const document = new JSDOM(renderToStaticMarkup(
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
)).window.document;
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
for (const value of ["25.0%", "95.0%", "0.0%"])
assert.ok(document.body.textContent!.includes(value));
});
test("disk usage retains a root-only reading and leaves missing data unknown", () => {
const known = renderToStaticMarkup(<MonitorDiskUsage stats={{ disks: [], diskPercent: 42 }} />);
const unknown = renderToStaticMarkup(<MonitorDiskUsage />);
assert.ok(known.includes("42.0%"));
assert.ok(unknown.includes("--"));
assert.ok(!unknown.includes("0.0%"));
});
test("cards show the two largest capacities without changing the source disk order", () => {
const disks = [
{ mountPoint: "/small", used: 49, total: 50, percent: 98 },
{ mountPoint: "/data", used: 100, total: 1000, percent: 10 },
{ mountPoint: "/backup", used: 0, total: 500, percent: 0 },
];
const document = new JSDOM(renderToStaticMarkup(
<MonitorDiskUsage stats={{ disks, diskPercent: 98 }} limit={2} />,
)).window.document;
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/data", "/backup"]);
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
});
for (const view of ["list", "charts"] as const) {
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
const storage = new Map<string, string>();
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
} });
t.after(() => {
if (original) Object.defineProperty(globalThis, "localStorage", original);
else Reflect.deleteProperty(globalThis, "localStorage");
});
writeMonitorPanels([{ id: "panel", name: "Panel", description: "", hostIds: hosts.map((h) => h.id), view, interval: 10, range: 15, sort: "manual" }]);
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorPanels hosts={hosts} customGroups={[]} onConnect={() => {}} /></I18nProvider>,
)).window.document;
const icon = document.querySelector(".lucide-cloud")!;
assert.ok(icon);
assert.equal((icon.parentElement as HTMLElement).style.backgroundColor, "rgb(147, 51, 234)");
assert.ok(document.querySelector('img[src="/distro/ubuntu.svg"]'));
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];
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
}
File diff suppressed because it is too large. Load diff
@@ -71,13 +71,12 @@
### 独立入口
Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和折叠方式。正在运行时,栏目旁显示运行面板数;现有顶部仍能看到运行提示。页面主体依次为:
Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和折叠方式。正在运行时,栏目旁显示运行面板数;应用顶部工具栏保持原样。页面主体依次为:
1. 标题“监控”、全局运行数量、“全部停止”和“新建面板”。
1. 标题“监控”、“全部停止”和“新建面板”。
2. 当前面板名称下拉、说明、编辑与更多菜单。
3. 列表/图表切换、搜索、趋势范围、更新频率、当前面板开始/停止。
4. 正常、待登录、过期等状态摘要与“只看需关注”。
5. 主机列表或图表卡片;右侧按需展开编辑或详情。
4. 主机列表或图表卡片;右侧按需展开编辑或详情,与页面顶部对齐,覆盖在内容上方并可拖拽调宽,不改变左侧内容宽度。
面板多时下拉支持按名称查找。第一版不再给监控面板引入分组、文件夹或嵌套页面。
@@ -95,21 +94,21 @@ Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和
### 列表模式
每台一行,字段为主机名称/地址、状态、CPU、内存、根分区、上传/下载、更新时间。提供按 CPU/内存/磁盘排序、搜索、需关注筛选与详情入口。
每台一行,字段为主机图标/名称/地址、状态、CPU、内存、各分区占用、上传/下载、更新时间。仅提供名称与保存的顺序两种排序,列表和卡片均支持拖拽;拖拽基于当前显示顺序保存并切换为保存的顺序,搜索隐藏的主机仍保留。提供搜索与详情入口。没有“需关注”机制。磁盘进度保留百分比,鼠标悬停时显示完整分区名与已用/总容量,缺失容量不伪造为零。
列表用于比较,避免每一行都塞完整图表。缺失值显示 `--`;失败时保留旧值但降灰并说明时间;第一次差值采样尚未完成时显示采样中。CPU 与网速未就绪不伪装成 0。
### 图表模式
每台一张自动排列的卡片:名称/地址/状态、CPU 当前值及核数、内存当前值及容量、CPU/内存短趋势、根分区占用条、网络上下行当前速率。磁盘其他分区有高占用时,正式功能需补充指明具体分区,不能用磁盘总量掩盖单盘满。
每台一张自动排列的卡片:主机图标/名称/地址/状态、CPU 当前值及核数、内存当前值及容量、CPU/内存短趋势(悬停显示指示线、真实采样值和时间)、总容量最大的两个分区的挂载点和占用条、网络上下行当前速率。卡片高度保持一致,内部以留白和彩色读数分区,不嵌套描边容器或状态标签;列表同样无边框,工具栏控件统一高度。面板说明不在主页面重复展示。
点击主机打开右侧详情,显示放大的 CPU/内存趋势、网络上传/下载趋势、系统信息和各盘/各网卡读数。详情、编辑与选主机使用同一面板区,互斥打开;窄窗口切为占用主要内容区,保留返回/关闭和固定底部操作。
点击主机打开右侧详情,显示放大的 CPU/内存趋势、网络上传/下载趋势、系统信息和各盘/各网卡读数。详情、编辑与选主机互斥打开,均使用可拖拽调宽的内联侧栏,打开时收窄左侧内容,与其他 Vault 分区保持一致;侧栏标题栏与主内容标题栏同高,保留返回/关闭和固定底部操作。
两种展示共用主机集合、读数和短趋势。换视图不重新登录、不清曲线、不新建另一份采集。显示偏好按每个面板保存。首版不加入自由拖拽布局和每张图的查询编辑器。
## 4. 已认可的运行规则
建议每个面板独立开始/停止。**明确开始的面板在应用运行期间继续采集,切换面板或进入终端不中断。** 不因打开下拉菜单或保存面板便自动开始全部面板。
建议每个面板独立开始/停止。**明确开始的面板在应用运行期间继续采集,切换面板或进入终端不中断。** 不因打开下拉菜单或保存面板便自动开始全部面板。每次明确开始和每次变更采集频率都会清空当前面板的所有旧曲线,从本次操作之后的真实读数重新绘制;不会清除其他面板或终端的共享读数。
这种持续运行方式随雏形获得用户认可。它与上一稿“离开页面暂停”的规则不同,以本稿为准。研究和雏形阶段没有发起真实主机连接;正式实现须使用可控测试环境验证,不能任意批量登录用户保存的生产主机。
@@ -120,7 +119,7 @@ Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和
- Vault 锁定、系统睡眠或网络断开时,不继续用缓存秘密重试登录;展示暂停/断开与缺口,解锁或恢复网络后依既有安全规则处理,不能绕过身份确认。
- 需要口令、验证码或主机身份确认时逐台处理,其他主机继续;不要同时弹出一串登录框。
首版建议 10 秒更新,可选 30/60 秒。频率是设计值,不是竞品证明的最佳值或严格性能保证。系统信息、磁盘容量可低频,列表不采完整进程/GPU/每核详情。采集失败有限退避,认证或身份变化等待用户,不循环刷日志。
默认 5 秒更新,菜单提供 3/5/10/30/60 秒,隐藏 1/2 秒选项,内部采样能力不作变更。共享缓存按当前监控的最快需求刷新,不以旧的 4 秒缓存限制 1 秒采集。系统信息、磁盘容量可低频,列表不采完整进程/GPU/每核详情。采集失败有限退避,认证或身份变化等待用户,不循环刷日志。
## 5. SSH 长连接:复用现有机制的真实边界
@@ -136,7 +135,7 @@ Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和
- 当前池没有正式 monitor 用途;现有 [统计入口](../../electron/bridges/sshBridge/sessionOps.cjs)仍依赖终端 session。正式实现需要新增监控用途,并把采样上下文与 CPU/网速前次计数从终端会话解耦。不能创建隐藏终端冒充监控,否则可能带来终端启动脚本和其他副作用。
- 多面板、详情、终端与窗口对同一兼容身份共用一份采集;只有第一次无可用连接才按既有认证/组继承/跳板/信任检查流程建连。不另造监控专用池,不绕过认证。
- 多个订阅频率不同时,底层基础读数按最短有效需求;例如终端仍要每 5 秒读数,监控面板选择 30 秒不意味着该机器远端查询变为每 30 秒。各面板可按自身频率呈现,实际共享频率需可解释。
- 多个订阅频率不同时,底层基础读数按最短有效需求;1 秒等快速监控始终使用轻量采集,进程/GPU 等完整读数只由终端按其自身频率触发,并有独立的 4 秒缓存;例如终端仍要每 5 秒读数,监控面板选择 30 秒不意味着该机器远端查询变为每 30 秒。各面板可按自身频率呈现,实际共享频率需可解释。
- 长连接指已认证 SSH 传输保持与复用,现有路径仍定时执行查询。它不自动等于持续推流、无远端命令或零堡垒机审计;改为持续采集进程属于另一个明确决策,本方案不默认引入远端落盘脚本或常驻安装。
正式支持范围先以现有 Linux/macOS SSH 统计与已验证跳板方式为准。网络设备、Telnet、单通道/受限方式需明确不可用原因。现有 [SelectHostPanel](../../components/SelectHostPanel.tsx)的默认过滤不足以代表这些能力已经全部处理。
@@ -156,9 +155,9 @@ Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和
监控读数不写 localStorage、IndexedDB、文件、缓存、备份、同步或常规日志;不通过“页面状态恢复”间接带出采样。诊断仅记录必要的状态与计数,不记录采样正文。
短趋势建议每个唯一采集身份一份有上限的内存缓冲,保留最近 30 分钟、最多 180 点,监控展示最快 10 秒一点。多个面板不各存一份。按时间和数量同时裁剪,失去需求后有限期回收;程序退出全部清空。
短趋势建议每个唯一采集身份一份有上限的内存缓冲,保留最近 30 分钟、最多 1800 点,监控最快 1 秒一点。多个面板不各存一份。按时间和数量同时裁剪,失去需求后有限期回收;程序退出全部清空。
因此只提供最近 5/15/30 分钟的观察范围,不提供昨天/过去一周等历史查询。实际采样不足所选范围时显示已采时长,断网或暂停期间保留缺口,不补 0、不虚构连贯趋势。100 台 × 180 点 × 六个 8 字节数字约 0.86MB 只是原始数字下限,实际对象和渲染占用仍要实测。
因此只提供最近 5/15/30 分钟的观察范围,不提供昨天/过去一周等历史查询。实际采样不足所选范围时显示已采时长,断网或暂停期间保留缺口,不补 0、不虚构连贯趋势。100 台 × 1,800 点 × 六个 8 字节数字约 8.6MB 只是原始数字下限,实际对象和渲染占用仍要实测。
## 7. 雏形与本次验证
@@ -206,8 +205,8 @@ Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和
- Panel configuration is normalized through an allowlist. It participates in the existing three-way and convergent sync collections, deletion records, auto-sync change notifications, Vault JSON export/import and protective backups. Prepared cloud applies rebase intervening window edits; old payloads without the field preserve local panels.
- Monitoring runs in the terminal worker, using the existing SSH registry's full identity and joined dials, with an explicit `monitor` lease. Authentication reuses the existing SSH preparation, host-key, keyboard-interactive and jump-host paths. No terminal session or shell is created for monitoring.
- Ordinary terminal opens retain their existing fresh-login policy. If a terminal already exists, monitoring borrows its compatible transport. Distinct transports with the exact same normalized identity share the metrics cache and baseline too, so a required fresh terminal login does not duplicate collection.
- Shared collection/history is volatile. Each history keeps at most 180 points from the last 30 minutes, sampled no faster than every 10 seconds. At most 256 unique monitoring identities run at once; the metrics identity cache is bounded to 512 entries and periodically prunes expired histories. No readings, errors, running intent or credentials enter the panel storage shape.
- Known network devices are rejected using the existing renderer banner rules, extracted into one shared module. Recognized single-channel peers are rejected before an extra command runs. Monitor-only samples omit process and GPU queries. A terminal reader upgrades a lightweight sample to a complete one; subsequent shared queries remain complete while a terminal is actively reading. The same existing Linux/macOS parser, disk handling, CPU deltas and network deltas serve terminal and monitoring readers.
- Shared collection/history is volatile. Each history keeps at most 1800 points from the last 30 minutes, sampled no faster than every second. At most 256 unique monitoring identities run at once; the metrics identity cache is bounded to 512 entries and periodically prunes expired histories. No readings, errors, running intent or credentials enter the panel storage shape. Renderer polling transfers only new history points and omits unchanged host stats using per-history sample cursors; fresh samples and connection changes include their complete current stats. Trend geometry is memoized across unchanged samples (including after panel resets), reduces dense runs to per-column extrema, and advances idle viewports on a shared five-second clock. Hover still reads the original samples; missing samples and cadence gaps remain separate runs.
- Known network devices are rejected using the existing renderer banner rules, extracted into one shared module. Recognized single-channel peers are rejected before an extra command runs. Monitor-only samples omit process and GPU queries. Terminal readers share a separate four-second complete-sample cache; monitor readers continue collecting lightweight samples. The same existing Linux/macOS parser, disk handling, CPU deltas and network deltas serve terminal and monitoring readers.
- A real macOS shell exercise found an existing cooked-template escape in the disk awk expression. It now uses a literal prefix comparison, with a generated-command regression test; the actual monitor reads root/volume capacity correctly.
Verified so far in an isolated Electron profile and controlled SSH fixture (no saved production host logins): creation, validation, cross-group selection, copy starting stopped, two running panels, navigation retention, direct and jump-host real shell sampling, known-host confirmation, unsupported network devices, sequential verification prompts, failed login details, light/dark and Chinese/narrow rendering. Stopping an in-flight slow query produced no additional queries after 12 seconds; another panel and an ordinary terminal remained usable. Starting monitoring against an existing terminal did not add an authenticated connection. Restart restored two saved panels with zero running panels and zero monitor readings.
@@ -223,3 +222,7 @@ Additional acceptance evidence:
- A controlled SSH fixture supplied 100 distinct identities over real connections. All 100 produced their first readings in 876ms; 100 cards rendered (two trend charts each), with a measured renderer heap increase of 6.69MiB. The fixture recorded 100 successful authentications, 100 collection commands plus capability/disk work, and zero shells for this exercise. This tests fixture throughput and rendering, not a production fleet's network latency.
- Accelerated in-memory history exercise through the production sampler retained exactly 18,000 points for 100 identities, with a measured heap increase of 1.92MiB after collection. This measures the real bounded object representation; it does not claim 30 minutes of production observation.
- Last successful readings and their timestamp survive sampling failure; missing measurements remain null. A failed host does not block reuse and collection on an already-authenticated host. Capacity is applied after obsolete demand is removed, including replacing a host at the 256-identity bound.
### Fast-cadence transport validation
Renderer polling sends a per-host timestamp cursor. Worker snapshots send only newer points after the first full snapshot; renderer memory merges, deduplicates and bounds history to 1,800 points and 30 minutes. Endpoint history identities travel with cursors so replacing a host connection cannot merge readings from different endpoints. Worker/runtime changes replace history, and older responses cannot overwrite newer state. A 256-host test with full retained histories transfers only 256 new points per polling round, below one percent of the full-history payload. Full terminal stats keep their own four-second cache; one-second monitor requests remain lightweight.
+46
View File
@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "./monitorChart";
import type { MonitorPoint } from "./monitorRuntime";
const points: MonitorPoint[] = [
{ at: 1000, cpu: 12.5, memory: 40.123, disk: null, rx: null, tx: null },
{ at: 2000, cpu: null, memory: 50, disk: null, rx: null, tx: null },
{ at: 3000, cpu: 80, memory: null, disk: null, rx: null, tx: null },
];
test("chart hit testing returns the exact sampled value and timestamp", () => {
assert.equal(findMonitorChartPoint(points, "cpu", 2900, 300), points[2]);
assert.equal(findMonitorChartPoint(points, "memory", 1100, 300), points[0]);
});
test("chart hit testing does not fabricate readings in missing or distant history", () => {
assert.equal(findMonitorChartPoint(points, "cpu", 2000, 300), null);
assert.equal(findMonitorChartPoint(points, "cpu", -5000, 300), null);
assert.equal(findMonitorChartPoint([], "cpu", 1000, 300), null);
});
test("full histories fit the chart budget while extrema and raw hover samples survive", () => {
const end = 1800000;
const samples = Array.from({ length: 1800 }, (_, i) => ({ ...points[0], at: (i + 1) * 1000, cpu: 50 }));
samples[700].cpu = 100;
samples[701].cpu = 0;
const geometry = buildMonitorTrendGeometry(samples, "cpu", 30, 1, end);
assert.equal(geometry.segments.length, 1);
const path = geometry.segments[0].path;
assert.ok(path.split(/ [ML] /).length <= 302);
assert.ok(path.includes(" 4.00"));
assert.ok(path.includes(" 58.00"));
assert.equal(geometry.visible.length, 1800);
assert.equal(findMonitorChartPoint(geometry.visible, "cpu", samples[699].at, 0), samples[699]);
assert.equal(samples.length, 1800);
});
test("chart reduction preserves missing samples and cadence gaps", () => {
const geometry = buildMonitorTrendGeometry([
...points,
{ ...points[0], at: 20000 },
{ ...points[0], at: 21000, cpu: Infinity },
{ ...points[0], at: 22000 },
], "cpu", 1, 1, 22000);
assert.equal(geometry.segments.length, 4);
assert.ok(geometry.segments.every((segment) => !segment.path.includes(" L ")));
});
+76
View File
@@ -0,0 +1,76 @@
import type { MonitorPoint } from "./monitorRuntime";
export type MonitorTrendField = "cpu" | "memory" | "rx" | "tx";
/** Keep extrema within each two-pixel column; hit testing still uses raw samples. */
export function buildMonitorTrendGeometry(
points: MonitorPoint[], field: MonitorTrendField, range: number, interval: number, end: number,
) {
const start = end - range * 60000;
const visible = points.filter((p) => p.at >= start && p.at <= end);
const max = field === "cpu" || field === "memory" ? 100
: Math.max(1, ...visible.map((p) => Number.isFinite(p[field]) ? p[field]! : 0));
const runs: MonitorPoint[][] = [];
let run: MonitorPoint[] = [];
for (const p of visible) {
if (p[field] == null || !Number.isFinite(p[field])) {
if (run.length) runs.push(run);
run = [];
continue;
}
if (run.length && p.at - run[run.length - 1].at > interval * 2500) {
runs.push(run);
run = [];
}
run.push(p);
}
if (run.length) runs.push(run);
const segments = runs.map((samples) => {
const selected = new Set([samples[0], samples[samples.length - 1]]);
const columns = new Map<number, { min: MonitorPoint; max: MonitorPoint }>();
for (const p of samples) {
const column = Math.min(149, Math.floor(((p.at - start) / (end - start)) * 150));
const extremes = columns.get(column);
if (!extremes) columns.set(column, { min: p, max: p });
else {
if (p[field]! < extremes.min[field]!) extremes.min = p;
if (p[field]! > extremes.max[field]!) extremes.max = p;
}
}
for (const extremes of columns.values()) {
selected.add(extremes.min);
selected.add(extremes.max);
}
const coordinates = samples.filter((p) => selected.has(p)).map((p) => ({
x: Math.max(0, Math.min(300, ((p.at - start) / (end - start)) * 300)),
y: 58 - (Math.min(max, Math.max(0, p[field]!)) / max) * 54,
}));
const last = coordinates[coordinates.length - 1];
return {
path: coordinates.map((p, i) => `${i ? "L" : "M"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" "),
firstX: coordinates[0].x, x: last.x, y: last.y,
};
});
return { start, end, visible, max, segments };
}
/** Hit actual samples within cursor tolerance, never an interpolated reading. */
export function findMonitorChartPoint(
points: MonitorPoint[],
field: MonitorTrendField,
at: number,
tolerance: number,
): MonitorPoint | null {
let closest: MonitorPoint | null = null;
let distance = tolerance;
for (const point of points) {
const value = point[field];
if (value == null || !Number.isFinite(value)) continue;
const delta = Math.abs(point.at - at);
if (delta <= distance) {
closest = point;
distance = delta;
}
}
return closest;
}
+16 -1
View File
@@ -4,6 +4,8 @@ import {
normalizeMonitorPanels,
rebaseMonitorPanel,
type MonitorPanel,
MONITOR_INTERVALS,
reorderMonitorHosts,
} from "./monitorPanels";
import { mergeSyncPayloads } from "./syncMerge";
import type { SyncPayload } from "./sync";
@@ -22,7 +24,6 @@ const panel: MonitorPanel = {
interval: 10,
range: 15,
sort: "manual",
attentionOnly: false,
};
const payload = (panels?: MonitorPanel[]): SyncPayload => ({
hosts: [],
@@ -32,6 +33,20 @@ const payload = (panels?: MonitorPanel[]): SyncPayload => ({
syncedAt: 1,
...(panels === undefined ? {} : { monitorPanels: panels }),
});
test("drag ordering moves either direction without mutating saved references", () => {
const saved = ["c", "a", "b", "hidden"];
assert.deepEqual(reorderMonitorHosts(saved, "c", "b"), ["a", "b", "c", "hidden"]);
assert.deepEqual(reorderMonitorHosts(saved, "b", "c"), ["b", "c", "a", "hidden"]);
assert.deepEqual(saved, ["c", "a", "b", "hidden"]);
assert.equal(reorderMonitorHosts(saved, "missing", "b"), saved);
assert.equal(reorderMonitorHosts(saved, "a", "a"), saved);
});
test("monitor intervals include each supported cadence and default to five seconds", () => {
assert.deepEqual(MONITOR_INTERVALS, [1, 2, 3, 5, 10, 30, 60]);
for (const interval of MONITOR_INTERVALS)
assert.equal(normalizeMonitorPanels([{ ...panel, interval }])[0].interval, interval);
assert.equal(normalizeMonitorPanels([{ ...panel, interval: undefined }])[0].interval, 5);
});
test("portable panel sanitizer strips all runtime data and preserves ordered references", () => {
const clean = normalizeMonitorPanels([
{
+20 -12
View File
@@ -1,3 +1,6 @@
export const MONITOR_INTERVALS = [1, 2, 3, 5, 10, 30, 60] as const;
export const DEFAULT_MONITOR_INTERVAL = 5;
/** Portable configuration only. Never add samples, connection state or errors here. */
export interface MonitorPanel {
id: string;
@@ -5,10 +8,9 @@ export interface MonitorPanel {
description: string;
hostIds: string[];
view: "list" | "charts";
interval: 10 | 30 | 60;
interval: (typeof MONITOR_INTERVALS)[number];
range: 5 | 15 | 30;
sort: "manual" | "name" | "cpu" | "memory" | "disk";
attentionOnly: boolean;
sort: "manual" | "name";
}
export function normalizeMonitorPanels(value: unknown): MonitorPanel[] {
@@ -42,16 +44,11 @@ export function normalizeMonitorPanels(value: unknown): MonitorPanel[] {
]
: [],
view: p.view === "charts" ? "charts" : "list",
interval: p.interval === 30 || p.interval === 60 ? p.interval : 10,
interval: MONITOR_INTERVALS.includes(p.interval as MonitorPanel["interval"])
? p.interval as MonitorPanel["interval"]
: DEFAULT_MONITOR_INTERVAL,
range: p.range === 5 || p.range === 15 ? p.range : 30,
sort:
p.sort === "name" ||
p.sort === "cpu" ||
p.sort === "memory" ||
p.sort === "disk"
? p.sort
: "manual",
attentionOnly: p.attentionOnly === true,
sort: p.sort === "name" ? "name" : "manual",
},
];
});
@@ -83,6 +80,17 @@ export function normalizeMonitorPanels(value: unknown): MonitorPanel[] {
);
}
/** Move within the displayed ordering, then persist it as the saved ordering. */
export function reorderMonitorHosts(hostIds: string[], from: string, to: string): string[] {
const fromIndex = hostIds.indexOf(from);
const toIndex = hostIds.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return hostIds;
const next = [...hostIds];
next.splice(fromIndex, 1);
next.splice(toIndex, 0, from);
return next;
}
/** Rebase only edited fields onto the latest disk entity; deletion wins over a stale editor. */
export function rebaseMonitorPanel(
latest: MonitorPanel[],
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mergeMonitorSnapshotHistory, monitorStateSince, type MonitorHostState, type MonitorSnapshot } from "./monitorRuntime";
const state: MonitorHostState = {
historyId: "endpoint-a",
status: "live", at: 2000, stats: {
cpu: 50, cpuCores: 4, cpuPerCore: [], memTotal: 100, memUsed: 25,
memFree: 75, memBuffers: 0, memCached: 0, swapTotal: 0, swapUsed: 0,
topProcesses: [], diskPercent: 30, diskUsed: 30, diskTotal: 100, disks: [],
netRxSpeed: 10, netTxSpeed: 20, latencyMs: 1, netInterfaces: [],
},
points: [1000, 2000].map((at) => ({ at, cpu: 50, memory: 25, disk: 30, rx: 10, tx: 20 })),
};
test("starting or changing cadence clears all old readings and chart fields", () => {
const cleared = monitorStateSince(state, 2000)!;
assert.deepEqual(cleared.points, []);
assert.equal(cleared.stats, null);
assert.equal(cleared.at, 0);
assert.equal(cleared.status, "queued");
assert.equal(monitorStateSince(state, undefined), undefined);
});
test("only current-run readings are visible without mutating shared history", () => {
const fresh = monitorStateSince(state, 1500)!;
assert.deepEqual(fresh.points.map((p) => p.at), [2000]);
assert.equal(fresh.stats, state.stats);
assert.equal(fresh.status, "live");
assert.equal(monitorStateSince({ ...state, status: "unsupported" }, 2000)?.status, "unsupported");
assert.equal(monitorStateSince(state, 0)?.points.length, 2);
assert.equal(state.points.length, 2);
assert.equal(monitorStateSince(state, 1500)?.points, fresh.points);
assert.equal(monitorStateSince({ ...state }, 1500)?.points, fresh.points);
assert.notEqual(monitorStateSince(state, 1000)?.points, fresh.points);
});
const snapshot = (host: MonitorHostState): MonitorSnapshot => ({
historyMode: "replace", runtimeId: "worker", epoch: 0, takenAt: 3000,
running: ["p"], startedAt: { p: 0 }, count: 1, hostCount: 1, activeSessions: [], hosts: { h: host },
});
test("incremental snapshots retain history, deduplicate races and replace on worker reset", () => {
const original = snapshot(state);
const point = { ...state.points[1], at: 3000 };
const delta: MonitorSnapshot = { ...snapshot({ ...state, points: [state.points[1], point] }), historyMode: "append", takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(original, delta);
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [1000, 2000, 3000]);
assert.deepEqual(mergeMonitorSnapshotHistory(merged, delta).hosts.h.points, merged.hosts.h.points);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...original, takenAt: 2000 }), merged);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, epoch: 1 }).hosts.h.points.length, 2);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, historyMode: "replace" }).hosts.h.points.length, 2);
assert.equal(original.hosts.h.points.length, 2);
});
test("incremental history expires after thirty minutes and remains bounded", () => {
const points = Array.from({ length: 1800 }, (_, i) => ({ ...state.points[0], at: 10000 + i * 1000 }));
const previous = snapshot({ ...state, points });
const next = { ...snapshot({ ...state, points: [{ ...points.at(-1)!, at: 1810000 }] }), historyMode: "append" as const, takenAt: 1810000 };
const merged = mergeMonitorSnapshotHistory(previous, next);
assert.equal(merged.hosts.h.points.length, 1800);
assert.equal(merged.hosts.h.points[0].at, 11000);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...next, takenAt: 4000000 }).hosts.h.points.length, 0);
});
test("delayed replies cannot restore a stopped epoch or retired worker", () => {
const stopped: MonitorSnapshot = { ...snapshot(state), epoch: 1, takenAt: 4000, running: [], hosts: {}, startedAt: {} };
const old = { ...snapshot(state), historyMode: "append" as const };
assert.equal(mergeMonitorSnapshotHistory(stopped, old), stopped);
assert.equal(mergeMonitorSnapshotHistory(stopped, { ...old, takenAt: 5000 }), stopped);
const restarted = { ...stopped, runtimeId: "new-worker", epoch: 0 };
assert.equal(mergeMonitorSnapshotHistory(restarted, old), restarted);
const fresh = { ...restarted, runtimeId: "next-worker", takenAt: 5000 };
assert.equal(mergeMonitorSnapshotHistory(restarted, fresh), fresh);
});
test("a new endpoint replaces retained points even when its first delta races a configuration response", () => {
const original = snapshot(state);
const changed: MonitorSnapshot = { ...snapshot({ ...state, historyId: "endpoint-b", points: [{ ...state.points[0], at: 2500 }] }), historyMode: "append", takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(original, changed);
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [2500]);
assert.equal(merged.hosts.h.historyId, "endpoint-b");
});
test("unchanged stats are retained by reference while fresh, cleared and new-endpoint stats replace them", () => {
const previous = snapshot(state);
const delta = { ...snapshot({ ...state, stats: undefined, points: [] }), historyMode: "append" as const, takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(previous, delta);
assert.equal(merged.hosts.h.stats, state.stats);
assert.equal(merged.hosts.h.points, state.points);
assert.equal(monitorStateSince(merged.hosts.h, 0), merged.hosts.h);
assert.deepEqual(merged.hosts.h.points, state.points);
const fresh = { ...state.stats!, cpu: 67 };
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: fresh, points: [] } } }).hosts.h.stats, fresh);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: null, points: [] } } }).hosts.h.stats, null);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, historyId: "endpoint-b", stats: null, points: [] } } }).hosts.h.stats, null);
});
+70 -16
View File
@@ -11,6 +11,7 @@ export type MonitorPoint = {
tx: number | null;
};
export type MonitorHostState = {
historyId: string;
status:
| "queued"
| "connecting"
@@ -21,10 +22,12 @@ export type MonitorHostState = {
| "disconnected"
| "limit";
at: number;
stats: MonitorStats | null;
/** Omitted by incremental replies when the receiver already has this sample. */
stats?: MonitorStats | null;
points: MonitorPoint[];
};
export type MonitorSnapshot = {
historyMode: "replace" | "append";
running: string[];
count: number;
hostCount: number;
@@ -32,11 +35,77 @@ export type MonitorSnapshot = {
runtimeId: string;
takenAt: number;
activeSessions: string[];
startedAt: Record<string, number>;
unavailable?: boolean;
hosts: Record<string, MonitorHostState>;
};
export type MonitorHistoryCursor = {
runtimeId: string;
epoch: number;
after: Record<string, { at: number; statsAt: number; historyId: string }>;
};
/** Incremental transport keeps the full bounded history in the renderer. */
export function mergeMonitorSnapshotHistory(previous: MonitorSnapshot, next: MonitorSnapshot): MonitorSnapshot {
if (previous.runtimeId !== next.runtimeId) {
// An old worker reply can still be in transit after its replacement starts.
if (previous.runtimeId && next.runtimeId && next.takenAt <= previous.takenAt) return previous;
return next;
}
if (next.epoch < previous.epoch) return previous;
if (next.epoch > previous.epoch) return next;
if (next.takenAt < previous.takenAt) return previous;
if (next.historyMode === "replace") return next;
return {
...next,
hosts: Object.fromEntries(Object.entries(next.hosts).map(([id, host]) => {
if (previous.hosts[id]?.historyId !== host.historyId) return [id, host];
const retained = previous.hosts[id]?.points ?? [];
const last = retained.at(-1)?.at ?? 0;
const added = host.points.filter((point) => point.at > last);
const points = added.length || (retained[0] && next.takenAt - retained[0].at > 30 * 60000)
? [...retained, ...added].filter((point) => next.takenAt - point.at <= 30 * 60000).slice(-1800)
: retained;
return [id, { ...host, stats: host.stats === undefined ? previous.hosts[id]?.stats : host.stats,
points }];
})),
};
}
const filteredHistories = new WeakMap<MonitorPoint[], Map<number, MonitorPoint[]>>();
function pointsSince(points: MonitorPoint[], startedAt: number) {
if (!points.length || points[0].at > startedAt) return points;
let cache = filteredHistories.get(points);
if (!cache) {
cache = new Map();
filteredHistories.set(points, cache);
}
let filtered = cache.get(startedAt);
if (!filtered) {
filtered = points.filter((point) => point.at > startedAt);
cache.set(startedAt, filtered);
if (cache.size > 32) cache.delete(cache.keys().next().value!);
}
return filtered;
}
/** A panel only displays samples collected during its current cadence/run. */
export function monitorStateSince(state: MonitorHostState | undefined, startedAt: number | undefined): MonitorHostState | undefined {
if (!state || typeof startedAt !== "number") return undefined;
const fresh = state.at > startedAt;
if (fresh && (!state.points.length || state.points[0].at > startedAt)) return state;
return {
...state,
at: fresh ? state.at : 0,
stats: fresh ? state.stats : null,
status: !fresh && state.status === "live" ? "queued" : state.status,
points: pointsSince(state.points, startedAt),
};
}
export type MonitorPanelDemand = {
id: string;
runId: string;
interval: MonitorPanel["interval"];
hosts: {
id: string;
@@ -44,18 +113,3 @@ export type MonitorPanelDemand = {
status?: "login" | "error";
}[];
};
export function monitorNeedsAttention(
state: MonitorHostState | undefined,
interval: number,
now = Date.now(),
): boolean {
if (!state || state.status !== "live" || now - state.at > interval * 2500)
return true;
const s = state.stats;
return (
!!s &&
((s.cpu ?? 0) >= 85 ||
(s.memTotal && s.memUsed ? s.memUsed / s.memTotal >= 0.9 : false) ||
s.disks.some((d) => d.percent >= 90))
);
}
+32 -4
View File
@@ -72,6 +72,8 @@ function createMonitorService({
conn: entry.transport.conn,
hostname: entry.hostname,
_monitorStats: true,
_monitorIntervalMs: intervalFor(entry) * 1000,
_monitorFreshAfter: Math.max(...[...panels.values()].filter((panel) => panel.keys.includes(entry.key)).map((panel) => panel.startedAt)),
_reuseEndpoint: entry.transport.endpoint,
});
if (entry.cancelled) return;
@@ -136,6 +138,7 @@ function createMonitorService({
publishState(entry, "unsupported");
return;
}
entry.ready = true;
void collect(entry);
}
function connect(entry, event, options) {
@@ -218,6 +221,7 @@ function createMonitorService({
if (!Array.isArray(input)) throw new Error("Invalid monitor configuration");
const owner = event.sender.id;
const wanted = new Set();
const freshKeys = new Set();
for (const panel of input) {
if (typeof panel.id !== "string" || !Array.isArray(panel.hosts)) continue;
const id = `${owner}:${panel.id}`;
@@ -246,13 +250,19 @@ function createMonitorService({
hosts.push({ id: host.id, key });
hosts[hosts.length - 1].options = options;
}
const interval = [1, 2, 3, 5, 10, 30, 60].includes(panel.interval) ? panel.interval : 5;
const previous = panels.get(id);
const fresh = !previous || previous.interval !== interval || previous.runId !== panel.runId;
if (fresh) for (const key of keys) freshKeys.add(key);
panels.set(id, {
event,
owner,
id: panel.id,
keys,
hosts,
interval: [10, 30, 60].includes(panel.interval) ? panel.interval : 10,
interval,
runId: panel.runId,
startedAt: fresh ? now() : previous.startedAt,
});
}
for (const [id, panel] of panels)
@@ -278,6 +288,12 @@ function createMonitorService({
release(entry);
admitEntries(owner);
for (const entry of entries.values()) {
if (freshKeys.has(entry.key) && entry.ready && entry.transport && !entry.sampling) {
if (entry.timer) timers.clearTimeout(entry.timer);
entry.timer = null;
void collect(entry);
continue;
}
if (entry.timer && entry.due - now() > intervalFor(entry) * 1000) {
timers.clearTimeout(entry.timer);
entry.due = now() + intervalFor(entry) * 1000;
@@ -303,12 +319,14 @@ function createMonitorService({
if (!host.key || entries.has(host.key) || entries.size >= 256) continue;
const entry = {
key: host.key,
historyId: randomUUID(),
hostname: host.options.hostname,
sessionId: `monitor-${randomUUID()}`,
holder: {},
owner: panel.owner,
status: "queued",
failures: 0,
ready: false,
transport: null,
timer: null,
cancelled: false,
@@ -318,8 +336,9 @@ function createMonitorService({
connect(entry, panel.event, host.options);
}
}
function snapshot(event) {
function snapshot(event, cursor) {
const own = [...panels.values()].filter((p) => p.owner === event.sender.id);
const append = cursor?.runtimeId === runtimeId && cursor.epoch === epoch;
const hosts = {};
for (const panel of own)
for (const host of panel.hosts) {
@@ -327,15 +346,23 @@ function createMonitorService({
const shared = entry?.transport
? readSharedStats(entry.transport.conn)
: null;
const historyId = entry?.historyId ?? "";
const last = cursor?.after?.[host.id];
hosts[host.id] = {
historyId,
status: entry?.status ?? host.status ?? "limit",
at: shared?.at ?? 0,
stats: shared?.result?.success ? shared.result.stats : null,
points: shared?.points ?? [],
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
? {}
: { stats: shared?.result?.success ? shared.result.stats : null }),
points: append && last?.historyId === historyId
? (shared?.points ?? []).filter((point) => point.at > last.at)
: shared?.points ?? [],
};
}
return {
epoch,
historyMode: append ? "append" : "replace",
runtimeId,
takenAt: now(),
activeSessions: [
@@ -348,6 +375,7 @@ function createMonitorService({
),
],
running: own.map((p) => p.id),
startedAt: Object.fromEntries(own.map((p) => [p.id, p.startedAt])),
count: panels.size,
hostCount: entries.size,
hosts,
+196
View File
@@ -19,9 +19,33 @@ const options = {
};
const demand = (id = "p", opts = options) => ({
id,
runId: "first-run",
interval: 10,
hosts: [{ id: "h", options: opts }],
});
test("each start and interval change begins fresh panel history while other panels keep theirs", () => {
let now = 1000;
const f = fixture({ now: () => now });
const a = { ...demand("a"), hosts: [] };
const b = { ...demand("b"), hosts: [] };
f.service.reconcile(event(1), [a, b]);
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 1000, b: 1000 });
now = 2000;
f.service.reconcile(event(1), [a, b]);
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 1000, b: 1000 });
const changed = { ...a, interval: 5 };
f.service.reconcile(event(1), [changed, b]);
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 2000, b: 1000 });
now = 3000;
f.service.reconcile(event(1), [{ ...changed, runId: "second-run" }, b]);
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 3000, b: 1000 });
f.service.reconcile(event(1), [b]);
now = 4000;
f.service.reconcile(event(1), [changed, b]);
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 4000, b: 1000 });
f.service.dispose();
});
const tick = () => new Promise((resolve) => setImmediate(resolve));
async function until(predicate) {
const deadline = Date.now() + 5000;
@@ -556,3 +580,175 @@ for (const sharedHost of ["first", "second"]) {
f.service.dispose();
});
}
for (const interval of [1, 2, 3, 5, 10, 30, 60]) {
test(`monitor schedules ${interval}s collection and passes its cache demand`, async () => {
let requested;
const f = fixture({ getStats: async (context) => {
requested = context._monitorIntervalMs;
return { success: true, stats: {} };
} });
f.service.reconcile(event(1), [{ ...demand(), interval }]);
await until(() => f.timers.length > 0);
assert.equal(f.timers.at(-1).ms, interval * 1000);
assert.equal(requested, interval * 1000);
f.service.dispose();
});
}
test("one-second monitoring bypasses the terminal's four-second cache and records bounded history", async () => {
const c = conn();
const original = Date.now;
let now = 100000, calls = 0;
Date.now = () => now;
const session = { conn: c, _monitorStats: true, _monitorIntervalMs: 1000 };
const collect = async () => {
calls++;
return { success: true, stats: { cpu: calls % 100, memTotal: 100, memUsed: 50 } };
};
try {
await sampleSharedStats(session, collect, now);
now += 500;
await sampleSharedStats(session, collect, now);
assert.equal(calls, 1);
now += 500;
await sampleSharedStats(session, collect, now);
assert.equal(calls, 2);
assert.equal(readSharedStats(c).points.length, 2);
for (let i = 0; i < 2000; i++) {
now += 1000;
await sampleSharedStats(session, collect, now);
}
const history = readSharedStats(c).points;
assert.equal(history.length, 1800);
assert.ok(history.every((p) => now - p.at <= 1800000));
} finally {
Date.now = original;
}
});
test("fast monitoring stays lightweight while terminal full reads retain their own cache", async (t) => {
let now = 100000;
t.mock.method(Date, "now", () => now);
const c = conn();
const fullTimes = [], lightTimes = [];
const collect = async (ctx) => {
(ctx._monitorStats ? lightTimes : fullTimes).push(now);
return { success: true, stats: { cpu: 25, memTotal: 100, memUsed: 50 } };
};
await sampleSharedStats({ conn: c }, collect);
for (let second = 1; second <= 10; second++) {
now += 1000;
await sampleSharedStats({ conn: c, _monitorStats: true, _monitorIntervalMs: 1000 }, collect);
if ([2, 5, 7, 10].includes(second)) await sampleSharedStats({ conn: c }, collect);
}
assert.deepEqual(fullTimes, [100000, 105000, 110000]);
assert.equal(lightTimes.length, 10);
});
test("changing to a long cadence immediately records a fresh point instead of reusing pre-reset cache", async () => {
let calls = 0;
const f = fixture({ getStats: (ctx) => sampleSharedStats(ctx, async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
calls++;
return { success: true, stats: { cpu: calls, memTotal: 100, memUsed: 50 } };
}) });
f.service.reconcile(event(1), [{ ...demand(), interval: 1 }]);
await until(() => f.timers.length === 1);
const previousAt = f.service.snapshot(event(1)).hosts.h.points[0].at;
f.service.reconcile(event(1), [{ ...demand(), interval: 60 }]);
await until(() => calls === 2 && f.timers.at(-1).ms === 60000);
const snapshot = f.service.snapshot(event(1));
assert.equal(snapshot.hosts.h.stats.cpu, 2);
assert.ok(snapshot.hosts.h.at > snapshot.startedAt.p);
assert.equal(snapshot.hosts.h.points.length, 2);
assert.ok(snapshot.hosts.h.points[1].at - previousAt < 1000);
assert.ok(f.timers[0].cancelled);
f.service.dispose();
});
test("reset joining a terminal query still records its first fresh point within one second", async (t) => {
let now = 100000;
t.mock.method(Date, "now", () => now);
const c = conn();
const result = { success: true, stats: { cpu: 50, memTotal: 100, memUsed: 25 } };
await sampleSharedStats({ conn: c, _monitorStats: true }, async () => result);
now = 100100;
let finish;
const terminal = sampleSharedStats({ conn: c }, () => new Promise((resolve) => { finish = resolve; }));
now = 100200;
const monitor = sampleSharedStats({ conn: c, _monitorStats: true, _monitorIntervalMs: 60000, _monitorFreshAfter: now }, async () => { throw new Error("must join terminal query"); });
now = 100300;
finish(result);
await Promise.all([terminal, monitor]);
assert.deepEqual(readSharedStats(c).points.map((point) => point.at), [100000, 100300]);
});
test("fresh monitoring never samples a reused terminal before its restricted banner check", async () => {
const f = fixture();
f.c._remoteVer = "SSH-2.0-FortiSSH_1.0";
const transport = pool.createTransport({ conn: f.c, endpoint: pool.buildConnectionReuseEndpoint(options) });
const terminal = {};
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder: terminal });
f.service.reconcile(event(1), [demand()]);
await until(() => f.service.snapshot(event(1)).hosts.h.status === "unsupported");
assert.equal(f.samples, 0);
assert.equal(transport.count, 1);
f.service.dispose();
pool.returnTransport(terminal);
});
test("snapshot deltas send new points only, even with 256 full retained histories", async (t) => {
const connections = [];
const f = fixture({
startConnection: async (_event, opts) => {
const c = conn();
connections.push(c);
return { transport: pool.createTransport({ conn: c, endpoint: pool.buildConnectionReuseEndpoint(opts) }) };
},
});
const hosts = Array.from({ length: 256 }, (_, index) => ({ id: `h${index}`, options: { ...options, hostname: `host${index}` } }));
f.service.reconcile(event(1), [{ ...demand(), hosts }]);
await until(() => f.timers.length === 256);
t.mock.timers.enable({ apis: ["Date"], now: Date.now() });
const at = Date.now();
for (const c of connections) {
const shared = readSharedStats(c);
shared.result.stats = { ...shared.result.stats,
disks: Array.from({ length: 32 }, (_, index) => ({ mount: `/disk/${index}`, total: 1024, used: 512 })),
netInterfaces: Array.from({ length: 32 }, (_, index) => ({ name: `eth${index}`, rx: 10, tx: 20 })),
cpuPerCore: Array.from({ length: 64 }, () => 50),
};
const points = shared.points;
points.length = 0;
for (let i = 0; i < 1800; i++) points.push({ at: at - (1799 - i) * 1000, cpu: i % 100, memory: 50, disk: 40, rx: 10, tx: 20 });
}
const initial = f.service.snapshot(event(1));
const initialBytes = JSON.stringify(initial).length;
const cursor = { runtimeId: initial.runtimeId, epoch: initial.epoch, after: Object.fromEntries(hosts.map((host) => [host.id, { at, statsAt: initial.hosts[host.id].at, historyId: initial.hosts[host.id].historyId }])) };
for (const c of connections) {
const points = readSharedStats(c).points;
points.shift();
points.push({ at: at + 1000, cpu: 51, memory: 52, disk: 40, rx: 10, tx: 20 });
}
const delta = f.service.snapshot(event(1), cursor);
assert.equal(initial.historyMode, "replace");
assert.equal(delta.historyMode, "append");
assert.equal(Object.values(delta.hosts).reduce((count, host) => count + host.points.length, 0), 256);
assert.ok(Object.values(delta.hosts).every((host) => !Object.hasOwn(host, "stats")));
assert.ok(JSON.stringify(delta).length < initialBytes / 100);
const caughtUp = { ...cursor, after: Object.fromEntries(hosts.map((host) => [host.id, { ...cursor.after[host.id], at: at + 1000 }])) };
assert.ok(Object.values(f.service.snapshot(event(1), caughtUp).hosts).every((host) => host.points.length === 0));
const staleIdentity = { ...cursor, after: { ...cursor.after, h0: { at: at + 999999, historyId: "old-endpoint" } } };
assert.equal(f.service.snapshot(event(1), staleIdentity).hosts.h0.points.length, 1800);
assert.deepEqual(f.service.snapshot(event(1), staleIdentity).hosts.h0.stats, initial.hosts.h0.stats);
assert.equal(f.service.snapshot(event(1), { ...cursor, runtimeId: "old-worker" }).historyMode, "replace");
const shared = readSharedStats(connections[0]);
t.mock.timers.tick(5);
await sampleSharedStats({ conn: connections[0], _monitorStats: true }, async () =>
({ success: true, stats: { ...shared.result.stats, cpu: 67 } }), shared.at + 5000);
const fresh = f.service.snapshot(event(1), caughtUp);
assert.equal(fresh.hosts.h0.stats.cpu, 67);
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
f.service.dispose();
});
+1 -1
View File
@@ -1459,7 +1459,7 @@ const monitorService = createMonitorService({ startConnection: startSSHSession,
});
const monitorHandlers = {
"netcatty:monitor:update": (event, payload) => monitorService.reconcile(event, payload),
"netcatty:monitor:snapshot": (event) => monitorService.snapshot(event),
"netcatty:monitor:snapshot": (event, cursor) => monitorService.snapshot(event, cursor),
"netcatty:monitor:stop-all": () => monitorService.stopAll(),
"netcatty:monitor:owner-close": (_event, owner) => monitorService.removeOwner(owner),
"netcatty:monitor:retry": (event, hostId) => monitorService.retry(event, hostId),
+25 -9
View File
@@ -26,7 +26,8 @@ function stateFor(conn, endpoint) {
controller: null,
latestFull: false,
pendingFull: false,
fullUntil: 0,
fullResult: null,
fullAt: 0,
};
}
states.set(conn, state);
@@ -45,11 +46,11 @@ function stateFor(conn, endpoint) {
}
return state;
}
function appendPoint(state, result, now) {
function appendPoint(state, result, now, freshAfter = 0) {
if (
!result.success ||
!result.stats ||
now - (state.points.at(-1)?.at ?? 0) < 10000
(now - (state.points.at(-1)?.at ?? 0) < 1000 && (state.points.at(-1)?.at ?? 0) > freshAfter)
)
return;
const s = result.stats;
@@ -66,7 +67,7 @@ function appendPoint(state, result, now) {
});
state.points = state.points
.filter((p) => now - p.at <= 30 * 60000)
.slice(-180);
.slice(-1800);
}
async function sampleSharedStats(session, collect, now = Date.now()) {
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
@@ -80,15 +81,25 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
state.latest = result;
state.at = Date.now();
state.latestFull = true;
state.fullResult = result;
state.fullAt = state.at;
}
return result;
}
const state = stateFor(conn, session._reuseEndpoint);
const full = !session._monitorStats;
if (full) state.fullUntil = now + 15000;
const cacheMs = full ? 4000 : Math.min(4000, session._monitorIntervalMs ?? 5000);
if (full && state.fullResult?.success && now - state.fullAt < 4000)
return state.fullResult;
if (state.pending) {
if (full) state.terminalJoined = true;
if (!full || state.pendingFull) return state.pending;
if (!full || state.pendingFull) {
const result = await state.pending;
// A reset can join a terminal query that began before the new boundary.
if (!full && state.at > session._monitorFreshAfter)
appendPoint(state, result, state.at, session._monitorFreshAfter);
return result;
}
// A terminal needs process/GPU fields omitted from a monitor-only query.
// Finish the shared lightweight query, then upgrade once for all full readers.
await state.pending;
@@ -96,11 +107,12 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
}
if (
state.latest?.success &&
now - state.at < 4000 &&
now - state.at < cacheMs &&
(full || state.at > (session._monitorFreshAfter ?? 0)) &&
(!full || state.latestFull)
)
return state.latest;
const collectFull = full || now < state.fullUntil;
const collectFull = full;
// Keep transport capabilities on the collection context; baseline is independent of a terminal.
const companion = !session.conn;
Object.assign(state.context, {
@@ -122,7 +134,11 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
state.latest = result;
state.latestFull = collectFull;
state.at = Date.now();
appendPoint(state, result, state.at);
if (collectFull) {
state.fullResult = result;
state.fullAt = state.at;
}
appendPoint(state, result, state.at, session._monitorFreshAfter);
}
return result;
})
+1 -1
View File
@@ -304,7 +304,7 @@ function createPreloadApi(ctx) {
return result.sessionId;
},
monitorUpdate: (panels) => ipcRenderer.invoke("netcatty:monitor:update", panels),
monitorSnapshot: () => ipcRenderer.invoke("netcatty:monitor:snapshot"),
monitorSnapshot: (cursor) => ipcRenderer.invoke("netcatty:monitor:snapshot", cursor),
monitorStopAll: () => ipcRenderer.invoke("netcatty:monitor:stop-all"),
monitorRetry: (hostId) => ipcRenderer.invoke("netcatty:monitor:retry", hostId),
testConnection: async (options) => {
+5
View File
@@ -2130,3 +2130,8 @@ html[data-theme-transition] *::after {
--terminal-host-tree-drop-bg: var(--nc-term-host-tree-drop-bg, transparent);
--terminal-host-tree-folder-fg: var(--nc-term-host-tree-folder-fg, var(--foreground));
}
/* Monitoring's single-row header follows the available pane width. */
@container monitor-main (max-width: 760px) {
.monitor-header-action-label { display: none; }
}
+1 -1
View File
@@ -4,7 +4,7 @@ declare global {
monitorUpdate?(
panels: import("../../domain/monitorRuntime").MonitorPanelDemand[],
): Promise<import("../../domain/monitorRuntime").MonitorSnapshot>;
monitorSnapshot?(): Promise<
monitorSnapshot?(cursor?: import("../../domain/monitorRuntime").MonitorHistoryCursor): Promise<
import("../../domain/monitorRuntime").MonitorSnapshot
>;
monitorStopAll?(): Promise<void>;