mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 05:43:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680eb5eb0f | ||
|
|
df5c033dfa | ||
|
|
b5fee9632e | ||
|
|
fb69f4a48d |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выбрать серверы",
|
||||
|
||||
@@ -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": "选择主机",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -21,6 +21,17 @@ async function fixture(t: test.TestContext) {
|
||||
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
|
||||
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
|
||||
value: (id: number) => frames.delete(id) });
|
||||
const resizeObservers = new Map<ResizeObserver, Set<Element>>();
|
||||
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
|
||||
class LayoutResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
|
||||
observe(target: Element) { resizeObservers.get(this)?.add(target); }
|
||||
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
|
||||
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
|
||||
}
|
||||
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
|
||||
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
|
||||
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
|
||||
const style = env.document.createElement('div').style;
|
||||
style.colorScheme = 'dark';
|
||||
globalThis.getComputedStyle = () => style;
|
||||
@@ -40,8 +51,11 @@ async function fixture(t: test.TestContext) {
|
||||
isVisible: visible });
|
||||
return React.createElement('div', { ref: panel.elementRef });
|
||||
}
|
||||
const element = { isConnected: true, closest: () => null,
|
||||
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
|
||||
const element = env.document.createElement('div');
|
||||
env.document.body.append(element);
|
||||
let bounds = new env.window.DOMRect(200, 100, 500, 600);
|
||||
let reads = 0;
|
||||
element.getBoundingClientRect = () => { reads += 1; return bounds; };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => {
|
||||
if (renderer) await act(async () => renderer.unmount());
|
||||
@@ -62,11 +76,15 @@ async function fixture(t: test.TestContext) {
|
||||
};
|
||||
const tick = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(0);
|
||||
});
|
||||
};
|
||||
return { layouts, overlay, tick, panel: () => panel,
|
||||
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
|
||||
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
|
||||
observerCount: () => resizeObservers.size,
|
||||
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
|
||||
respond: (callback: typeof respond) => { respond = callback; },
|
||||
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
|
||||
}
|
||||
@@ -128,3 +146,103 @@ test('hiding or locking the pane clears pictures without taking another capture'
|
||||
await f.hide();
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
|
||||
test('a stationary visible browser leaves no repeating layout work', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (let index = 0; index < 600; index += 1) await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
|
||||
});
|
||||
|
||||
|
||||
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const reads = f.reads();
|
||||
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
f.resize();
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
|
||||
}
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
f.env.document.dispatchEvent(new f.env.window.Event('load'));
|
||||
f.style.colorScheme = 'light';
|
||||
f.env.document.documentElement.classList.add('light');
|
||||
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), reads + 1);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
|
||||
assert.equal(f.layouts.at(-1)?.theme, 'light');
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
f.env.document.body.setAttribute('inert', ''); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
f.env.document.body.removeAttribute('inert'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
});
|
||||
|
||||
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip, 10, 10); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
|
||||
f.resize(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
await f.hide();
|
||||
assert.equal(f.observerCount(), 0);
|
||||
const reads = f.reads();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>');
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.reads(), reads);
|
||||
});
|
||||
|
||||
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const animation = { pending: false, playState: 'running' };
|
||||
f.element.getAnimations = () => [animation as unknown as Animation];
|
||||
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
|
||||
const reads = f.reads();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
|
||||
await f.tick();
|
||||
}
|
||||
assert.equal(f.reads(), reads + 5);
|
||||
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
|
||||
animation.playState = 'finished'; await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
|
||||
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
|
||||
spinner.getAnimations = () => [animation as unknown as Animation];
|
||||
const settledReads = f.reads();
|
||||
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
|
||||
});
|
||||
|
||||
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip);
|
||||
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 1);
|
||||
popper.remove(); await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const captures: ((result: Result) => void)[] = [];
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
|
||||
const first = f.overlay(tooltip); await f.tick();
|
||||
first.remove(); await f.tick();
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.equal(captures.length, 2);
|
||||
await act(async () => captures[0]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
|
||||
await act(async () => captures[1]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
});
|
||||
@@ -38,9 +38,33 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let frame: number | null = null;
|
||||
let previous = '';
|
||||
let disposed = false;
|
||||
let layoutRevision = 0;
|
||||
const element = elementRef.current;
|
||||
const doc = element?.ownerDocument;
|
||||
const view = doc?.defaultView;
|
||||
const observed = new Set<Element>();
|
||||
const animations = new Map<Animation, Element>();
|
||||
const schedule = () => {
|
||||
if (disposed || frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
for (const [animation, target] of animations) {
|
||||
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
|
||||
}
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
});
|
||||
};
|
||||
const trackAnimations = (target: Element) => {
|
||||
for (const animation of target.getAnimations?.() ?? []) {
|
||||
if (!animation.pending && animation.playState !== 'running') continue;
|
||||
animations.set(animation, target);
|
||||
}
|
||||
};
|
||||
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
@@ -48,9 +72,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
)).some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (observed.has(target)) continue;
|
||||
observed.add(target);
|
||||
resizeObserver?.observe(target);
|
||||
trackAnimations(target);
|
||||
}
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
@@ -63,17 +99,59 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const next = JSON.stringify(layout);
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || previous !== next) return;
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
if (!isVisible) {
|
||||
setSnapshot(null);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout, theme and portal changes coalesce into a single frame. Keep
|
||||
// following only running animations on the pane's ancestors or overlay
|
||||
// containers; unrelated spinner/cursor animations must not restart polling.
|
||||
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
};
|
||||
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
|
||||
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
|
||||
doc?.addEventListener('scroll', schedule, true);
|
||||
doc?.addEventListener('load', schedule, true);
|
||||
view?.addEventListener('resize', schedule);
|
||||
doc?.fonts?.addEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.addEventListener('loadingerror', schedule);
|
||||
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(frame);
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
|
||||
doc?.removeEventListener('scroll', schedule, true);
|
||||
doc?.removeEventListener('load', schedule, true);
|
||||
view?.removeEventListener('resize', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingerror', schedule);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Catty prompt cache repair and verification
|
||||
|
||||
## Scope and issue history
|
||||
|
||||
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
|
||||
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
|
||||
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
|
||||
|
||||
## Confirmed defects and changes
|
||||
|
||||
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
|
||||
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
|
||||
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
|
||||
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
|
||||
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
|
||||
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
|
||||
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
|
||||
|
||||
## Comparison with mature agents and official guidance
|
||||
|
||||
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
|
||||
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
|
||||
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
|
||||
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
|
||||
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
|
||||
|
||||
## Live verification
|
||||
|
||||
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
|
||||
|
||||
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
|
||||
|
||||
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
|
||||
|
||||
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
|
||||
|
||||
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
|
||||
| Command execution | 12 | 98.59% | 98.12%-99.03% |
|
||||
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
|
||||
|
||||
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
|
||||
|
||||
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
|
||||
|
||||
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
|
||||
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
|
||||
- Production build passed.
|
||||
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
|
||||
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
|
||||
|
||||
Full-repository verification also found setup and unrelated timing limits:
|
||||
|
||||
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
|
||||
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
|
||||
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
|
||||
|
||||
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
|
||||
@@ -0,0 +1,955 @@
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"endpoint": "https://api.deepseek.com",
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": "synthetic prompts and in-memory tool output",
|
||||
"totalTurns": 36,
|
||||
"totalModelCalls": 72,
|
||||
"summary": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"turns": 12,
|
||||
"inputTokens": 162870,
|
||||
"cachedTokens": 160512,
|
||||
"weightedHitRate": 98.55,
|
||||
"minTurnRate": 98.19,
|
||||
"maxTurnRate": 98.92
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"turns": 12,
|
||||
"inputTokens": 341856,
|
||||
"cachedTokens": 337024,
|
||||
"weightedHitRate": 98.59,
|
||||
"minTurnRate": 98.12,
|
||||
"maxTurnRate": 99.03
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"turns": 12,
|
||||
"inputTokens": 598639,
|
||||
"cachedTokens": 585472,
|
||||
"weightedHitRate": 97.8,
|
||||
"minTurnRate": 97.3,
|
||||
"maxTurnRate": 98.32
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 21,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 690
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 898
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 982
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 611
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 676
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27473,
|
||||
"cachedTokens": 27008,
|
||||
"hitRate": 98.31,
|
||||
"outputTokens": 384,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 5711
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 28156,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 93,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4606
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28538,
|
||||
"cachedTokens": 28160,
|
||||
"hitRate": 98.68,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4738
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28940,
|
||||
"cachedTokens": 28544,
|
||||
"hitRate": 98.63,
|
||||
"outputTokens": 113,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5000
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 29342,
|
||||
"cachedTokens": 29056,
|
||||
"hitRate": 99.03,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 5011
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29714,
|
||||
"cachedTokens": 29312,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4517
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42570,
|
||||
"cachedTokens": 41472,
|
||||
"hitRate": 97.42,
|
||||
"outputTokens": 344,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 6895
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45647,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.3,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5644
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48547,
|
||||
"cachedTokens": 47488,
|
||||
"hitRate": 97.82,
|
||||
"outputTokens": 165,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5271
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51449,
|
||||
"cachedTokens": 50304,
|
||||
"hitRate": 97.77,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5826
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54353,
|
||||
"cachedTokens": 53248,
|
||||
"hitRate": 97.97,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 6235
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57280,
|
||||
"cachedTokens": 56320,
|
||||
"hitRate": 98.32,
|
||||
"outputTokens": 168,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5692
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 21
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 364
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13942,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14018,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 73
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14138,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14214,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14324,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14400,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 93
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14540,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14616,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14726,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14802,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14912,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 243
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14394,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14618,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14705,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15360,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15582,
|
||||
"cachedTokens": 15232,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15669,
|
||||
"cachedTokens": 15488,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16326,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 98
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16552,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16639,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17294,
|
||||
"cachedTokens": 16640,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17516,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17603,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18261,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18489,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18576,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19236,
|
||||
"cachedTokens": 18560,
|
||||
"completionTokens": 101
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19468,
|
||||
"cachedTokens": 19328,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 57,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1278
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 809
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 958
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 857
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1061
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27300,
|
||||
"cachedTokens": 26880,
|
||||
"hitRate": 98.46,
|
||||
"outputTokens": 195,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 4773
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 27785,
|
||||
"cachedTokens": 27264,
|
||||
"hitRate": 98.12,
|
||||
"outputTokens": 84,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4529
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28142,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.7,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4584
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28482,
|
||||
"cachedTokens": 28032,
|
||||
"hitRate": 98.42,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 4706
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 28822,
|
||||
"cachedTokens": 28416,
|
||||
"hitRate": 98.59,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 4558
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29162,
|
||||
"cachedTokens": 28800,
|
||||
"hitRate": 98.76,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4496
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42428,
|
||||
"cachedTokens": 41344,
|
||||
"hitRate": 97.45,
|
||||
"outputTokens": 270,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 5945
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45447,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.73,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5125
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48356,
|
||||
"cachedTokens": 47232,
|
||||
"hitRate": 97.68,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5238
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51279,
|
||||
"cachedTokens": 50176,
|
||||
"hitRate": 97.85,
|
||||
"outputTokens": 167,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5535
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54195,
|
||||
"cachedTokens": 53120,
|
||||
"hitRate": 98.02,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 5977
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57088,
|
||||
"cachedTokens": 55936,
|
||||
"hitRate": 97.98,
|
||||
"outputTokens": 162,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5391
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 57
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 191
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13769,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13829,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 80
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13956,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14016,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14126,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14186,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14296,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14356,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14466,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14526,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14636,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 166
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14320,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14550,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14637,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15293,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15517,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15604,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16262,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16490,
|
||||
"cachedTokens": 16256,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16577,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17236,
|
||||
"cachedTokens": 16512,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17466,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17553,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18209,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18433,
|
||||
"cachedTokens": 18176,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18520,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19174,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 95
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19394,
|
||||
"cachedTokens": 19200,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -35,11 +35,24 @@ async function capture(file, selector) {
|
||||
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
|
||||
}
|
||||
async function click(selector) {
|
||||
// Electron requires native focus before sendInputEvent can deliver a click.
|
||||
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
|
||||
await until(() => owner.isFocused(), true, 'acceptance window has native focus for click');
|
||||
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
|
||||
assert.ok(rect, `missing ${selector}`);
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
|
||||
await run('new Promise(resolve => requestAnimationFrame(resolve))');
|
||||
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(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
}
|
||||
async function pressEscape() {
|
||||
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
|
||||
await until(() => owner.isFocused(), true, 'acceptance window has native focus for Escape');
|
||||
await run('new Promise(resolve => requestAnimationFrame(resolve))');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
owner.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
}
|
||||
async function input(selector, value) {
|
||||
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
|
||||
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
|
||||
@@ -93,6 +106,65 @@ async function main() {
|
||||
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
|
||||
const page = root.children[0].webContents;
|
||||
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
|
||||
// A visible but stationary browser must not spend every frame reading the
|
||||
// whole renderer. Settle startup/FontFaceSet work before observing idle time.
|
||||
await wait(300);
|
||||
const assertIdleLayout = async () => {
|
||||
await run(`(() => {
|
||||
const query=document.querySelector.bind(document),queryAll=document.querySelectorAll.bind(document),style=getComputedStyle;
|
||||
window.layoutReads={queries:0,styles:0};
|
||||
document.querySelector=(...args)=>{window.layoutReads.queries++;return query(...args)};
|
||||
document.querySelectorAll=(...args)=>{window.layoutReads.queries++;return queryAll(...args)};
|
||||
window.getComputedStyle=(...args)=>{window.layoutReads.styles++;return style(...args)};
|
||||
window.restoreLayoutReads=()=>{document.querySelector=query;document.querySelectorAll=queryAll;window.getComputedStyle=style;};
|
||||
})()`);
|
||||
await wait(250);
|
||||
const reads=await run('window.restoreLayoutReads();window.layoutReads');
|
||||
assert.deepEqual(reads,{queries:0,styles:0},'idle browser leaves no repeated overlay/style scans');
|
||||
};
|
||||
await assertIdleLayout();
|
||||
|
||||
// Position-only CSS motion does not fire ResizeObserver on the viewport.
|
||||
// Follow its real animation frames, then release the frame loop again.
|
||||
await run(`(() => {const parent=document.querySelector('[data-section=host-browser-root]').parentElement;parent.id='moving-browser-fixture';parent.style.transition='transform 300ms linear';parent.style.transform='translateX(32px)';})()`);
|
||||
const positions=[];
|
||||
for(let index=0;index<15;index++){await wait(25);positions.push(root.getBounds().x);}
|
||||
assert.ok(new Set(positions).size>=4,'the native browser follows intermediate transform positions');
|
||||
const expectedX=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().x)');
|
||||
assert.equal(root.getBounds().x,expectedX,'native browser reaches the final CSS position');
|
||||
await assertIdleLayout();
|
||||
await run('document.getElementById("moving-browser-fixture").style.transform="none"');await wait(350);
|
||||
|
||||
// Exercise continuous pointer-driven size updates through the same hook and
|
||||
// native view. No application session or real SSH host is involved.
|
||||
await run(`(() => {
|
||||
const handle=document.createElement('div');handle.id='browser-drag-fixture';handle.style.cssText='position:fixed;left:2px;top:100px;width:8px;height:200px;z-index:99999';
|
||||
const parent=document.getElementById('moving-browser-fixture');
|
||||
handle.onmousedown=()=>{const move=event=>{parent.style.width=Math.max(300,event.clientX-parent.getBoundingClientRect().x)+'px'};const up=()=>{window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up)};window.addEventListener('mousemove',move);window.addEventListener('mouseup',up)};
|
||||
document.body.append(handle);
|
||||
})()`);
|
||||
owner.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:6,y:150});
|
||||
for(const x of [850,750,650,550]){owner.webContents.sendInputEvent({type:'mouseMove',x,y:150});await wait(30);}
|
||||
owner.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:550,y:150});await wait(80);
|
||||
const expectedWidth=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().width)');
|
||||
assert.equal(root.getBounds().width,expectedWidth,'drag-driven viewport width reaches the native browser');
|
||||
assert.ok(expectedWidth<600,'the pointer drag actually resized the fixture');
|
||||
await run('document.getElementById("moving-browser-fixture").style.width="";document.getElementById("browser-drag-fixture").remove()');await wait(100);
|
||||
|
||||
await run('document.documentElement.style.colorScheme="light";document.documentElement.style.setProperty("--background","40 70% 90%")');
|
||||
await until(()=>manager.getPanelState(event,browserId).theme,'light','theme reaches native browser without a frame poll');
|
||||
await run('document.documentElement.style.colorScheme="dark";document.documentElement.style.setProperty("--background","210 25% 18%")');
|
||||
await until(()=>manager.getPanelState(event,browserId).theme,'dark','theme restores');
|
||||
await run(`document.body.insertAdjacentHTML('beforeend','<div id="modal-layout-fixture" role="dialog" aria-modal="true" style="position:fixed;left:0;top:0;width:1px;height:1px"></div>')`);
|
||||
await until(()=>owner.contentView.children.includes(root),false,'modal beside the viewport still covers native content');
|
||||
await run('document.getElementById("modal-layout-fixture").remove()');
|
||||
await until(()=>owner.contentView.children.includes(root),true,'closing modal restores native content');
|
||||
await run(`document.body.insertAdjacentHTML('beforeend','<div id="lock-layout-fixture" data-app-lock-overlay></div>')`);
|
||||
await until(()=>owner.contentView.children.includes(root),false,'app lock hides native content');
|
||||
await run('document.getElementById("lock-layout-fixture").remove()');
|
||||
await until(()=>owner.contentView.children.includes(root),true,'unlock restores native content');
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"),'Unsaved browser input');
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
app.focus({ steal: true });
|
||||
owner.focus(); owner.webContents.focus();
|
||||
@@ -110,7 +182,7 @@ async function main() {
|
||||
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
|
||||
await click(`#choice-${index}-0`);
|
||||
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
|
||||
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
|
||||
@@ -204,7 +276,7 @@ async function main() {
|
||||
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
|
||||
await click('[aria-label="下载"]');
|
||||
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
|
||||
const activePage = root.children[0].webContents;
|
||||
await activePage.executeJavaScript("document.getElementById('download').click()");
|
||||
@@ -218,15 +290,15 @@ async function main() {
|
||||
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
|
||||
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
|
||||
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
await click('[title="在文件夹中显示"]');
|
||||
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
|
||||
await click('[aria-label="打开下载文件夹"]');
|
||||
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
await activePage.executeJavaScript("document.getElementById('download').click()");
|
||||
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
activePage.downloadURL(`${url}slow.bin`);
|
||||
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
|
||||
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
|
||||
@@ -237,7 +309,7 @@ async function main() {
|
||||
await click('[aria-label="下载"]');
|
||||
await click('[aria-label="取消下载"]');
|
||||
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
|
||||
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
|
||||
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
|
||||
@@ -246,7 +318,7 @@ async function main() {
|
||||
await capture(pdfProgressScreenshot);
|
||||
await click('[aria-label="取消下载"]');
|
||||
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await pressEscape();
|
||||
pendingPdf = undefined;
|
||||
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
|
||||
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
|
||||
@@ -258,11 +330,19 @@ async function main() {
|
||||
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
|
||||
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
|
||||
await capture(downloadScreenshot);
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
// Close the list through its own trigger before testing independent
|
||||
// navigation focus. A prior Escape may leave the baseline list open.
|
||||
await click('[aria-label="下载"]');
|
||||
await until(() => run('!!document.querySelector("[data-radix-popper-content-wrapper] > [role=dialog]")'), false, 'download list is closed before starting navigation');
|
||||
await input('[aria-label="输入网址"]', `${url}slow-page`);
|
||||
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
|
||||
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
|
||||
await click('[aria-label="新标签页"]');
|
||||
assert.equal(activePage.isLoading(), true, 'the response remains unfinished while switching tabs');
|
||||
// Exercise the native page shortcut while its response remains unfinished.
|
||||
// The new-tab button is covered above with the same real mouse input helper.
|
||||
activePage.focus();
|
||||
activePage.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
activePage.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
|
||||
await input('[aria-label="输入网址"]', 'keep my address draft');
|
||||
pendingNavigation.end();
|
||||
@@ -289,7 +369,7 @@ async function main() {
|
||||
const screenshot = path.join(userData, 'unified-browser.png');
|
||||
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
|
||||
assert.deepEqual(errors, [], 'no renderer errors');
|
||||
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
|
||||
console.log(JSON.stringify({ ok: true, rendererChrome: true, idleLayout: true, animatedLayout: true, dragLayout: true, themeLayout: true, modalAndLockLayout: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
|
||||
}
|
||||
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
|
||||
clearTimeout(watchdog);
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface CattyStreamChunk {
|
||||
stepNumber?: number;
|
||||
}
|
||||
|
||||
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
|
||||
const STEP_HANDLE_NOTICE_RE = /^(?:\[step \d+\] Tool output handles available:|\[Tool output handles available:)/;
|
||||
|
||||
function isStepHandleNoticeMessage(content: unknown): boolean {
|
||||
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { ToolResultDedup } from './toolResultDedup';
|
||||
import { SessionStateStore } from './sessionState';
|
||||
import type { CattyTurnInput, TurnDriverContext } from './turnDrivers/types';
|
||||
import type { AgentEvent } from './types';
|
||||
import type { AISession, ChatMessage, ProviderConfig } from '../types';
|
||||
|
||||
interface WireMessage {
|
||||
role: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
interface WireRequest {
|
||||
messages: WireMessage[];
|
||||
tools?: unknown[];
|
||||
system?: Array<{ type: string; text: string; cache_control?: unknown }>;
|
||||
}
|
||||
|
||||
type Reply = { tool: 'terminal_execute' | 'sftp_read_file' } | { text: string };
|
||||
|
||||
function fixture(t: test.TestContext, replies: Reply[], style: 'openai' | 'anthropic' = 'openai', terminalOutput = 'small output') {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancelRaf = globalThis.cancelAnimationFrame;
|
||||
t.after(() => {
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCancelRaf;
|
||||
});
|
||||
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = id => clearTimeout(id);
|
||||
|
||||
const requests: WireRequest[] = [];
|
||||
const urls: string[] = [];
|
||||
const events: AgentEvent[] = [];
|
||||
const messages: ChatMessage[] = [];
|
||||
const data = new Map<string, (value: string) => void>();
|
||||
const end = new Map<string, () => void>();
|
||||
const bridge = {
|
||||
aiSetChatSessionCancelled: async () => ({ ok: true }),
|
||||
aiMcpUpdateSessions: async () => undefined,
|
||||
aiExec: async () => ({ ok: true, stdout: terminalOutput, stderr: '', exitCode: 0 }),
|
||||
aiCapability: async (_method: string, params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
path: params.path,
|
||||
content: Array.from({ length: 2000 }, (_, i) => `log ${i}: synthetic ${String(params.path)}`).join('\n'),
|
||||
}),
|
||||
onAiStreamData: (id: string, callback: (value: string) => void) => {
|
||||
data.set(id, callback);
|
||||
return () => { data.delete(id); };
|
||||
},
|
||||
onAiStreamEnd: (id: string, callback: () => void) => {
|
||||
end.set(id, callback);
|
||||
return () => { end.delete(id); };
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatCancel: async () => true,
|
||||
aiChatStream: async (id: string, url: string, _headers: Record<string, string>, body: string) => {
|
||||
urls.push(url);
|
||||
requests.push(JSON.parse(body) as WireRequest);
|
||||
const reply = replies[requests.length - 1];
|
||||
assert.ok(reply, 'unexpected model request');
|
||||
const send = (chunk: unknown) => data.get(id)?.(JSON.stringify(chunk));
|
||||
if (style === 'anthropic') {
|
||||
send({ type: 'message_start', message: {
|
||||
id: `response-${requests.length}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 100, output_tokens: 0, cache_read_input_tokens: 900, cache_creation_input_tokens: 0 },
|
||||
} });
|
||||
send({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
||||
send({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'OK' } });
|
||||
send({ type: 'content_block_stop', index: 0 });
|
||||
send({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
||||
send({ type: 'message_stop' });
|
||||
} else {
|
||||
const toolReply = 'tool' in reply;
|
||||
const delta = toolReply ? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: {
|
||||
name: reply.tool,
|
||||
arguments: JSON.stringify(reply.tool === 'terminal_execute'
|
||||
? { sessionId: 'terminal-1', command: `printf audit-${requests.length}` }
|
||||
: { sessionId: 'terminal-1', path: `/fixture/log-${requests.length}` }),
|
||||
} }],
|
||||
} : { role: 'assistant', content: reply.text };
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
|
||||
choices: [{ index: 0, delta, finish_reason: null }] });
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: toolReply ? 'tool_calls' : 'stop' }] });
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [],
|
||||
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
|
||||
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 } });
|
||||
data.get(id)?.('[DONE]');
|
||||
}
|
||||
end.get(id)?.();
|
||||
return { ok: true, statusCode: 200 };
|
||||
},
|
||||
};
|
||||
globalThis.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
const state = new SessionStateStore();
|
||||
const provider: ProviderConfig = {
|
||||
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true, style,
|
||||
defaultModel: style === 'anthropic' ? 'claude-sonnet-4-5' : 'test-model',
|
||||
baseURL: style === 'anthropic' ? 'https://api.anthropic.com' : 'https://provider.example.test/v1', apiKey: 'not-a-real-key',
|
||||
};
|
||||
let turnNumber = 0;
|
||||
return {
|
||||
requests, urls, events, messages, provider,
|
||||
async turn(maxIterations = 1) {
|
||||
turnNumber += 1;
|
||||
const history = structuredClone(messages);
|
||||
const userText = `Audit round ${turnNumber}`;
|
||||
const assistantId = `assistant-${turnNumber}`;
|
||||
messages.push({ id: `user-${turnNumber}`, role: 'user', content: userText, timestamp: turnNumber },
|
||||
{ id: assistantId, role: 'assistant', content: '', timestamp: turnNumber });
|
||||
const currentSession: AISession = {
|
||||
id: 'chat-1', title: 'Fixture', agentId: 'catty', scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
createdAt: 0, updatedAt: turnNumber, messages: history,
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
const input: CattyTurnInput = {
|
||||
backend: 'catty', chatSessionId: 'chat-1', sendScopeKey: 'chat-1', userText, signal,
|
||||
currentSession, assistantMsgId: assistantId, maxIterations, bridge,
|
||||
context: {
|
||||
activeProvider: provider, activeModelId: provider.defaultModel!, scopeType: 'terminal', scopeLabel: 'Fixture',
|
||||
globalPermissionMode: 'auto', commandBlocklist: [], autoTitleSession: () => undefined,
|
||||
terminalSessions: [{ sessionId: 'terminal-1', hostname: 'fixture.example.test', label: 'Fixture', connected: true }],
|
||||
},
|
||||
ui: {
|
||||
addMessageToSession: (_session, message) => { messages.push(message); },
|
||||
updateMessageById: (_session, id, update) => {
|
||||
const index = messages.findIndex(message => message.id === id);
|
||||
if (index >= 0) messages[index] = update(messages[index]);
|
||||
},
|
||||
updateLastMessage: () => undefined,
|
||||
reportStreamError: (_session, _signal, error) => { throw error; },
|
||||
setStreamingForScope: () => undefined,
|
||||
},
|
||||
};
|
||||
const context: TurnDriverContext = {
|
||||
turnId: `turn-${turnNumber}`, chatSessionId: 'chat-1', sessionId: 'chat-1', backend: 'catty', signal,
|
||||
emit: event => { events.push({
|
||||
...event, sessionId: 'chat-1', chatSessionId: 'chat-1', backend: 'catty', timestamp: Date.now(),
|
||||
} as AgentEvent); }, toolOutputStore: store, toolResultDedup: dedup, sessionStateStore: state,
|
||||
};
|
||||
dedup.beginTurn();
|
||||
await new CattyTurnDriver().run(input, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertRequestPrefix(previous: WireRequest, next: WireRequest) {
|
||||
assert.deepEqual(next.messages.slice(0, previous.messages.length), previous.messages,
|
||||
'already-sent conversation must remain an exact prefix');
|
||||
assert.deepEqual(next.tools, previous.tools);
|
||||
}
|
||||
|
||||
test('Catty preserves the request prefix across six ordinary turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => ({ text: 'OK' })));
|
||||
for (let i = 0; i < 6; i += 1) await f.turn();
|
||||
assert.equal(f.requests.length, 6);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty replays completed small commands without rewriting their results', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'terminal_execute' as const }, { text: 'OK' },
|
||||
]).flat());
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 1800, 'usage includes both model steps');
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/, 'OpenAI-compatible requests use implicit caching');
|
||||
});
|
||||
|
||||
test('Catty keeps large-output handles without replacing the conversation prefix', async t => {
|
||||
const f = fixture(t, [{ tool: 'sftp_read_file' }, { tool: 'sftp_read_file' }, { text: 'OK' }]);
|
||||
await f.turn(3);
|
||||
assert.equal(f.requests.length, 3);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
assert.match(JSON.stringify(f.requests.at(-1)), /tool output handle:/);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests.at(-1)), /Tool output handles available/);
|
||||
});
|
||||
|
||||
test('Catty preserves large-output history across consecutive user turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'sftp_read_file' as const }, { text: 'OK' },
|
||||
]).flat());
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty preserves large terminal results across consecutive user turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'terminal_execute' as const }, { text: 'OK' },
|
||||
]).flat(), 'openai', Array.from({ length: 14000 }, (_, i) => `synthetic log record ${i}`).join('\n'));
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty keeps DeepSeek cache reads in trace and persisted assistant usage', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }]);
|
||||
await f.turn();
|
||||
const event = f.events.find(event => event.type === 'usage');
|
||||
assert.ok(event?.type === 'usage');
|
||||
assert.equal(event.cachedPromptTokens, 900);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
|
||||
});
|
||||
|
||||
test('Catty marks a stable Claude system block and the conversation tail for caching', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
await f.turn();
|
||||
const system = f.requests[0].system!;
|
||||
assert.ok(system.length >= 2, 'static and dynamic system context need separate boundaries');
|
||||
assert.deepEqual(system[0].cache_control, { type: 'ephemeral' });
|
||||
assert.doesNotMatch(system[0].text, /## Current Scope/);
|
||||
assert.match(system[1].text, /## Current Scope/);
|
||||
assert.match(JSON.stringify(f.requests[0].messages.at(-1)), /cache_control/);
|
||||
const event = f.events.find(event => event.type === 'usage');
|
||||
assert.ok(event?.type === 'usage');
|
||||
assert.equal(event.cachedPromptTokens, 900);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
|
||||
});
|
||||
|
||||
test('Catty does not send vendor cache extensions to an Anthropic-compatible gateway', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
f.provider.baseURL = 'https://gateway.example.test/anthropic';
|
||||
await f.turn();
|
||||
assert.equal(f.requests.length, 1);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
|
||||
assert.equal(f.messages.at(-1)?.content, 'OK');
|
||||
});
|
||||
|
||||
for (const providerId of ['openrouter', 'ollama'] as const) {
|
||||
test(`Catty respects the ${providerId} endpoint fallback with Anthropic format`, async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
f.provider.providerId = providerId;
|
||||
f.provider.baseURL = '';
|
||||
await f.turn();
|
||||
assert.match(f.urls[0], providerId === 'openrouter' ? /^https:\/\/openrouter\.ai\// : /^http:\/\/localhost:11434\//);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
|
||||
assert.equal(f.messages.at(-1)?.content, 'OK');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ModelMessage, SystemModelMessage } from 'ai';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
const CACHE_CONTROL = { type: 'ephemeral' } as const;
|
||||
|
||||
/** Native Anthropic needs explicit breakpoints; implicit-cache protocols stay untouched. */
|
||||
export function createCattyPromptCache(
|
||||
systemPrompt: string,
|
||||
provider: string,
|
||||
endpoint?: Pick<ProviderConfig, 'baseURL'>,
|
||||
) {
|
||||
let officialEndpoint = false;
|
||||
if (endpoint) {
|
||||
try {
|
||||
// This must be the resolved SDK address, including vendor fallbacks.
|
||||
// Only an unresolved URL after that step uses the SDK's native default.
|
||||
officialEndpoint = new URL(endpoint.baseURL || 'https://api.anthropic.com').origin === 'https://api.anthropic.com';
|
||||
} catch { /* An unknown endpoint must not receive vendor extensions. */ }
|
||||
}
|
||||
const enabled = /^anthropic(?:\.|$)/.test(provider) && officialEndpoint;
|
||||
const boundary = systemPrompt.indexOf('\n\n## Current Scope');
|
||||
const instructions: string | SystemModelMessage[] = enabled
|
||||
? [{
|
||||
role: 'system',
|
||||
content: boundary < 0 ? systemPrompt : systemPrompt.slice(0, boundary),
|
||||
providerOptions: { anthropic: { cacheControl: CACHE_CONTROL } },
|
||||
}, ...(boundary < 0 ? [] : [{
|
||||
role: 'system' as const,
|
||||
content: systemPrompt.slice(boundary),
|
||||
}])]
|
||||
: systemPrompt;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
instructions,
|
||||
prepareMessages(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (!enabled) return messages;
|
||||
// Keep at most two moving conversation breakpoints, plus the static
|
||||
// system boundary. Remove our prior message-level markers as v7 carries
|
||||
// prepared messages forward; never alter signed reasoning content.
|
||||
const targets = new Set<number>();
|
||||
for (let index = messages.length - 1; index >= 0 && targets.size < 2; index -= 1) {
|
||||
if (messages[index].role === 'user' || messages[index].role === 'tool') targets.add(index);
|
||||
}
|
||||
return messages.map((message, index) => {
|
||||
const existing = message.providerOptions?.anthropic;
|
||||
if (!targets.has(index) && !existing?.cacheControl && !existing?.cache_control) return message;
|
||||
const anthropic = { ...existing };
|
||||
delete anthropic.cacheControl;
|
||||
delete anthropic.cache_control;
|
||||
if (targets.has(index)) anthropic.cacheControl = CACHE_CONTROL;
|
||||
return { ...message, providerOptions: { ...message.providerOptions, anthropic } };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { createCattyPromptCache } from './cattyPromptCache';
|
||||
|
||||
test('leaves implicit-cache protocols unchanged', () => {
|
||||
for (const provider of ['openai.chat', 'openai.responses', 'google.generative-ai']) {
|
||||
const cache = createCattyPromptCache('instructions', provider);
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
assert.equal(cache.instructions, 'instructions');
|
||||
assert.equal(cache.prepareMessages(messages), messages);
|
||||
}
|
||||
});
|
||||
|
||||
test('Claude markers stay bounded across tool steps without changing signed reasoning', () => {
|
||||
const cache = createCattyPromptCache('static\n\n## Current Scope\nvolatile', 'anthropic.messages', { baseURL: 'https://api.anthropic.com' });
|
||||
const reasoning = { type: 'reasoning' as const, text: 'thinking',
|
||||
providerOptions: { anthropic: { signature: 'signed-value' } } };
|
||||
let messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
messages = cache.prepareMessages(messages);
|
||||
const marked = messages.filter(message => message.providerOptions?.anthropic?.cacheControl);
|
||||
assert.ok(marked.length <= 2);
|
||||
messages.push({ role: 'assistant', content: [reasoning, { type: 'text', text: 'result' }] });
|
||||
messages.push({ role: 'user', content: `next-${step}` });
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant') assert.equal(message.content[0], reasoning);
|
||||
}
|
||||
assert.ok(Array.isArray(cache.instructions));
|
||||
assert.deepEqual(cache.instructions[0].providerOptions?.anthropic?.cacheControl, { type: 'ephemeral' });
|
||||
});
|
||||
|
||||
test('only the official HTTPS Anthropic endpoint enables vendor cache markers', () => {
|
||||
for (const baseURL of ['https://proxy.example.test/v1', 'http://api.anthropic.com', 'https://api.anthropic.com.example.test', 'invalid']) {
|
||||
const cache = createCattyPromptCache('instructions', 'anthropic.messages', { baseURL });
|
||||
assert.equal(cache.enabled, false);
|
||||
}
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages').enabled, false);
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', {}).enabled, true);
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', { baseURL: 'https://api.anthropic.com/v1' }).enabled, true);
|
||||
});
|
||||
@@ -78,7 +78,7 @@ test('prepareTurnContext applies typed compression before LLM summarize threshol
|
||||
assert.ok(prepared.messages.length >= messages.length);
|
||||
const serialized = JSON.stringify(prepared.messages);
|
||||
assert.match(serialized, /Fix only the upstream timeout issue/);
|
||||
assert.match(serialized, /Permission mode: confirm/);
|
||||
assert.doesNotMatch(serialized, /Netcatty session context/);
|
||||
assert.ok(serialized.length < JSON.stringify(messages).length);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ test('TraceStore bounds compaction history independently from events', () => {
|
||||
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
|
||||
});
|
||||
|
||||
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
|
||||
test('prepareStepContext preserves prior notices and appends only newly available handles', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.store({
|
||||
chatSessionId: 'chat-4',
|
||||
@@ -194,9 +194,22 @@ test('prepareStepContext replaces prior step handle notices under v7 carry-forwa
|
||||
&& typeof message.content === 'string'
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
|
||||
assert.equal(notices.length, 2);
|
||||
assert.deepEqual(prepared.messages.slice(0, 2), [priorNotice, { role: 'user', content: 'continue' }]);
|
||||
assert.equal(notices[0], priorNotice);
|
||||
assert.match(String(notices[1]?.content), /^\[Tool output handles available:/);
|
||||
assert.doesNotMatch(String(notices[1]?.content), /tool-output-old|\[step/);
|
||||
assert.equal(prepared.messages.at(-1), notices[1]);
|
||||
|
||||
const next = await prepareStepContext({
|
||||
messages: prepared.messages,
|
||||
stepNumber: 3,
|
||||
sessionId: 'chat-4',
|
||||
chatSessionId: 'chat-4',
|
||||
toolOutputStore: store,
|
||||
runtimeContext,
|
||||
});
|
||||
assert.deepEqual(next.messages, prepared.messages, 'unchanged handles need no per-step rewrite');
|
||||
});
|
||||
|
||||
test('prepareStepContext emits step compaction trace when over budget', async () => {
|
||||
@@ -266,7 +279,8 @@ test('prepareStepContext retains handle notice after step budget guard', async (
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
assert.match(String(notices[0]?.content), /^\[Tool output handles available:/);
|
||||
assert.equal(prepared.messages.at(-1), notices[0]);
|
||||
});
|
||||
|
||||
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { ChatMessage, AIPermissionMode } from '../types';
|
||||
import { isStepHandleNoticeMessage } from './agentEventAdapter';
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
@@ -265,7 +264,9 @@ export async function prepareTurnContext(
|
||||
}
|
||||
|
||||
const reinjection = buildReinjectionMessages(input.reinjection);
|
||||
if (reinjection.length > 0 && didAdjust) {
|
||||
// Routine output cleanup does not discard the conversation or its goals.
|
||||
// Reinserting the latest goal at the front would invalidate every turn.
|
||||
if (reinjection.length > 0 && didAdjust && (underBudgetPressure || didLlmSummarize)) {
|
||||
const reinjectionTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: reinjection,
|
||||
providerId: input.providerId,
|
||||
@@ -414,22 +415,6 @@ export async function prepareStepContext(
|
||||
const typed = compressMessagesForRequestTooLargeRetry(working);
|
||||
working = typed.messages;
|
||||
|
||||
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
|
||||
let didHandleNotice = false;
|
||||
if (pendingHandles.length > 0 && input.stepNumber > 0) {
|
||||
working = working.filter((message) => {
|
||||
if (message.role !== 'user') return true;
|
||||
const content = typeof message.content === 'string' ? message.content : '';
|
||||
return !isStepHandleNoticeMessage(content);
|
||||
});
|
||||
const notice: ModelMessage = {
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
};
|
||||
working = [notice, ...working];
|
||||
didHandleNotice = true;
|
||||
}
|
||||
|
||||
const budgetGuard = applyStepBudgetGuard(working, {
|
||||
contextWindow,
|
||||
reservedTokens: input.reservedTokens ?? 0,
|
||||
@@ -438,36 +423,42 @@ export async function prepareStepContext(
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = budgetGuard.messages;
|
||||
if (didHandleNotice && pendingHandles.length > 0) {
|
||||
const hasHandleNotice = working.some(
|
||||
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
if (!hasHandleNotice) {
|
||||
working = [{
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
}, ...working];
|
||||
}
|
||||
}
|
||||
if (didHandleNotice) {
|
||||
const notices = working.filter(
|
||||
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
const body = working.filter(
|
||||
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
|
||||
);
|
||||
const pendingHandles = input.stepNumber > 0
|
||||
? input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? []
|
||||
: [];
|
||||
const unannouncedHandles = () => {
|
||||
// Bounded tool results already carry their read handles. Announce only
|
||||
// handles whose result was pruned, so normal history needs no transient
|
||||
// user message that would disappear when the next turn is rebuilt.
|
||||
const visibleContent = working.map(message => typeof message.content === 'string'
|
||||
? message.content : JSON.stringify(message.content));
|
||||
return pendingHandles.filter(handle => !visibleContent.some(content => content.includes(handle.id)));
|
||||
};
|
||||
let didHandleNotice = false;
|
||||
if (unannouncedHandles().length > 0) {
|
||||
// Reserve the complete list: the guard may remove an older announcement.
|
||||
// New notices belong at the tail; replacing a step-numbered prefix would
|
||||
// invalidate the entire cached conversation on every model call.
|
||||
const noticeTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: notices,
|
||||
messages: [{ role: 'user', content: `[Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}]` }],
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
const finalGuard = applyStepBudgetGuard(body, {
|
||||
const finalGuard = applyStepBudgetGuard(working, {
|
||||
contextWindow,
|
||||
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = [...notices, ...finalGuard.messages];
|
||||
working = finalGuard.messages;
|
||||
const handles = unannouncedHandles();
|
||||
if (handles.length > 0) {
|
||||
working = [...working, {
|
||||
role: 'user',
|
||||
content: `[Tool output handles available: ${handles.map(h => h.id).join(', ')}]`,
|
||||
}];
|
||||
didHandleNotice = true;
|
||||
}
|
||||
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
|
||||
}
|
||||
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type AssistantContentPart,
|
||||
type CattyProviderContinuationContext,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { fitLargeUserInputForModel } from '../largeUserInput';
|
||||
import type { ToolOutputStore } from '../toolOutputStore';
|
||||
|
||||
@@ -198,7 +198,6 @@ export interface BuildCattySdkMessagesInput {
|
||||
trimmed: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
continuationContext: CattyProviderContinuationContext;
|
||||
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
|
||||
chatSessionId: string;
|
||||
toolOutputStore: ToolOutputStore;
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
|
||||
@@ -212,7 +211,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
trimmed,
|
||||
attachments,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults = new Set<ToolResult>(),
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
fieldsByMessage,
|
||||
@@ -394,9 +392,12 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
toolName: toolCall?.name ?? 'unknown',
|
||||
output: {
|
||||
type: 'text' as const,
|
||||
value: buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
|
||||
}),
|
||||
// Live results are already bounded. Reformatting them merely
|
||||
// because another user turn arrived breaks the cached prefix.
|
||||
// Context-budget pruning still owns necessary reductions.
|
||||
value: redactSecretsForModel(buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
} from '../../cattyRequestTooLargeRetry';
|
||||
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
|
||||
import type { AgentEvent } from '../types';
|
||||
import type { ProviderAdvancedParams } from '../../types';
|
||||
import { resolveProviderStyle, type ProviderAdvancedParams } from '../../types';
|
||||
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
|
||||
import { createModelFromConfig } from '../../sdk/providers';
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from '../../sdk/providers';
|
||||
import type { CattyToolsBundle } from '../capabilityTools';
|
||||
import { buildCattyToolApproval } from '../cattyToolApproval';
|
||||
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
|
||||
import { buildCattyStreamTimeouts } from '../streamTimeouts';
|
||||
import { createCattyPromptCache } from '../cattyPromptCache';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
mergeProviderContinuation,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
type ToolOutputDeniedChunk,
|
||||
type ToolResultChunk,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import type { ChatMessage } from '../../types';
|
||||
import type { ChatMessage, ProviderConfig } from '../../types';
|
||||
|
||||
export type CattyModel = ReturnType<typeof createModelFromConfig>;
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface CattyStreamUiSink {
|
||||
export interface ProcessCattyStreamInput {
|
||||
streamSessionId: string;
|
||||
model: CattyModel;
|
||||
providerConfig?: ProviderConfig;
|
||||
systemPrompt: string;
|
||||
toolsBundle: CattyToolsBundle;
|
||||
sdkMessages: ModelMessage[];
|
||||
@@ -76,6 +78,7 @@ export interface ProcessCattyStreamInput {
|
||||
export interface ProcessCattyStreamResult {
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
cachedPromptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
@@ -129,11 +132,15 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
|
||||
let runtimeContext = initialRuntimeContext;
|
||||
const { tools, toolsContext } = toolsBundle;
|
||||
const endpoint = input.providerConfig
|
||||
? resolveProviderEndpoint(input.providerConfig, resolveProviderStyle(input.providerConfig), undefined)
|
||||
: undefined;
|
||||
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
instructions: systemPrompt,
|
||||
messages: promptCache.prepareMessages(sdkMessages),
|
||||
instructions: promptCache.instructions,
|
||||
tools,
|
||||
toolsContext,
|
||||
runtimeContext,
|
||||
@@ -186,6 +193,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
modelId: step.model.modelId,
|
||||
finishReason: step.finishReason,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} as AgentEvent);
|
||||
@@ -201,15 +209,17 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
estimated: false,
|
||||
} as AgentEvent);
|
||||
}
|
||||
},
|
||||
...(prepareStep ? {
|
||||
...(prepareStep || promptCache.enabled ? {
|
||||
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
|
||||
const prepared = await prepareStep({
|
||||
const prepared = await prepareStep?.({
|
||||
stepNumber,
|
||||
messages,
|
||||
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
|
||||
@@ -217,7 +227,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
if (prepared?.runtimeContext) {
|
||||
runtimeContext = prepared.runtimeContext;
|
||||
}
|
||||
return prepared ?? { messages };
|
||||
return { ...prepared, messages: promptCache.prepareMessages(prepared?.messages ?? messages) };
|
||||
},
|
||||
} : {}),
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
@@ -552,6 +562,21 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
const usage = await result.usage;
|
||||
const finalStep = await result.finalStep;
|
||||
const performance = finalStep?.performance;
|
||||
// onEnd can run before this consumer flushes its final text and creates the
|
||||
// post-tool assistant message. Attach turn totals only after that flush.
|
||||
if (usage) {
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, message => ({
|
||||
...message,
|
||||
usage: {
|
||||
inputTokens: usage.inputTokens ?? 0,
|
||||
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
estimated: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (performance) {
|
||||
onAgentEvent?.({
|
||||
@@ -571,6 +596,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
return {
|
||||
usage: usage ? {
|
||||
promptTokens: usage.inputTokens,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} : undefined,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
buildCattySdkMessages,
|
||||
collectOpenAIChatAssistantFieldsForMessages,
|
||||
collectPreservedTerminalWriteFingerprints,
|
||||
collectToolResultsAfterMessage,
|
||||
createContinuationContext,
|
||||
} from './cattyMessageBuilder';
|
||||
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
|
||||
@@ -218,7 +217,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
const buildSdkMessages = (
|
||||
allMessages: import('../../types').ChatMessage[],
|
||||
includeCurrentUserMessage: boolean,
|
||||
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
|
||||
sessionContextCompaction = currentSession?.contextCompaction,
|
||||
) => buildCattySdkMessages({
|
||||
allMessages,
|
||||
@@ -227,7 +225,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
trimmed: modelUserText,
|
||||
attachments: includeCurrentUserMessage ? attachments : undefined,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults: options.preserveTerminalToolResults,
|
||||
chatSessionId: sessionId,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
fieldsByMessage: openAIChatAssistantFieldsByMessage,
|
||||
@@ -399,7 +396,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
const headSdkMessages = buildSdkMessages(
|
||||
uiMessages.slice(0, compactedMessageCount),
|
||||
false,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
const compacted = await compactMessages(headSdkMessages, {
|
||||
@@ -414,7 +410,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
};
|
||||
ui.persistContextCompaction?.(sessionId, nextCompaction);
|
||||
emitContextSnapshot(prepareMessagesForStream(
|
||||
buildSdkMessages(uiMessages, false, {}, nextCompaction),
|
||||
buildSdkMessages(uiMessages, false, nextCompaction),
|
||||
));
|
||||
} else {
|
||||
// Non-durable outcome: keep the meter honest (full current context).
|
||||
@@ -452,6 +448,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
await processCattyStream({
|
||||
streamSessionId: sessionId,
|
||||
model,
|
||||
providerConfig: context.activeProvider,
|
||||
systemPrompt,
|
||||
toolsBundle,
|
||||
sdkMessages: streamMessages,
|
||||
@@ -514,12 +511,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
|
||||
preserveTerminalToolResults: collectToolResultsAfterMessage(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
),
|
||||
}, latestSession.contextCompaction);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
|
||||
@@ -180,6 +180,7 @@ export interface StepEndEvent extends AgentEventBase {
|
||||
modelId?: string;
|
||||
finishReason?: string;
|
||||
promptTokens: number;
|
||||
cachedPromptTokens?: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeOpenAIChatCacheUsage } from './openAIChatCacheUsage';
|
||||
|
||||
test('adapts documented DeepSeek cache usage while preserving the rest of the response', () => {
|
||||
const response = { choices: [], usage: {
|
||||
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100,
|
||||
prompt_tokens_details: { other: 1 }, completion_tokens: 5,
|
||||
} };
|
||||
const parsed = JSON.parse(normalizeOpenAIChatCacheUsage(JSON.stringify(response)));
|
||||
assert.deepEqual(parsed, {
|
||||
...response, usage: { ...response.usage, prompt_tokens_details: { other: 1, cached_tokens: 900 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves standard usage and rejects invalid cache counts without breaking stream data', () => {
|
||||
for (const hit of [-1, 1.5, '900', 1001, null]) {
|
||||
const data = JSON.stringify({ usage: { prompt_tokens: 1000, prompt_cache_hit_tokens: hit } });
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
}
|
||||
for (const data of ['[DONE]', 'not JSON', '{"usage":null}', '{"type":"message_start"}']) {
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
}
|
||||
const data = JSON.stringify({ usage: {
|
||||
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_tokens_details: { cached_tokens: 0 },
|
||||
} });
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Adapt DeepSeek's documented usage fields before the OpenAI SDK validates them. */
|
||||
export function normalizeOpenAIChatCacheUsage(data: string): string {
|
||||
if (!data.includes('prompt_cache_hit_tokens')) return data;
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
const usage = body?.usage;
|
||||
const hit = usage?.prompt_cache_hit_tokens;
|
||||
if (!Number.isSafeInteger(hit) || hit < 0) return data;
|
||||
if (Number.isFinite(usage.prompt_tokens) && hit > usage.prompt_tokens) return data;
|
||||
// Preserve a provider's standard field, including an explicit zero.
|
||||
if (usage.prompt_tokens_details?.cached_tokens != null) return data;
|
||||
usage.prompt_tokens_details = { ...usage.prompt_tokens_details, cached_tokens: hit };
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isStepCount, streamText, tool } from 'ai';
|
||||
import { generateText, isStepCount, streamText, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
|
||||
import type { OpenAIChatAssistantFields } from './providerContinuation';
|
||||
@@ -1305,3 +1305,25 @@ for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
|
||||
indexes.map(index => `call_${index}`).sort());
|
||||
});
|
||||
}
|
||||
|
||||
test('non-streaming DeepSeek usage reaches the SDK as cached input', async t => {
|
||||
const originalWindow = globalThis.window;
|
||||
t.after(() => { globalThis.window = originalWindow; });
|
||||
globalThis.window = { netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: JSON.stringify({
|
||||
id: 'response', object: 'chat.completion', created: 0, model: 'deepseek-chat',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
|
||||
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 },
|
||||
}) }),
|
||||
} } as unknown as Window & typeof globalThis;
|
||||
const result = await generateText({
|
||||
model: createModelFromConfig({
|
||||
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true,
|
||||
baseURL: 'https://provider.example.test/v1', apiKey: 'fixture', defaultModel: 'deepseek-chat',
|
||||
}),
|
||||
prompt: 'hello',
|
||||
});
|
||||
assert.equal(result.usage.inputTokenDetails.cacheReadTokens, 900);
|
||||
assert.equal(result.usage.inputTokenDetails.noCacheTokens, 100);
|
||||
});
|
||||
@@ -158,13 +158,19 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
|
||||
const output = record.output;
|
||||
if (output && typeof output === "object") {
|
||||
const outputRecord = output as Record<string, unknown>;
|
||||
if (outputRecord.type === "text" && typeof outputRecord.value === "string") {
|
||||
const value = compressAndTruncateText(outputRecord.value, RETRY_MAX_TOOL_RESULT_CHARS);
|
||||
if (value === outputRecord.value) return part;
|
||||
const text = outputRecord.type === "text" && typeof outputRecord.value === "string"
|
||||
? outputRecord.value
|
||||
: outputRecord.type === "json" ? JSON.stringify(outputRecord.value) : undefined;
|
||||
if (text !== undefined) {
|
||||
// Live SDK tool results are JSON, while persisted results replay as
|
||||
// text. Apply the same reduction before their first model request.
|
||||
const value = compressAndTruncateText(text, RETRY_MAX_TOOL_RESULT_CHARS);
|
||||
if (value === text) return part;
|
||||
return {
|
||||
...record,
|
||||
output: {
|
||||
...outputRecord,
|
||||
type: "text",
|
||||
value,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveOpenAIApi, resolveProviderStyle } from '../types';
|
||||
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
|
||||
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
|
||||
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
|
||||
import { normalizeOpenAIChatCacheUsage } from '../openAIChatCacheUsage';
|
||||
|
||||
export { normalizeOllamaSdkBaseURL };
|
||||
import {
|
||||
@@ -529,7 +530,7 @@ export function createBridgeFetchForSDK(
|
||||
};
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
const normalizedData = normalizeOpenAIChatToolCalls(data);
|
||||
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`));
|
||||
@@ -627,7 +628,7 @@ export function createBridgeFetchForSDK(
|
||||
// Non-streaming path
|
||||
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
|
||||
|
||||
return new Response(result.data, {
|
||||
return new Response(normalizeOpenAIChatCacheUsage(result.data), {
|
||||
status: result.status,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user