mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31700d6847 | ||
|
|
7d3cd14e0b | ||
|
|
a1d0397ab7 | ||
|
|
1252ec3835 | ||
|
|
5fbfdc749f | ||
|
|
b0e6cdbeb9 | ||
|
|
ed94b4173c | ||
|
|
7e50cc3218 | ||
|
|
ca37599be7 | ||
|
|
294a9aca69 | ||
|
|
252d2026e0 | ||
|
|
e8b7a032e9 | ||
|
|
eca4577dbd | ||
|
|
259c1970f0 | ||
|
|
660d8483f5 | ||
|
|
ee46f36088 | ||
|
|
249211d893 | ||
|
|
aedf99794c | ||
|
|
059586c840 | ||
|
|
05cffda7be |
No files matched your search
@@ -1,5 +1,3 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -47,11 +45,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useMonitorRuntime();
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
</AppLocalStateProvider>
|
||||
);
|
||||
|
||||
@@ -542,7 +542,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { enMonitorMessages } from './en/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
@@ -10,7 +9,6 @@ import { enScriptsMessages } from './en/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const en: Messages = {
|
||||
...enMonitorMessages,
|
||||
...enCoreMessages,
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
"monitor.password": "Password (this run only)",
|
||||
"monitor.status.limit":
|
||||
"Monitoring is limited to 256 unique hosts at a time.",
|
||||
"sync.entityType.monitorPanels": "monitor panels",
|
||||
"monitor.title": "Monitoring",
|
||||
"monitor.create": "New panel",
|
||||
"monitor.createFirst": "Create your first panel",
|
||||
"monitor.empty": "Your hosts, at a glance",
|
||||
"monitor.emptyHint":
|
||||
"Choose saved hosts from any group. Start monitoring when you are ready.",
|
||||
"monitor.choosePanel": "Choose a panel",
|
||||
"monitor.edit": "Edit panel",
|
||||
"monitor.copy": "Duplicate panel",
|
||||
"monitor.copyName": "{name} copy",
|
||||
"monitor.delete": "Delete panel",
|
||||
"monitor.deleteHint":
|
||||
"Delete “{name}”? Its monitoring will stop. Saved hosts and other panels are kept.",
|
||||
"monitor.start": "Start monitoring",
|
||||
"monitor.stop": "Stop",
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
"monitor.minutes": "Last {count} min",
|
||||
"monitor.sort": "Sort hosts",
|
||||
"monitor.sort.manual": "Saved order",
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Cores: {count}",
|
||||
"monitor.memory": "Memory",
|
||||
"monitor.diskCapacity": "{used} used / {total} total",
|
||||
"monitor.diskCapacityUnknown": "Capacity unavailable",
|
||||
"monitor.rootDisk": "Root partition",
|
||||
"monitor.network": "Network",
|
||||
"monitor.download": "Download",
|
||||
"monitor.upload": "Upload",
|
||||
"monitor.updated": "Updated",
|
||||
"monitor.updatedAt": "Updated {time}",
|
||||
"monitor.noSamples": "Waiting for the first reading",
|
||||
"monitor.cpuTrend": "CPU trend",
|
||||
"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",
|
||||
"monitor.selectionHint":
|
||||
"Choose across groups. Unsupported connections are shown with a clear status when started.",
|
||||
"monitor.defaultView": "Default view",
|
||||
"monitor.saveHint":
|
||||
"Saving keeps configuration only and does not connect. Copies start stopped too.",
|
||||
"monitor.nameRequired": "Enter a panel name.",
|
||||
"monitor.hostRequired": "Select at least one host.",
|
||||
"monitor.duplicateName": "A panel already uses this name.",
|
||||
"monitor.saveFailed": "Could not save the panel. Please try again.",
|
||||
"monitor.deleted":
|
||||
"This panel was deleted in another window. Close the editor and create a new panel.",
|
||||
"monitor.moveUp": "Move host up",
|
||||
"monitor.removeHost": "Remove from panel",
|
||||
"monitor.missingHost": "Deleted host",
|
||||
"monitor.observed": "Observed for {count} min this run",
|
||||
"monitor.disks": "Partitions",
|
||||
"monitor.system": "System information",
|
||||
"monitor.loginHint":
|
||||
"Check the host credentials or connection settings, then retry. Identity and sign-in prompts are handled one host at a time.",
|
||||
"monitor.retry": "Retry connection",
|
||||
"monitor.unsupportedHint":
|
||||
"Monitoring needs Linux or macOS over SSH with support for additional command channels.",
|
||||
"monitor.openTerminal": "Open terminal",
|
||||
"monitor.confirmHost": "Confirm host identity",
|
||||
"monitor.status.stopped": "Not started",
|
||||
"monitor.status.queued": "Queued",
|
||||
"monitor.status.connecting": "Connecting",
|
||||
"monitor.status.live": "Up to date",
|
||||
"monitor.status.login": "Sign-in needed",
|
||||
"monitor.status.error": "Collection failed",
|
||||
"monitor.status.unsupported": "Unavailable",
|
||||
"monitor.status.disconnected": "Disconnected",
|
||||
"monitor.status.stale": "Out of date",
|
||||
"monitor.status.missing": "Host deleted",
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { esMonitorMessages } from './es/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
@@ -10,7 +9,6 @@ import { esScriptsMessages } from './es/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esMonitorMessages,
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.actions": "Acciones del panel",
|
||||
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
|
||||
"monitor.username": "Usuario",
|
||||
"monitor.password": "Contraseña (solo esta ejecución)",
|
||||
"monitor.status.limit":
|
||||
"Se pueden monitorizar hasta 256 equipos únicos a la vez.",
|
||||
"sync.entityType.monitorPanels": "paneles de monitorización",
|
||||
"monitor.title": "Monitorización",
|
||||
"monitor.create": "Nuevo panel",
|
||||
"monitor.createFirst": "Crear el primer panel",
|
||||
"monitor.empty": "Tus equipos de un vistazo",
|
||||
"monitor.emptyHint":
|
||||
"Elige equipos guardados de cualquier grupo e inicia cuando quieras.",
|
||||
"monitor.choosePanel": "Elegir panel",
|
||||
"monitor.edit": "Editar panel",
|
||||
"monitor.copy": "Duplicar panel",
|
||||
"monitor.copyName": "Copia de {name}",
|
||||
"monitor.delete": "Eliminar panel",
|
||||
"monitor.deleteHint":
|
||||
"¿Eliminar «{name}»? Se detendrá su monitorización. Se conservarán los equipos y otros paneles.",
|
||||
"monitor.start": "Iniciar monitorización",
|
||||
"monitor.stop": "Detener",
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
"monitor.minutes": "Últimos {count} min",
|
||||
"monitor.sort": "Ordenar equipos",
|
||||
"monitor.sort.manual": "Orden guardado",
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Núcleos: {count}",
|
||||
"monitor.memory": "Memoria",
|
||||
"monitor.diskCapacity": "{used} usados / {total} en total",
|
||||
"monitor.diskCapacityUnknown": "Capacidad no disponible",
|
||||
"monitor.rootDisk": "Partición raíz",
|
||||
"monitor.network": "Red",
|
||||
"monitor.download": "Descarga",
|
||||
"monitor.upload": "Subida",
|
||||
"monitor.updated": "Actualizado",
|
||||
"monitor.updatedAt": "Actualizado: {time}",
|
||||
"monitor.noSamples": "Esperando la primera lectura",
|
||||
"monitor.cpuTrend": "Tendencia de CPU",
|
||||
"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",
|
||||
"monitor.selectionHint":
|
||||
"Selecciona entre grupos. Las conexiones no compatibles se indicarán al iniciar.",
|
||||
"monitor.defaultView": "Vista predeterminada",
|
||||
"monitor.saveHint":
|
||||
"Guardar conserva la configuración sin conectar. Las copias también empiezan detenidas.",
|
||||
"monitor.nameRequired": "Escribe un nombre para el panel.",
|
||||
"monitor.hostRequired": "Selecciona al menos un equipo.",
|
||||
"monitor.duplicateName": "Ya existe un panel con ese nombre.",
|
||||
"monitor.saveFailed": "No se pudo guardar el panel. Inténtalo de nuevo.",
|
||||
"monitor.deleted":
|
||||
"El panel se eliminó en otra ventana. Cierra el editor y crea uno nuevo.",
|
||||
"monitor.moveUp": "Subir equipo",
|
||||
"monitor.removeHost": "Quitar del panel",
|
||||
"monitor.missingHost": "Equipo eliminado",
|
||||
"monitor.observed": "Observado durante {count} min",
|
||||
"monitor.disks": "Particiones",
|
||||
"monitor.system": "Información del sistema",
|
||||
"monitor.loginHint":
|
||||
"Comprueba las credenciales o la conexión y reintenta. Las solicitudes se procesan equipo por equipo.",
|
||||
"monitor.retry": "Reintentar conexión",
|
||||
"monitor.unsupportedHint":
|
||||
"Se necesita Linux o macOS por SSH con canales de comandos adicionales.",
|
||||
"monitor.openTerminal": "Abrir terminal",
|
||||
"monitor.confirmHost": "Confirmar identidad",
|
||||
"monitor.status.stopped": "Sin iniciar",
|
||||
"monitor.status.queued": "En cola",
|
||||
"monitor.status.connecting": "Conectando",
|
||||
"monitor.status.live": "Actualizado",
|
||||
"monitor.status.login": "Requiere acceso",
|
||||
"monitor.status.error": "Error de lectura",
|
||||
"monitor.status.unsupported": "No disponible",
|
||||
"monitor.status.disconnected": "Desconectado",
|
||||
"monitor.status.stale": "Sin actualizar",
|
||||
"monitor.status.missing": "Equipo eliminado",
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { enMonitorMessages } from "./en/monitor";
|
||||
import { esMonitorMessages } from "./es/monitor";
|
||||
import { ruMonitorMessages } from "./ru/monitor";
|
||||
import { zhCNMonitorMessages } from "./zh-CN/monitor";
|
||||
import { zhTWMonitorMessages } from "./zh-TW/monitor";
|
||||
|
||||
test("monitoring UI literal message keys exist, including editor-only controls", () => {
|
||||
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
|
||||
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
|
||||
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
|
||||
for (const key of keys)
|
||||
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
|
||||
}
|
||||
});
|
||||
for (const [locale, messages] of Object.entries({
|
||||
es: esMonitorMessages,
|
||||
ru: ruMonitorMessages,
|
||||
"zh-CN": zhCNMonitorMessages,
|
||||
"zh-TW": zhTWMonitorMessages,
|
||||
})) {
|
||||
test(`monitoring has translated actions, states and matching placeholders in ${locale}`, () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(messages).sort(),
|
||||
Object.keys(enMonitorMessages).sort(),
|
||||
);
|
||||
for (const key of Object.keys(enMonitorMessages)) {
|
||||
const english = enMonitorMessages[key as keyof typeof enMonitorMessages];
|
||||
const translated = messages[key as keyof typeof messages];
|
||||
assert.ok(translated.trim(), key);
|
||||
assert.deepEqual(
|
||||
(translated.match(/\{\w+\}/g) ?? []).sort(),
|
||||
(english.match(/\{\w+\}/g) ?? []).sort(),
|
||||
key,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ruMonitorMessages } from './ru/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
@@ -10,7 +9,6 @@ import { ruScriptsMessages } from './ru/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruMonitorMessages,
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
"monitor.password": "Пароль (только для этого запуска)",
|
||||
"monitor.status.limit": "Одновременно можно наблюдать не более 256 серверов.",
|
||||
"sync.entityType.monitorPanels": "панели мониторинга",
|
||||
"monitor.title": "Мониторинг",
|
||||
"monitor.create": "Новая панель",
|
||||
"monitor.createFirst": "Создать первую панель",
|
||||
"monitor.empty": "Все серверы перед глазами",
|
||||
"monitor.emptyHint":
|
||||
"Выберите сохранённые серверы из любых групп и запустите мониторинг.",
|
||||
"monitor.choosePanel": "Выбрать панель",
|
||||
"monitor.edit": "Изменить панель",
|
||||
"monitor.copy": "Дублировать панель",
|
||||
"monitor.copyName": "Копия {name}",
|
||||
"monitor.delete": "Удалить панель",
|
||||
"monitor.deleteHint":
|
||||
"Удалить «{name}»? Мониторинг этой панели остановится. Серверы и другие панели сохранятся.",
|
||||
"monitor.start": "Начать мониторинг",
|
||||
"monitor.stop": "Остановить",
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
"monitor.minutes": "Последние {count} мин",
|
||||
"monitor.sort": "Сортировка серверов",
|
||||
"monitor.sort.manual": "Сохранённый порядок",
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
"monitor.cpu": "ЦП",
|
||||
"monitor.cores": "Ядер: {count}",
|
||||
"monitor.memory": "Память",
|
||||
"monitor.diskCapacity": "Использовано {used} / всего {total}",
|
||||
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
|
||||
"monitor.rootDisk": "Корневой раздел",
|
||||
"monitor.network": "Сеть",
|
||||
"monitor.download": "Загрузка",
|
||||
"monitor.upload": "Отправка",
|
||||
"monitor.updated": "Обновлено",
|
||||
"monitor.updatedAt": "Обновлено: {time}",
|
||||
"monitor.noSamples": "Ожидание первого измерения",
|
||||
"monitor.cpuTrend": "График ЦП",
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
"monitor.selectionHint":
|
||||
"Можно выбирать из разных групп. Неподдерживаемые подключения будут отмечены при запуске.",
|
||||
"monitor.defaultView": "Вид по умолчанию",
|
||||
"monitor.saveHint":
|
||||
"Сохранение не подключает серверы. Копии также создаются остановленными.",
|
||||
"monitor.nameRequired": "Введите название панели.",
|
||||
"monitor.hostRequired": "Выберите хотя бы один сервер.",
|
||||
"monitor.duplicateName": "Панель с таким названием уже существует.",
|
||||
"monitor.saveFailed": "Не удалось сохранить панель. Повторите попытку.",
|
||||
"monitor.deleted":
|
||||
"Панель удалена в другом окне. Закройте редактор и создайте новую.",
|
||||
"monitor.moveUp": "Переместить выше",
|
||||
"monitor.removeHost": "Убрать из панели",
|
||||
"monitor.missingHost": "Удалённый сервер",
|
||||
"monitor.observed": "Наблюдение: {count} мин",
|
||||
"monitor.disks": "Разделы дисков",
|
||||
"monitor.system": "Информация о системе",
|
||||
"monitor.loginHint":
|
||||
"Проверьте данные входа и настройки подключения. Подтверждения обрабатываются по одному серверу.",
|
||||
"monitor.retry": "Повторить подключение",
|
||||
"monitor.unsupportedHint":
|
||||
"Для мониторинга нужен Linux или macOS по SSH с дополнительными каналами команд.",
|
||||
"monitor.openTerminal": "Открыть терминал",
|
||||
"monitor.confirmHost": "Подтвердить сервер",
|
||||
"monitor.status.stopped": "Не запущено",
|
||||
"monitor.status.queued": "В очереди",
|
||||
"monitor.status.connecting": "Подключение",
|
||||
"monitor.status.live": "Актуально",
|
||||
"monitor.status.login": "Нужен вход",
|
||||
"monitor.status.error": "Ошибка сбора",
|
||||
"monitor.status.unsupported": "Недоступно",
|
||||
"monitor.status.disconnected": "Отключено",
|
||||
"monitor.status.stale": "Устарело",
|
||||
"monitor.status.missing": "Сервер удалён",
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { zhCNMonitorMessages } from './zh-CN/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
@@ -10,7 +9,6 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhCN: Messages = {
|
||||
...zhCNMonitorMessages,
|
||||
...zhCNCoreMessages,
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
"monitor.password": "密码(仅本次使用)",
|
||||
"monitor.status.limit": "同时最多监控 256 台不同主机。",
|
||||
"sync.entityType.monitorPanels": "监控面板",
|
||||
"monitor.title": "监控",
|
||||
"monitor.create": "新建面板",
|
||||
"monitor.createFirst": "创建第一个监控面板",
|
||||
"monitor.empty": "一眼查看多台主机",
|
||||
"monitor.emptyHint": "跨分组选择已有主机,准备好后再开始监控。",
|
||||
"monitor.choosePanel": "选择面板",
|
||||
"monitor.edit": "编辑面板",
|
||||
"monitor.copy": "复制面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "删除面板",
|
||||
"monitor.deleteHint":
|
||||
"删除“{name}”?该面板将停止监控,已有主机和其他面板会保留。",
|
||||
"monitor.start": "开始监控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分钟",
|
||||
"monitor.sort": "主机排序",
|
||||
"monitor.sort.manual": "保存的顺序",
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核",
|
||||
"monitor.memory": "内存",
|
||||
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暂无容量读数",
|
||||
"monitor.rootDisk": "根分区",
|
||||
"monitor.network": "网络",
|
||||
"monitor.download": "下载",
|
||||
"monitor.upload": "上传",
|
||||
"monitor.updated": "更新时间",
|
||||
"monitor.updatedAt": "更新于 {time}",
|
||||
"monitor.noSamples": "等待第一次读数",
|
||||
"monitor.cpuTrend": "CPU 趋势",
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
"monitor.selectionHint": "可跨分组选择。开始后会明确标出不支持的连接。",
|
||||
"monitor.defaultView": "默认展示",
|
||||
"monitor.saveHint": "保存仅保留配置,不会连接。复制的面板也不会自动开始。",
|
||||
"monitor.nameRequired": "请输入面板名称。",
|
||||
"monitor.hostRequired": "请至少选择一台主机。",
|
||||
"monitor.duplicateName": "已有同名面板,请换个名称。",
|
||||
"monitor.saveFailed": "面板未能保存,请重试。",
|
||||
"monitor.deleted": "该面板已在其他窗口删除。请关闭编辑后新建面板。",
|
||||
"monitor.moveUp": "主机上移",
|
||||
"monitor.removeHost": "从面板移除",
|
||||
"monitor.missingHost": "已删除的主机",
|
||||
"monitor.observed": "本次已观察 {count} 分钟",
|
||||
"monitor.disks": "磁盘分区",
|
||||
"monitor.system": "系统信息",
|
||||
"monitor.loginHint":
|
||||
"请检查主机的登录信息或连接设置后重试。身份确认和登录提示会逐台处理。",
|
||||
"monitor.retry": "重试连接",
|
||||
"monitor.unsupportedHint":
|
||||
"监控需要支持额外命令通道的 Linux 或 macOS SSH 连接。",
|
||||
"monitor.openTerminal": "打开终端",
|
||||
"monitor.confirmHost": "确认主机身份",
|
||||
"monitor.status.stopped": "未开始",
|
||||
"monitor.status.queued": "排队中",
|
||||
"monitor.status.connecting": "连接中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登录",
|
||||
"monitor.status.error": "采集失败",
|
||||
"monitor.status.unsupported": "不支持",
|
||||
"monitor.status.disconnected": "已断开",
|
||||
"monitor.status.stale": "读数过期",
|
||||
"monitor.status.missing": "主机已删除",
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { zhTWMonitorMessages } from './zh-TW/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhTWCoreMessages } from './zh-TW/core';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault';
|
||||
@@ -10,7 +9,6 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhTW: Messages = {
|
||||
...zhTWMonitorMessages,
|
||||
...zhTWCoreMessages,
|
||||
...zhTWVaultMessages,
|
||||
...zhTWTerminalMessages,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
"monitor.password": "密碼(僅本次使用)",
|
||||
"monitor.status.limit": "同時最多監控 256 台不同主機。",
|
||||
"sync.entityType.monitorPanels": "監控面板",
|
||||
"monitor.title": "監控",
|
||||
"monitor.create": "新增面板",
|
||||
"monitor.createFirst": "建立第一個監控面板",
|
||||
"monitor.empty": "一眼查看多台主機",
|
||||
"monitor.emptyHint": "跨群組選擇現有主機,準備好後再開始監控。",
|
||||
"monitor.choosePanel": "選擇面板",
|
||||
"monitor.edit": "編輯面板",
|
||||
"monitor.copy": "複製面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "刪除面板",
|
||||
"monitor.deleteHint":
|
||||
"刪除「{name}」?此面板將停止監控,現有主機和其他面板會保留。",
|
||||
"monitor.start": "開始監控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分鐘",
|
||||
"monitor.sort": "主機排序",
|
||||
"monitor.sort.manual": "儲存的順序",
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核心",
|
||||
"monitor.memory": "記憶體",
|
||||
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暫無容量讀數",
|
||||
"monitor.rootDisk": "根分割區",
|
||||
"monitor.network": "網路",
|
||||
"monitor.download": "下載",
|
||||
"monitor.upload": "上傳",
|
||||
"monitor.updated": "更新時間",
|
||||
"monitor.updatedAt": "更新於 {time}",
|
||||
"monitor.noSamples": "等待第一次讀數",
|
||||
"monitor.cpuTrend": "CPU 趨勢",
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
"monitor.selectionHint": "可跨群組選擇。開始後會明確標示不支援的連線。",
|
||||
"monitor.defaultView": "預設檢視",
|
||||
"monitor.saveHint": "儲存僅保留設定,不會連線。複製的面板也不會自動開始。",
|
||||
"monitor.nameRequired": "請輸入面板名稱。",
|
||||
"monitor.hostRequired": "請至少選擇一台主機。",
|
||||
"monitor.duplicateName": "已有同名面板,請換個名稱。",
|
||||
"monitor.saveFailed": "面板無法儲存,請重試。",
|
||||
"monitor.deleted": "此面板已在其他視窗刪除。請關閉編輯後新增面板。",
|
||||
"monitor.moveUp": "主機上移",
|
||||
"monitor.removeHost": "從面板移除",
|
||||
"monitor.missingHost": "已刪除的主機",
|
||||
"monitor.observed": "本次已觀察 {count} 分鐘",
|
||||
"monitor.disks": "磁碟分割區",
|
||||
"monitor.system": "系統資訊",
|
||||
"monitor.loginHint":
|
||||
"請檢查主機的登入資訊或連線設定後重試。身分確認與登入提示會逐台處理。",
|
||||
"monitor.retry": "重試連線",
|
||||
"monitor.unsupportedHint":
|
||||
"監控需要支援額外命令通道的 Linux 或 macOS SSH 連線。",
|
||||
"monitor.openTerminal": "開啟終端機",
|
||||
"monitor.confirmHost": "確認主機身分",
|
||||
"monitor.status.stopped": "未開始",
|
||||
"monitor.status.queued": "排程中",
|
||||
"monitor.status.connecting": "連線中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登入",
|
||||
"monitor.status.error": "採集失敗",
|
||||
"monitor.status.unsupported": "不支援",
|
||||
"monitor.status.disconnected": "已中斷",
|
||||
"monitor.status.stale": "讀數過期",
|
||||
"monitor.status.missing": "主機已刪除",
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { MonitorPanel } from "../domain/monitorPanels";
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
const {
|
||||
buildCloudSyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
applyLocalVaultPayload,
|
||||
} = await import("./syncPayload");
|
||||
const {
|
||||
writeMonitorPanels,
|
||||
readMonitorPanels,
|
||||
saveMonitorPanel,
|
||||
deleteMonitorPanel,
|
||||
} = await import("./state/monitorPanelsStorage");
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
const vault = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
};
|
||||
const opts = { collectPluginSidecars: () => undefined };
|
||||
const importers = { importVaultData: () => {} };
|
||||
test("real builders and importers round-trip panel configuration through cloud and backups without runtime state", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([
|
||||
{
|
||||
...panel,
|
||||
stats: { cpu: 42 },
|
||||
running: true,
|
||||
points: [1],
|
||||
error: "never-save",
|
||||
},
|
||||
]);
|
||||
const cloud = await buildCloudSyncPayload(vault, [], opts);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.deepEqual(cloud.monitorPanels, [panel]);
|
||||
assert.deepEqual(backup.monitorPanels, [panel]);
|
||||
assert.ok(!JSON.stringify(cloud).includes("never-save"));
|
||||
writeMonitorPanels([]);
|
||||
await applySyncPayload(cloud, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
const renamed = {
|
||||
...panel,
|
||||
name: "Changed",
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
};
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [renamed] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
const legacy = { ...cloud };
|
||||
delete legacy.monitorPanels;
|
||||
await applySyncPayload(legacy, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
test("serialized window mutations rebase stale fields and do not resurrect a deletion", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
await Promise.all([
|
||||
saveMonitorPanel(panel, { ...panel, name: "Rename" }),
|
||||
saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" }),
|
||||
]);
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "Rename", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
await deleteMonitorPanel("p");
|
||||
await assert.rejects(
|
||||
saveMonitorPanel(panel, { ...panel, name: "Stale" }),
|
||||
/deleted/,
|
||||
);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
|
||||
test("cloud apply preserves panel edits committed after preparation", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
const { prepareSyncPayloadApply } = await import("./syncPayload");
|
||||
const prepared = await prepareSyncPayloadApply(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [{ ...panel, name: "From cloud" }],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
await saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" });
|
||||
await prepared();
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "From cloud", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("cloud and backup imports preserve both independently created same-name panels", async () => {
|
||||
store.clear();
|
||||
const original = { ...panel, id: "a", name: "Fleet" };
|
||||
const other = { ...panel, id: "b", name: "FLEET" };
|
||||
writeMonitorPanels([original]);
|
||||
await applySyncPayload(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [other, original],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readMonitorPanels().map((p) => [p.id, p.name]),
|
||||
[
|
||||
["a", "Fleet"],
|
||||
["b", "FLEET (2)"],
|
||||
],
|
||||
);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.equal(readMonitorPanels().length, 2);
|
||||
assert.equal(
|
||||
new Set(readMonitorPanels().map((p) => p.name.toLowerCase())).size,
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { mergeSyncPayloads } from "../../domain/syncMerge";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
normalizeMonitorPanels,
|
||||
rebaseMonitorPanel,
|
||||
type MonitorPanel,
|
||||
} from "../../domain/monitorPanels";
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from "../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
localStorageAdapter,
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { withVaultImportLock } from "./vaultManagedImportLock";
|
||||
|
||||
export function readMonitorPanels(): MonitorPanel[] {
|
||||
return normalizeMonitorPanels(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_PANELS),
|
||||
);
|
||||
}
|
||||
export function writeMonitorPanels(value: unknown): void {
|
||||
if (
|
||||
!localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_PANELS,
|
||||
normalizeMonitorPanels(value),
|
||||
)
|
||||
)
|
||||
throw new Error("monitor.saveFailed");
|
||||
}
|
||||
export async function saveMonitorPanel(
|
||||
base: MonitorPanel | null,
|
||||
next: MonitorPanel,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(rebaseMonitorPanel(readMonitorPanels(), base, next)),
|
||||
);
|
||||
}
|
||||
export async function deleteMonitorPanel(id: string): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(readMonitorPanels().filter((p) => p.id !== id)),
|
||||
);
|
||||
}
|
||||
/** Rebase a prepared cloud snapshot against edits committed while it was applying. */
|
||||
export async function applyMonitorPanels(
|
||||
value: unknown,
|
||||
base: MonitorPanel[],
|
||||
replace = false,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const remote = normalizeMonitorPanels(value);
|
||||
if (replace) {
|
||||
writeMonitorPanels(remote);
|
||||
return;
|
||||
}
|
||||
const payload = (panels: MonitorPanel[]) => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 0,
|
||||
monitorPanels: panels,
|
||||
});
|
||||
writeMonitorPanels(
|
||||
mergeSyncPayloads(
|
||||
payload(base),
|
||||
payload(readMonitorPanels()),
|
||||
payload(remote),
|
||||
).payload.monitorPanels ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
let cachedRaw: string | null | undefined;
|
||||
let cached: MonitorPanel[] = [];
|
||||
function getSnapshot() {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_MONITOR_PANELS);
|
||||
if (raw !== cachedRaw) {
|
||||
cachedRaw = raw;
|
||||
cached = readMonitorPanels();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
function subscribe(listener: () => void) {
|
||||
const handler = (event: Event) => {
|
||||
const key =
|
||||
event instanceof StorageEvent
|
||||
? event.key
|
||||
: (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (!key || key === STORAGE_KEY_MONITOR_PANELS) listener();
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
};
|
||||
}
|
||||
export function useMonitorPanels(): MonitorPanel[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
|
||||
const empty: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
running: [],
|
||||
count: 0,
|
||||
hostCount: 0,
|
||||
epoch: 0,
|
||||
runtimeId: "",
|
||||
takenAt: 0,
|
||||
activeSessions: [],
|
||||
startedAt: {},
|
||||
hosts: {},
|
||||
};
|
||||
let snapshot = empty;
|
||||
const listeners = new Set<() => void>();
|
||||
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
publishMonitorSnapshot({ ...empty, unavailable: true });
|
||||
}
|
||||
export function useMonitorSnapshot() {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
() => snapshot,
|
||||
() => empty,
|
||||
);
|
||||
}
|
||||
export const monitorActions: {
|
||||
start: (id: string) => void;
|
||||
stop: (id: string) => void;
|
||||
stopAll: () => void;
|
||||
retry: (
|
||||
id: string,
|
||||
credentials?: { username: string; password: string },
|
||||
) => void;
|
||||
} = {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
stopAll: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
@@ -7,55 +7,6 @@ import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
let streamStarts = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({ type: "file", size: 3626, lastModified: 1758000001000 }),
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async () => { streamStarts += 1; return {}; },
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = `failed-${direction}-connection-3698`;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(direction === "download" ? side : side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => { throw new Error("SFTP channel open timed out after 10000ms"); },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "failed");
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel open timed out after 10000ms");
|
||||
assert.ok(task?.endTime);
|
||||
assert.equal(streamStarts, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -721,6 +721,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
|
||||
|
||||
@@ -1136,6 +1137,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
} finally {
|
||||
// Walk registry cleanup is owned by transferRuntime.runWalk.
|
||||
// Drop ephemeral pool holds opened when browse was soft-closed.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { MonitorPanel } from "../../domain/monitorPanels";
|
||||
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
const events = new EventTarget();
|
||||
for (const key of [
|
||||
"addEventListener",
|
||||
"removeEventListener",
|
||||
"dispatchEvent",
|
||||
] as const) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: events[key].bind(events),
|
||||
});
|
||||
}
|
||||
const windowFixture = Object.assign(events, {
|
||||
localStorage: storage,
|
||||
CustomEvent,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: windowFixture,
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const { useAutoSync } = await import("./useAutoSync");
|
||||
const { getCloudSyncManager } =
|
||||
await import("../../infrastructure/services/CloudSyncManager");
|
||||
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
|
||||
await import("./monitorPanelsStorage");
|
||||
|
||||
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
|
||||
const manager = getCloudSyncManager();
|
||||
const snapshot = {
|
||||
...manager.getState(),
|
||||
securityState: "UNLOCKED" as const,
|
||||
syncState: "IDLE" as const,
|
||||
autoSyncEnabled: true,
|
||||
providers: {
|
||||
...manager.getState().providers,
|
||||
webdav: { provider: "webdav" as const, status: "connected" as const },
|
||||
},
|
||||
};
|
||||
const uploads: SyncPayload[] = [];
|
||||
const originals = {
|
||||
getState: manager.getState,
|
||||
syncAllProviders: manager.syncAllProviders,
|
||||
loadSyncBase: manager.loadSyncBase,
|
||||
inspectProviderRemote: manager.inspectProviderRemote,
|
||||
setPendingLocalSync: manager.setPendingLocalSync,
|
||||
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
||||
};
|
||||
manager.getState = () => snapshot;
|
||||
manager.loadSyncBase = async () => null;
|
||||
manager.inspectProviderRemote = async () => ({
|
||||
remoteFile: null,
|
||||
payload: null,
|
||||
remoteChanged: false,
|
||||
});
|
||||
manager.syncAllProviders = async (payload) => {
|
||||
uploads.push(structuredClone(payload));
|
||||
return new Map<CloudProvider, SyncResult>([
|
||||
["webdav", { provider: "webdav", success: true, action: "upload" }],
|
||||
]);
|
||||
};
|
||||
manager.setPendingLocalSync = () => {};
|
||||
manager.setAvailablePluginSyncProviders = () => {};
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
let api: ReturnType<typeof useAutoSync> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const apply = async () => {};
|
||||
const Probe = () => {
|
||||
api = useAutoSync({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ["fixture"],
|
||||
startupReady: true,
|
||||
onApplyPayload: apply,
|
||||
onApplyConvergentPayload: apply,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const waitForUpload = async (count: number) => {
|
||||
// Flush the adapter's batched event, then observe the actual debounced
|
||||
// upload. Slow CI must not be mistaken for an incomplete collection.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
if (uploads.length === count) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
});
|
||||
} else {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (uploads.length < count && Date.now() < deadline)
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
uploads.length,
|
||||
count,
|
||||
"a panel-only change must alter the auto-sync hash and upload",
|
||||
);
|
||||
};
|
||||
try {
|
||||
writeMonitorPanels([
|
||||
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
|
||||
]);
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
await act(async () => {
|
||||
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
|
||||
});
|
||||
assert.deepEqual(uploads[0].monitorPanels, [panel]);
|
||||
const renamed = { ...panel, name: "Renamed" };
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, renamed);
|
||||
});
|
||||
await waitForUpload(2);
|
||||
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
|
||||
const edited = {
|
||||
...renamed,
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
interval: 30 as const,
|
||||
};
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(renamed, edited);
|
||||
});
|
||||
await waitForUpload(3);
|
||||
assert.deepEqual(uploads[2].monitorPanels, [edited]);
|
||||
await act(async () => {
|
||||
await deleteMonitorPanel(panel.id);
|
||||
});
|
||||
await waitForUpload(4);
|
||||
assert.deepEqual(uploads[3].monitorPanels, []);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(null, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
assert.deepEqual(uploads[4].monitorPanels, [panel]);
|
||||
assert.ok(
|
||||
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
|
||||
);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readMonitorPanels } from './monitorPanelsStorage';
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
|
||||
/**
|
||||
* useAutoSync - Auto-sync Hook for Cloud Sync
|
||||
*
|
||||
@@ -153,7 +151,6 @@ const isRestoreInProgress = (): boolean => {
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
monitorPanels: payload.monitorPanels,
|
||||
hosts: sanitizeHostsForSync(payload.hosts),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
@@ -264,7 +261,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
|
||||
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && !chatChanged) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -286,7 +283,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
return {
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts: sanitizeHostsForSync(config.hosts),
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppVaultRuntime } from "./appRuntimeBridge";
|
||||
import { useMonitorSnapshot } from "./monitorRuntimeStore";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
createKnownHostFromHostKeyInfo,
|
||||
upsertKnownHost,
|
||||
} from "../../domain/knownHosts";
|
||||
import { toHostKeyInfo } from "../../domain/hostKey";
|
||||
type Request = Parameters<
|
||||
Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]
|
||||
>[0];
|
||||
export function useMonitorHostKeyVerification() {
|
||||
const { knownHosts, updateKnownHosts } = useAppVaultRuntime();
|
||||
const [request, setRequest] = useState<Request | null>(null);
|
||||
const snapshot = useMonitorSnapshot();
|
||||
const receivedAt = useRef(0);
|
||||
useEffect(
|
||||
() =>
|
||||
netcattyBridge.get()?.onHostKeyVerification?.((r) => {
|
||||
if (r.sessionId?.startsWith("monitor-")) {
|
||||
receivedAt.current = Date.now();
|
||||
setRequest(r);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const respond = (accept: boolean, save = false) => {
|
||||
if (!request) return;
|
||||
if (accept && save)
|
||||
updateKnownHosts(
|
||||
upsertKnownHost(
|
||||
knownHosts,
|
||||
createKnownHostFromHostKeyInfo(toHostKeyInfo(request)),
|
||||
),
|
||||
);
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.respondHostKeyVerification?.(request.requestId, accept, save);
|
||||
setRequest(null);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (
|
||||
request &&
|
||||
snapshot.takenAt >= receivedAt.current &&
|
||||
(!snapshot.running.length ||
|
||||
!snapshot.activeSessions.includes(request.sessionId ?? ""))
|
||||
) {
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.respondHostKeyVerification?.(request.requestId, false, false);
|
||||
setRequest(null);
|
||||
}
|
||||
}, [
|
||||
request,
|
||||
snapshot.running.length,
|
||||
snapshot.takenAt,
|
||||
snapshot.activeSessions,
|
||||
]);
|
||||
return { request, respond };
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { hostRestrictsExtraSshChannels } from "../../domain/host";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildHostConnectionTestPlan } from "../../domain/hostConnectionTest";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import { getEffectiveKnownHosts } from "../../infrastructure/syncHelpers";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
useAppLockChrome,
|
||||
useAppSettingsRuntime,
|
||||
useAppVaultRuntime,
|
||||
} from "./appRuntimeBridge";
|
||||
import { useMonitorPanels } from "./monitorPanelsStorage";
|
||||
import {
|
||||
monitorActions,
|
||||
getMonitorHistoryCursor,
|
||||
publishMonitorSnapshot,
|
||||
publishMonitorUnavailable,
|
||||
} from "./monitorRuntimeStore";
|
||||
import type { MonitorPanelDemand } from "../../domain/monitorRuntime";
|
||||
|
||||
/** Root lifetime, independent of Vault navigation. No runtime state is persisted. */
|
||||
export function useMonitorRuntime() {
|
||||
const panels = useMonitorPanels();
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
isInitialized,
|
||||
} = useAppVaultRuntime();
|
||||
const { terminalSettings } = useAppSettingsRuntime();
|
||||
const { locked } = useAppLockChrome();
|
||||
const [running, setRunning] = useState(() => new Map<string, string>());
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const epoch = useRef<number | null>(null);
|
||||
const runtimeId = useRef<string | null>(null);
|
||||
const credentials = useRef(
|
||||
new Map<string, { username: string; password: string }>(),
|
||||
);
|
||||
useEffect(() => {
|
||||
monitorActions.start = (id) =>
|
||||
setRunning((prev) => new Map(prev).set(id, crypto.randomUUID()));
|
||||
monitorActions.stop = (id) =>
|
||||
setRunning((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
monitorActions.stopAll = () => {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
void netcattyBridge.get()?.monitorStopAll?.();
|
||||
};
|
||||
monitorActions.retry = (id, override) => {
|
||||
if (override) credentials.current.set(id, override);
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.monitorRetry?.(id)
|
||||
.then(() => setRetryVersion((v) => v + 1));
|
||||
};
|
||||
const offline = () => monitorActions.stopAll();
|
||||
window.addEventListener("offline", offline);
|
||||
return () => {
|
||||
window.removeEventListener("offline", offline);
|
||||
void netcattyBridge.get()?.monitorUpdate?.([]);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (locked || !isInitialized) {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
}
|
||||
}, [locked, isInitialized]);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.monitorUpdate) {
|
||||
publishMonitorUnavailable();
|
||||
return;
|
||||
}
|
||||
const demand: MonitorPanelDemand[] =
|
||||
locked || !isInitialized
|
||||
? []
|
||||
: panels
|
||||
.filter((p) => running.has(p.id))
|
||||
.map((panel) => ({
|
||||
id: panel.id,
|
||||
runId: running.get(panel.id)!,
|
||||
interval: panel.interval,
|
||||
hosts: panel.hostIds.map((id) => {
|
||||
const host = hosts.find((h) => h.id === id);
|
||||
if (
|
||||
!host ||
|
||||
(host.protocol && host.protocol !== "ssh") ||
|
||||
hostRestrictsExtraSshChannels(host)
|
||||
)
|
||||
return { id };
|
||||
const effective = resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
});
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
terminalSettings,
|
||||
sessionId: `monitor-${id}`,
|
||||
bootEpoch: 0,
|
||||
authOverride: credentials.current.has(id)
|
||||
? { ...credentials.current.get(id), authMethod: "password" }
|
||||
: null,
|
||||
});
|
||||
if (!plan.ok) return { id, status: "error" as const };
|
||||
if (plan.needsCredentialReentry)
|
||||
return { id, status: "login" as const };
|
||||
return {
|
||||
id,
|
||||
options: {
|
||||
...plan.options,
|
||||
singleChannelSsh: hostRestrictsExtraSshChannels(effective),
|
||||
sshAuthReadyTimeoutMs: 120000,
|
||||
agentForwarding: effective.agentForwarding,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const activeHostIds = new Set(
|
||||
demand.flatMap((p) => p.hosts.map((h) => h.id)),
|
||||
);
|
||||
for (const id of credentials.current.keys())
|
||||
if (!activeHostIds.has(id)) credentials.current.delete(id);
|
||||
void bridge
|
||||
.monitorUpdate(demand)
|
||||
.then(publishMonitorSnapshot)
|
||||
.catch(() => {
|
||||
credentials.current.clear();
|
||||
setRunning((prev) => (prev.size ? new Map() : prev));
|
||||
publishMonitorUnavailable();
|
||||
});
|
||||
}, [
|
||||
panels,
|
||||
running,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
locked,
|
||||
isInitialized,
|
||||
retryVersion,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let disposed = false;
|
||||
let pending = false;
|
||||
const poll = async () => {
|
||||
if (pending || !bridge?.monitorSnapshot) return;
|
||||
pending = true;
|
||||
try {
|
||||
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
|
||||
if (disposed) return;
|
||||
if (
|
||||
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
|
||||
(runtimeId.current !== null &&
|
||||
runtimeId.current !== snapshot.runtimeId)
|
||||
) {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
}
|
||||
epoch.current = snapshot.epoch;
|
||||
runtimeId.current = snapshot.runtimeId;
|
||||
publishMonitorSnapshot(snapshot);
|
||||
} catch {
|
||||
credentials.current.clear();
|
||||
setRunning((prev) => (prev.size ? new Map() : prev));
|
||||
publishMonitorUnavailable();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readMonitorPanels, writeMonitorPanels } from './monitorPanelsStorage';
|
||||
import type { MonitorPanel } from '../../domain/monitorPanels';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
hostsEqualForIdentityReuse,
|
||||
@@ -126,7 +124,6 @@ import {
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
monitorPanels?: MonitorPanel[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
@@ -1191,7 +1188,6 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
void withVaultImportLock('vault', async () => writeMonitorPanels([]));
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
@@ -1976,7 +1972,6 @@ export const useVaultState = () => {
|
||||
|
||||
const exportData = useCallback(
|
||||
(): ExportableVaultData => ({
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -1995,7 +1990,6 @@ export const useVaultState = () => {
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.monitorPanels !== undefined) encryptedWrites.push(withVaultImportLock('vault', async () => writeMonitorPanels(payload.monitorPanels)));
|
||||
if (payload.hosts) {
|
||||
// Cloud payloads no longer carry `lastConnectedAt` (#2629), but any
|
||||
// remote reconciliation replaces the whole local host array. Re-attach
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
@@ -9,8 +8,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -39,8 +38,6 @@ function installLocalStorage(): LocalStorageMock {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readMonitorPanels, applyMonitorPanels } from './state/monitorPanelsStorage';
|
||||
import { normalizeMonitorPanels } from '../domain/monitorPanels';
|
||||
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
@@ -1092,7 +1090,6 @@ function buildVaultSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: collectSyncableSettings(),
|
||||
@@ -1181,7 +1178,6 @@ export async function buildCloudSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
@@ -1363,7 +1359,6 @@ async function preparePayloadApply(
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const monitorPanelsAtPrepare = readMonitorPanels();
|
||||
// Prepare credentials before any vault or settings mutation. Encryption can
|
||||
// fail when local credential storage is unavailable; reuse the result below.
|
||||
const providers = sanitizedPayload.settings?.ai?.providers;
|
||||
@@ -1405,7 +1400,6 @@ async function preparePayloadApply(
|
||||
}
|
||||
|
||||
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
if (sanitizedPayload.monitorPanels !== undefined) await applyMonitorPanels(normalizeMonitorPanels(sanitizedPayload.monitorPanels), monitorPanelsAtPrepare, options.includeLocalOnlyData);
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/** Serialized Web Locks fixture for tests with a simulated browser window. */
|
||||
export function installBrowserLockFixture(): void {
|
||||
const tails = new Map<string, Promise<void>>();
|
||||
const locks = {
|
||||
async request<T>(name: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = tails.get(name) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
tails.set(name, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (tails.get(name) === tail) tails.delete(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: { locks },
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,6 @@ interface SelectHostPanelProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onBack: () => void;
|
||||
onClose?: () => void;
|
||||
onContinue?: () => void;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
@@ -26,7 +25,6 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
@@ -44,7 +42,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onBack,
|
||||
onClose,
|
||||
onContinue,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
@@ -56,7 +53,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
headerClassName,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
@@ -82,9 +78,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onClose ?? onBack}
|
||||
onClose={onBack}
|
||||
title={panelTitle}
|
||||
headerClassName={headerClassName}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
|
||||
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
@@ -487,7 +487,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
aria-label={t('selectHost.toggleHost', { name: host.label })}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
|
||||
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
// Keep keyboard cursor visible even when the host is already selected.
|
||||
isActive && 'ring-1 ring-primary/40',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
|
||||
@@ -170,7 +170,6 @@ const SnippetsManager = lazy(() => import("./SnippetsManager"));
|
||||
|
||||
export type VaultSection =
|
||||
| "hosts"
|
||||
| "monitor"
|
||||
| "keys"
|
||||
| "proxies"
|
||||
| "snippets"
|
||||
@@ -1616,7 +1615,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
NotebookText,
|
||||
NotesManager,
|
||||
onCommitPluginImporterData,
|
||||
onConnect,
|
||||
onConnectSerial,
|
||||
onCreateLocalTerminal,
|
||||
onDeleteHost,
|
||||
|
||||
@@ -577,7 +577,6 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
|
||||
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
|
||||
browserBookmarks: 'cloudSync.conflict.entity.browserBookmarks',
|
||||
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
|
||||
monitorPanels: 'monitor.title',
|
||||
aiSessions: 'cloudSync.aiSessions.title',
|
||||
settings: 'cloudSync.conflict.entity.settings',
|
||||
};
|
||||
|
||||
@@ -34,9 +34,13 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
|
||||
assert.match(source, /navigableIndexByKey/);
|
||||
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
|
||||
assert.doesNotMatch(source, /navigable\.findIndex/);
|
||||
// The keyboard cursor stays visible on selected hosts through background alone.
|
||||
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
|
||||
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
|
||||
// Selected hosts must still show the keyboard active ring.
|
||||
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
|
||||
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
|
||||
);
|
||||
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
|
||||
// One listbox tab stop — not per-host tabIndex under virtualization.
|
||||
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
|
||||
|
||||
@@ -83,7 +83,6 @@ interface AsidePanelProps extends AsidePanelResizeProps {
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
@@ -100,7 +99,6 @@ interface AsidePanelHeaderProps {
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
showBackButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Header Component
|
||||
@@ -111,10 +109,9 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
onBack,
|
||||
onClose,
|
||||
showBackButton = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0", className)}>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showBackButton && onBack && (
|
||||
<button
|
||||
@@ -173,18 +170,16 @@ export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: strin
|
||||
// Action Menu Component (for the ... button)
|
||||
interface AsideActionMenuProps {
|
||||
children: ReactNode;
|
||||
triggerClassName?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children, triggerClassName, ariaLabel }) => {
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
|
||||
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -350,7 +345,6 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
onBack,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
resizable = false,
|
||||
@@ -420,7 +414,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && !usesPixelWidth && width,
|
||||
isResizing && 'transition-none',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
@@ -441,7 +435,6 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
className={headerClassName}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react";
|
||||
import type { MonitorStats } from "../../domain/monitorRuntime";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const diskCapacity = (value: number) => value >= 1024
|
||||
? `${(value / 1024).toFixed(2)} TiB`
|
||||
: value > 0 && value < 1
|
||||
? `${(value * 1024).toFixed(1)} MiB`
|
||||
: `${value.toFixed(1)} GiB`;
|
||||
|
||||
/** Cards show the largest capacities; the list keeps every reported mount. */
|
||||
export function MonitorDiskUsage({
|
||||
stats,
|
||||
limit,
|
||||
}: {
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const reported = stats?.disks.length
|
||||
? stats.disks
|
||||
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
|
||||
const disks = limit == null
|
||||
? reported
|
||||
: [...reported].sort((a, b) =>
|
||||
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
|
||||
).slice(0, limit);
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
|
||||
{disks.map((disk, index) => (
|
||||
<Tooltip key={`${disk.mountPoint}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<ResourceBar label="" value={disk.percent} className="flex-1" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
|
||||
<div className="mb-1 break-all font-medium">{disk.mountPoint}</div>
|
||||
<div className="tabular-nums text-muted-foreground">
|
||||
{typeof disk.used === "number" && Number.isFinite(disk.used) && disk.used >= 0 && typeof disk.total === "number" && Number.isFinite(disk.total) && disk.total > 0
|
||||
? t("monitor.diskCapacity", { used: diskCapacity(disk.used), total: diskCapacity(disk.total) })
|
||||
: t("monitor.diskCapacityUnknown")}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useMonitorHostKeyVerification } from "../../application/state/useMonitorHostKeyVerification";
|
||||
import { toHostKeyInfo } from "../../domain/hostKey";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
|
||||
export function MonitorHostKeyDialog() {
|
||||
const { t } = useI18n();
|
||||
const { request, respond } = useMonitorHostKeyVerification();
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && respond(false)}>
|
||||
<DialogContent hideCloseButton>
|
||||
<DialogTitle className="sr-only">
|
||||
{t("monitor.confirmHost")}
|
||||
</DialogTitle>
|
||||
{request && (
|
||||
<TerminalHostKeyVerification
|
||||
hostKeyInfo={toHostKeyInfo(request)}
|
||||
showLogs={false}
|
||||
progressLogs={[]}
|
||||
onClose={() => respond(false)}
|
||||
onContinue={() => respond(true)}
|
||||
onAddAndContinue={() => respond(true, true)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from "react";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { RippleButton } from "../ui/ripple";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
export function MonitorNavButton({
|
||||
active,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
onClick={onClick}
|
||||
aria-label={t("monitor.title")}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
collapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
active &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
>
|
||||
<Gauge size={16} className="shrink-0" />
|
||||
{!collapsed && <span>{t("monitor.title")}</span>}
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t("monitor.title")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider";
|
||||
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
|
||||
import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const point = (ago: number, cpu: number | null): MonitorPoint => ({
|
||||
at: now - ago,
|
||||
cpu,
|
||||
memory: null,
|
||||
disk: null,
|
||||
rx: null,
|
||||
tx: null,
|
||||
});
|
||||
const trend = (points: MonitorPoint[]) => new JSDOM(renderToStaticMarkup(
|
||||
<MonitorTrend clock={0} points={points} field="cpu" range={5} interval={10} label="CPU trend" />,
|
||||
)).window.document;
|
||||
|
||||
test("unchanged series reuse geometry but age out when the viewport clock advances", async (t) => {
|
||||
const dom = new JSDOM("<div id='root'></div>");
|
||||
const globals = { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const originals = Object.fromEntries(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
let current = now;
|
||||
t.mock.method(Date, "now", () => current);
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const samples = [point(1000, 42)];
|
||||
try {
|
||||
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU" />));
|
||||
const path = dom.window.document.querySelector("svg g path")!.getAttribute("d");
|
||||
current += 1000;
|
||||
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
|
||||
assert.equal(dom.window.document.querySelector("svg g path")!.getAttribute("d"), path);
|
||||
current += 6 * 60000;
|
||||
await act(async () => root.render(<MonitorTrend clock={1} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
|
||||
assert.equal(dom.window.document.querySelectorAll("svg g path, svg circle").length, 0);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const key of Object.keys(globals)) {
|
||||
const descriptor = originals[key];
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("empty trends do not draw a reading", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const document = trend([]);
|
||||
assert.equal(document.querySelectorAll("svg g path, svg circle").length, 0);
|
||||
});
|
||||
|
||||
test("a first reading is visible at its actual time without invented history", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const document = trend([point(1000, 40)]);
|
||||
const dot = document.querySelector("circle")!;
|
||||
assert.equal(Number(dot.getAttribute("cx")), 299);
|
||||
assert.equal(document.querySelector('g path[fill="none"]')!.getAttribute("d"), "M 299.00 36.40");
|
||||
const area = document.querySelector("g path")!.getAttribute("d")!;
|
||||
assert.equal(area, "M 299.00 36.40 L 299.00 58 L 299.00 58 Z");
|
||||
});
|
||||
|
||||
test("trend lines and shaded areas break at missing readings and collection gaps", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
for (const points of [
|
||||
[point(40000, 20), point(30000, null), point(20000, 30), point(10000, 40)],
|
||||
[point(50000, 20), point(10000, 30), point(0, 40)],
|
||||
]) {
|
||||
const document = trend(points);
|
||||
const segments = [...document.querySelectorAll("svg g")];
|
||||
assert.equal(segments.length, 2);
|
||||
assert.equal(segments[0].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), false);
|
||||
assert.equal(segments[1].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), true);
|
||||
// Each area starts at its own segment, rather than extending to the chart origin.
|
||||
for (const segment of segments) {
|
||||
const firstX = segment.querySelector("path")!.getAttribute("d")!.split(" ")[1];
|
||||
assert.ok(Number(firstX) > 0);
|
||||
assert.ok(segment.querySelector("path")!.getAttribute("d")!.endsWith(`L ${firstX} 58 Z`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple host charts use independent gradient references", () => {
|
||||
const document = new JSDOM(renderToStaticMarkup(<>
|
||||
<MonitorTrend clock={0} points={[]} field="cpu" range={5} interval={10} label="CPU" />
|
||||
<MonitorTrend clock={0} points={[]} field="memory" range={5} interval={10} label="Memory" />
|
||||
</>)).window.document;
|
||||
const ids = [...document.querySelectorAll("linearGradient")].map((g) => g.id);
|
||||
assert.equal(new Set(ids).size, 2);
|
||||
});
|
||||
|
||||
const hosts: Host[] = [
|
||||
{ id: "custom", label: "Cloud host", hostname: "cloud.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, iconMode: "custom", iconId: "cloud", iconColorMode: "manual", iconColorCustom: "#9333EA" },
|
||||
{ id: "brand", label: "Ubuntu host", hostname: "ubuntu.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, distro: "ubuntu" },
|
||||
];
|
||||
|
||||
test("disk usage shows every reported partition, including healthy and empty disks", () => {
|
||||
const disks = [
|
||||
{ mountPoint: "/", used: 25, total: 100, percent: 25 },
|
||||
{ mountPoint: "/data", used: 95, total: 100, percent: 95 },
|
||||
{ mountPoint: "/backup", used: 0, total: 100, percent: 0 },
|
||||
];
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
|
||||
)).window.document;
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
|
||||
for (const value of ["25.0%", "95.0%", "0.0%"])
|
||||
assert.ok(document.body.textContent!.includes(value));
|
||||
});
|
||||
|
||||
test("disk usage retains a root-only reading and leaves missing data unknown", () => {
|
||||
const known = renderToStaticMarkup(<MonitorDiskUsage stats={{ disks: [], diskPercent: 42 }} />);
|
||||
const unknown = renderToStaticMarkup(<MonitorDiskUsage />);
|
||||
assert.ok(known.includes("42.0%"));
|
||||
assert.ok(unknown.includes("--"));
|
||||
assert.ok(!unknown.includes("0.0%"));
|
||||
});
|
||||
|
||||
test("cards show the two largest capacities without changing the source disk order", () => {
|
||||
const disks = [
|
||||
{ mountPoint: "/small", used: 49, total: 50, percent: 98 },
|
||||
{ mountPoint: "/data", used: 100, total: 1000, percent: 10 },
|
||||
{ mountPoint: "/backup", used: 0, total: 500, percent: 0 },
|
||||
];
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<MonitorDiskUsage stats={{ disks, diskPercent: 98 }} limit={2} />,
|
||||
)).window.document;
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/data", "/backup"]);
|
||||
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
|
||||
});
|
||||
|
||||
for (const view of ["list", "charts"] as const) {
|
||||
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
|
||||
const storage = new Map<string, string>();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
} });
|
||||
t.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, "localStorage", original);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
writeMonitorPanels([{ id: "panel", name: "Panel", description: "", hostIds: hosts.map((h) => h.id), view, interval: 10, range: 15, sort: "manual" }]);
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorPanels hosts={hosts} customGroups={[]} onConnect={() => {}} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const icon = document.querySelector(".lucide-cloud")!;
|
||||
assert.ok(icon);
|
||||
assert.equal((icon.parentElement as HTMLElement).style.backgroundColor, "rgb(147, 51, 234)");
|
||||
assert.ok(document.querySelector('img[src="/distro/ubuntu.svg"]'));
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,3 @@
|
||||
import { MonitorPanels } from './MonitorPanels';
|
||||
import { MonitorNavButton } from './MonitorNavButton';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Combobox } from "../ui/combobox";
|
||||
import React, { useCallback } from "react";
|
||||
@@ -251,7 +249,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
onConnect,
|
||||
onConnectSerial,
|
||||
onCreateLocalTerminal,
|
||||
onDeleteHost,
|
||||
@@ -624,7 +621,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<MonitorNavButton active={currentSection === "monitor"} collapsed={sidebarCollapsed} onClick={() => setCurrentSection("monitor")} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
@@ -1411,7 +1407,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
{currentSection === "monitor" && <MonitorPanels hosts={hosts} customGroups={customGroups} onConnect={onConnect} />}
|
||||
{currentSection === "port" && (
|
||||
<LazyLoadBoundary
|
||||
name="Port forwarding"
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# 自定义监控面板:研究与页面雏形
|
||||
|
||||
日期:2026-10-17。对应 [#3621](https://github.com/binaricat/Netcatty/issues/3621)。本稿取代上一版“在主机分组里开启监控”的提案,以用户本轮要求为准。
|
||||
|
||||
状态:用户已认可雏形并授权正式实现,补充的五项要求如下,优先于原型中仅为演示而作的简化:
|
||||
|
||||
- 成品使用整个 App 的设计系统、主题色、字体、间距与现有组件;不把独立雏形的样式直接搬入产品。
|
||||
- 新建、编辑、选择主机与详情的表单交互沿用当前 Vault 的成熟模式,包括返回、取消、保存、验证与滚动行为。
|
||||
- 所有新增用户可见文字、状态、错误与操作完整接入现有 i18n,覆盖项目已有语言,不把雏形中的中文硬编码带入成品。
|
||||
- 面板配置中的主机引用、顺序、列表/图表布局和显示偏好等纳入现有云同步,并覆盖相应导入导出、备份及兼容路径;读数与趋势仍绝不持久化或同步。
|
||||
- 主机系统识别、能力判断、信息探测与状态采集复用或扩充现有逻辑,禁止为监控平行重写一套探测器。
|
||||
|
||||
## 1. 已确认的产品要求
|
||||
|
||||
1. Vault 左侧菜单新增独立“监控”栏目。
|
||||
2. 用户可创建、命名和切换多个监控面板。
|
||||
3. 每个面板自由选择已有主机,可跨分组;面板不等于主机分组。
|
||||
4. 同一个面板可在列表和丰富图表两种展示之间切换。
|
||||
5. 使用 SSH 长连接,沿用 Netcatty 现有连接复用逻辑,不另造一套独立监控连接池。
|
||||
6. 用户已明确:面板名称、主机选择、展示偏好等配置重启后保留;监控读数不在本地持久化。
|
||||
7. 研究与雏形已完成并获认可;正式实现单独创建 thread 与 worktree,延续此前授权的 PR 审查与合并流程。原型验证不替代真实 App、同步与 SSH 验收。
|
||||
|
||||
完成标准:核实多面板管理、主机选择、两种展示和连接复用的相关一手资料;给出一致的运行与保存规则;雏形中的新建、修改、复制、删除、选主机、切换、启停和失败提示能实际操作;打开浏览器验证布局与交互,并区分示例演示与真实连接验证。
|
||||
|
||||
## 2. 研究结论
|
||||
|
||||
最适合的组合是 **Homarr 的命名面板管理 + Grafana 的对象与展示分离 + ServerBox 的紧凑主机卡片 + Termix 的共享采集**。最终做成 Netcatty 自己的轻量监控页面,保持现有 Vault 导航、品牌、配色、图标和右侧面板习惯。
|
||||
|
||||
不需要让用户逐张图配置数据源、查询语言和坐标位置。面板只负责“看哪些主机、怎么显示、多久更新”,一台主机在多个面板出现,不应使远端查询成倍增加。
|
||||
|
||||
### 一手资料对照
|
||||
|
||||
| 项目 | 已核实的模式 | 本设计采用什么 | 没有照搬什么 |
|
||||
| --- | --- | --- | --- |
|
||||
| Homarr | 独立命名 board,创建、复制、打开、删除、搜索切换;服务器选择器补充来源防同名 | 面板管理与切换;主机显示名称、地址和分组 | 分享权限、门户首页、嵌套容器、完整自由布局 |
|
||||
| Grafana | 集中选择监控对象;同一数据提供者切换图形/表格预览;自动网格 | 主机集合属于整页面板;列表/图表只换展示;自动排布 | 查询编辑器、数据源管理、变量依赖、历史数据库 |
|
||||
| ServerBox | 多主机卡片/行/网格密度,核心读数摘要与单机详情 | 图表卡片与列表的信息密度 | 远端采集脚本与其断连行为 |
|
||||
| Termix | 同机多观察者共享采集;列表摘要与详情分开;认证与网络错误分别处理 | 多面板共享一份指标,明确失败和待登录 | 独立于终端的监控连接池 |
|
||||
|
||||
本轮没有部署竞品;下列行为来自官方文档与固定提交源码。没有声称某一个竞品完整具备本设计的全部功能。
|
||||
|
||||
### Homarr:面板管理和对象选择
|
||||
|
||||
固定提交:`e0ff7ab007d915269351ab9f5b227f2ffcc2c115`。
|
||||
|
||||
- [官方 Boards](https://homarr.dev/docs/management/boards/) 与 [创建表单](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/packages/forms-collection/src/board-create/board-create-form.tsx#L20-L98)确认 board 是可命名的独立对象;[复制表单](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/apps/nextjs/src/app/%5Blocale%5D/manage/boards/_components/duplicate-board-form.tsx#L22-L86)要求为副本命名。
|
||||
- [BoardSwitcher](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/apps/nextjs/src/components/board/board-switcher.tsx#L51-L80)支持按名称过滤面板;我们采用轻量下拉,不再增加第二级永久侧栏。
|
||||
- [系统选择](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/packages/widgets/src/beszel-system-stats/selection.ts#L14-L32)用来源 ID 与系统 ID 区分对象,多来源时补来源名称。我们对应保存已有主机 ID,并展示地址和分组,避免只凭名称选错。
|
||||
- 官方有 [table](https://homarr.dev/docs/widgets/beszel-system-table/)、[grid](https://homarr.dev/docs/widgets/beszel-system-grid/) 和 [stats](https://homarr.dev/docs/widgets/beszel-system-stats/) 三种相关展示。它们是不同 widget,并不能证明 Homarr 有一个按钮让整个页面直接切换三种展示。我们的一键列表/图表是针对本需求做的简化。
|
||||
|
||||
### Grafana:同数据换展示与自动排布
|
||||
|
||||
固定提交:`3392442829842df6485c9305a23c30943147ebe6`。
|
||||
|
||||
- [Variables 文档](https://grafana.com/docs/grafana/latest/visualizations/dashboards/variables/)说明通过页面顶部的统一选择改变监控对象,避免复制很多相同内容。我们的对象选择使用现有主机多选,而不引入变量语言。
|
||||
- [PanelEditor.onToggleTableView](https://github.com/grafana/grafana/blob/3392442829842df6485c9305a23c30943147ebe6/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx#L422-L448)保留原图表的数据提供者,通过 `DataProviderSharer` 建立表格预览。这是同数据换展示的明确先例,但它是图表编辑器内预览,不是全局 dashboard 一键转表格。
|
||||
- [自动网格编辑器](https://github.com/grafana/grafana/blob/3392442829842df6485c9305a23c30943147ebe6/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx#L44-L171)提供按最小列宽和最多列数排布的能力。我们的首版只自动适应可用宽度,不做拖拽搭建器。
|
||||
|
||||
### ServerBox 与 Termix:多机读数与共享
|
||||
|
||||
- [ServerBox 列表密度](https://github.com/lollipopkit/flutter_server_box/blob/278d48013e0e855c889455aeb58e17c9b8db6679/lib/view/page/server/card/density.dart#L6-L95)和[读数构造](https://github.com/lollipopkit/flutter_server_box/blob/278d48013e0e855c889455aeb58e17c9b8db6679/lib/view/page/server/card/metric.dart#L491)提供多机紧凑呈现参考;标签过滤不等于本设计的命名面板。
|
||||
- [Termix 观察者管理](https://github.com/Termix-SSH/Plugin-Host-Metrics/blob/e3bde42a184e41c2b09c80a82fe2ab1f3306a726/src/backend/poller.ts#L59-L157)提供同机多处查看共享采集的参考;[采集入口](https://github.com/Termix-SSH/Plugin-Host-Metrics/blob/e3bde42a184e41c2b09c80a82fe2ab1f3306a726/src/backend/collect.ts#L67-L185)优先使用插件自己的连接或 stats 池,不能当成已经满足 Netcatty 的终端连接复用要求。
|
||||
|
||||
### Xterminal 在新方案中的位置
|
||||
|
||||
此前已实际查看需求中的列表截图、官方连接中心和单机监控图片。Xterminal 的“信息”列和右侧详细监控适合参考读数组织;新的多命名面板结构来自本轮用户要求,不能继续受“只在分组里加开关”的旧方案限制。
|
||||
|
||||
[Xterminal 连接中心](https://docs.xterminal.cn/connections/overview/);[官方监控说明](https://docs.xterminal.cn/advanced/server-monitor/)明确会写采集脚本,不能将它作为“长连接就没有查询/审计”的证明。
|
||||
|
||||
## 3. 页面结构与主要流程
|
||||
|
||||
### 独立入口
|
||||
|
||||
Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和折叠方式。正在运行时,栏目旁显示运行面板数;应用顶部工具栏保持原样。页面主体依次为:
|
||||
|
||||
1. 标题“监控”、“全部停止”和“新建面板”。
|
||||
2. 当前面板名称下拉、说明、编辑与更多菜单。
|
||||
3. 列表/图表切换、搜索、趋势范围、更新频率、当前面板开始/停止。
|
||||
4. 主机列表或图表卡片;右侧按需展开编辑或详情,与页面顶部对齐,覆盖在内容上方并可拖拽调宽,不改变左侧内容宽度。
|
||||
|
||||
面板多时下拉支持按名称查找。第一版不再给监控面板引入分组、文件夹或嵌套页面。
|
||||
|
||||
### 新建与编辑
|
||||
|
||||
首次无面板时显示“创建第一个监控面板”。新建与编辑使用现有右侧面板风格:名称、可选说明、默认列表/图表、已有主机多选。
|
||||
|
||||
选择器按名称、地址和分组搜索;分组只负责帮助选择,最终保存的是明确的主机 ID 集合。可跨组选主机,显示“已选 N 台”,支持选择筛选结果和清空。名称不能为空、不能与现有面板重名;至少选一台。
|
||||
|
||||
保存只保存配置,不发起连接。新面板进入未开始状态,用户点击“开始监控”才连接。复制只复制配置与主机集合,不复制读数和运行状态;删除只删除面板,并释放它的监控需求,主机和其他面板保留。
|
||||
|
||||
编辑运行中面板时,保存后应用差异:新增成员加入队列,移除成员撤销本面板的需求;重复成员与其他面板继续共用。取消编辑不改变正在运行的名单。
|
||||
|
||||
主机改名或改组后按 ID 跟随;主机删除后显示失效成员并允许移除,不静默替换为同地址的另一条主机。组选中按当时的具体成员保存,未来新加主机不会悄悄加入面板。
|
||||
|
||||
### 列表模式
|
||||
|
||||
每台一行,字段为主机图标/名称/地址、状态、CPU、内存、各分区占用、上传/下载、更新时间。仅提供名称与保存的顺序两种排序,列表和卡片均支持拖拽;拖拽基于当前显示顺序保存并切换为保存的顺序,搜索隐藏的主机仍保留。提供搜索与详情入口。没有“需关注”机制。磁盘进度保留百分比,鼠标悬停时显示完整分区名与已用/总容量,缺失容量不伪造为零。
|
||||
|
||||
列表用于比较,避免每一行都塞完整图表。缺失值显示 `--`;失败时保留旧值但降灰并说明时间;第一次差值采样尚未完成时显示采样中。CPU 与网速未就绪不伪装成 0。
|
||||
|
||||
### 图表模式
|
||||
|
||||
每台一张自动排列的卡片:主机图标/名称/地址/状态、CPU 当前值及核数、内存当前值及容量、CPU/内存短趋势(悬停显示指示线、真实采样值和时间)、总容量最大的两个分区的挂载点和占用条、网络上下行当前速率。卡片高度保持一致,内部以留白和彩色读数分区,不嵌套描边容器或状态标签;列表同样无边框,工具栏控件统一高度。面板说明不在主页面重复展示。
|
||||
|
||||
点击主机打开右侧详情,显示放大的 CPU/内存趋势、网络上传/下载趋势、系统信息和各盘/各网卡读数。详情、编辑与选主机互斥打开,均使用可拖拽调宽的内联侧栏,打开时收窄左侧内容,与其他 Vault 分区保持一致;侧栏标题栏与主内容标题栏同高,保留返回/关闭和固定底部操作。
|
||||
|
||||
两种展示共用主机集合、读数和短趋势。换视图不重新登录、不清曲线、不新建另一份采集。显示偏好按每个面板保存。首版不加入自由拖拽布局和每张图的查询编辑器。
|
||||
|
||||
## 4. 已认可的运行规则
|
||||
|
||||
建议每个面板独立开始/停止。**明确开始的面板在应用运行期间继续采集,切换面板或进入终端不中断。** 不因打开下拉菜单或保存面板便自动开始全部面板。每次明确开始和每次变更采集频率都会清空当前面板的所有旧曲线,从本次操作之后的真实读数重新绘制;不会清除其他面板或终端的共享读数。
|
||||
|
||||
这种持续运行方式随雏形获得用户认可。它与上一稿“离开页面暂停”的规则不同,以本稿为准。研究和雏形阶段没有发起真实主机连接;正式实现须使用可控测试环境验证,不能任意批量登录用户保存的生产主机。
|
||||
|
||||
- 侧栏与顶部持续显示运行面板数;监控页同时显示去重后的主机数并提供“全部停止”。
|
||||
- 停止 A 面板不影响 B 面板;交集主机由仍有效的需求继续使用。
|
||||
- 停止取消本面板的后续采样和排队,释放不再需要的监控用途;正常终端不被断开。
|
||||
- 退出软件结束全部监控。重启只还原面板配置,全部为未开始,不自动恢复后台运行。
|
||||
- Vault 锁定、系统睡眠或网络断开时,不继续用缓存秘密重试登录;展示暂停/断开与缺口,解锁或恢复网络后依既有安全规则处理,不能绕过身份确认。
|
||||
- 需要口令、验证码或主机身份确认时逐台处理,其他主机继续;不要同时弹出一串登录框。
|
||||
|
||||
默认 5 秒更新,菜单提供 3/5/10/30/60 秒,隐藏 1/2 秒选项,内部采样能力不作变更。共享缓存按当前监控的最快需求刷新,不以旧的 4 秒缓存限制 1 秒采集。系统信息、磁盘容量可低频,列表不采完整进程/GPU/每核详情。采集失败有限退避,认证或身份变化等待用户,不循环刷日志。
|
||||
|
||||
## 5. SSH 长连接:复用现有机制的真实边界
|
||||
|
||||
核对基线:Netcatty main `8aeb6ade6fc2b56436442ebe93852cb5dde5c141`。
|
||||
|
||||
### 已有基础
|
||||
|
||||
- [共享连接池](../../electron/bridges/sshConnectionPool.cjs)已支持非终端用途的借用/归还、兼容身份匹配、并发建连合并和空闲保留;[转发使用路径](../../electron/bridges/portForwardingBridge.cjs)说明不必先开终端才能借用连接。
|
||||
- 完整连接身份包含保存主机、地址/端口/账号、路由与代理、认证及信任策略等。不能按 IP 或主机名另做一个简化去重表。
|
||||
- 最后一个用途归还后,普通空闲连接默认可保留 5 分钟;配置为 0 时可保留到退出/主动清理。故“停止监控”应表示停止查询与归还用途,不应许诺物理连接瞬间消失。
|
||||
|
||||
### 需要增加的能力
|
||||
|
||||
- 当前池没有正式 monitor 用途;现有 [统计入口](../../electron/bridges/sshBridge/sessionOps.cjs)仍依赖终端 session。正式实现需要新增监控用途,并把采样上下文与 CPU/网速前次计数从终端会话解耦。不能创建隐藏终端冒充监控,否则可能带来终端启动脚本和其他副作用。
|
||||
- 多面板、详情、终端与窗口对同一兼容身份共用一份采集;只有第一次无可用连接才按既有认证/组继承/跳板/信任检查流程建连。不另造监控专用池,不绕过认证。
|
||||
- 多个订阅频率不同时,底层基础读数按最短有效需求;1 秒等快速监控始终使用轻量采集,进程/GPU 等完整读数只由终端按其自身频率触发,并有独立的 4 秒缓存;例如终端仍要每 5 秒读数,监控面板选择 30 秒不意味着该机器远端查询变为每 30 秒。各面板可按自身频率呈现,实际共享频率需可解释。
|
||||
- 长连接指已认证 SSH 传输保持与复用,现有路径仍定时执行查询。它不自动等于持续推流、无远端命令或零堡垒机审计;改为持续采集进程属于另一个明确决策,本方案不默认引入远端落盘脚本或常驻安装。
|
||||
|
||||
正式支持范围先以现有 Linux/macOS SSH 统计与已验证跳板方式为准。网络设备、Telnet、单通道/受限方式需明确不可用原因。现有 [SelectHostPanel](../../components/SelectHostPanel.tsx)的默认过滤不足以代表这些能力已经全部处理。
|
||||
|
||||
## 6. 保存什么、不保存什么
|
||||
|
||||
| 保存的面板配置 | 只在内存中的运行内容 |
|
||||
| --- | --- |
|
||||
| 面板 ID、名称、说明 | 实时读数和最后成功快照 |
|
||||
| 有序主机 ID 集合 | 趋势点与网速/CPU 差值基线 |
|
||||
| 列表/图表偏好、刷新间隔、趋势范围 | 正在运行的面板集合、连接状态、错误响应 |
|
||||
| 必要的排序和显示偏好 | SSH 连接对象、临时认证状态 |
|
||||
|
||||
配置通过现有 [storageKeys](../../infrastructure/config/storageKeys.ts) 和 [localStorageAdapter](../../infrastructure/persistence/localStorageAdapter.ts) 保存,不复制凭据。按照用户最新要求,必须把面板实体、主机引用、顺序与布局/显示偏好接入现有云同步、导入导出与备份体系。当前这些路径尚无面板字段,正式实现要补齐字段传输、合并、删除传播和旧版本缺字段兼容,不能只增加本地键后宣称同步已完成。多个设备和窗口间的修改不能互相静默覆盖或使已删除面板复活;主机 ID 的解析和失效引用遵循现有主机同步语义。
|
||||
|
||||
实时读数、内存曲线、登录连接与“正在运行”状态不进入同步载荷。其他设备收到面板配置后以未开始状态呈现,不因一次云端配置拉取自动批量登录主机。
|
||||
|
||||
监控读数不写 localStorage、IndexedDB、文件、缓存、备份、同步或常规日志;不通过“页面状态恢复”间接带出采样。诊断仅记录必要的状态与计数,不记录采样正文。
|
||||
|
||||
短趋势建议每个唯一采集身份一份有上限的内存缓冲,保留最近 30 分钟、最多 1800 点,监控最快 1 秒一点。多个面板不各存一份。按时间和数量同时裁剪,失去需求后有限期回收;程序退出全部清空。
|
||||
|
||||
因此只提供最近 5/15/30 分钟的观察范围,不提供昨天/过去一周等历史查询。实际采样不足所选范围时显示已采时长,断网或暂停期间保留缺口,不补 0、不虚构连贯趋势。100 台 × 1,800 点 × 六个 8 字节数字约 8.6MB 只是原始数字下限,实际对象和渲染占用仍要实测。
|
||||
|
||||
## 7. 雏形与本次验证
|
||||
|
||||
独立雏形位于本对话的可视化输出目录 `vault-monitor-panels`,以独立本地预览运行;未把监控功能接入主仓库。使用现有 AppLogo、AppWordmark 和 Lucide 图标,参考实际运行的 Vault 浅色界面。所有主机、状态和曲线都是示例;“打开终端”只说明正式功能的去向,验证码只演示逐台恢复。
|
||||
|
||||
已在浏览器检查:
|
||||
|
||||
- 初始未开始;开始后图表、计数和异常状态正确;列表/图表来回切换。
|
||||
- 跨组选两台主机,创建新面板后未自动开始;开始第二面板后按唯一主机数计数。
|
||||
- 离开监控栏目仍有运行数量提示;回来保留运行状态。
|
||||
- 编辑改名、复制配置但不复制运行状态、删除面板不删其他配置。
|
||||
- 删除到零个面板后,空状态仍能创建;空名称和未选主机有提示。
|
||||
- 重开页面保留配置,运行状态与读数清空。
|
||||
- 逐台演示登录恢复、过期数据提示、重试和网络详情。
|
||||
- 桌面、小窗口、窄窗口及深色模式;修复了编辑侧栏底部按钮需要滚到页面底部才能看到的问题。
|
||||
- 浏览器错误/警告检查与独立雏形构建。
|
||||
|
||||
这些证明雏形可用于审阅流程,不证明新的真实 SSH 采集、复用、审计或内存占用已经验收。首次开始后的预填短趋势是用于展示效果的示例数据,正式功能不能回填并不存在的历史。
|
||||
|
||||
## 8. 用户认可后,正式实现的交付门槛
|
||||
|
||||
1. 另开 thread/worktree,先确认 issue 与 PR 关系,按本稿确认的范围实现,不与暂缓的私人分组锁定混合。
|
||||
2. 无终端的新监控连接、已有终端/SFTP/转发连接的复用、同身份并发建连都走真实路径;账号、密钥、代理或信任策略不同绝不错误共享。
|
||||
3. 两面板、两窗口、列表/详情/终端同时查看,同机只采一份;切视图不重连,停一个面板不影响其他用途,退出后没有监控遗留。
|
||||
4. 点击停止时连接中/排队中/采集中分别验证:不再补发后续查询;连接按池规则保留或关闭,终端继续能输入。
|
||||
5. 核验读数绝无落盘或同步路径;重启只有配置没有曲线与运行状态;配置跨窗口编辑不会互相覆盖。两端真实同步或代表性端到端路径覆盖创建、改名、增删主机、改布局、删除面板、旧载荷缺字段及导入导出恢复;对既有同步方式的影响逐项核对。
|
||||
6. 真实主机与至少一个代表堡垒机测查询与审计量;测试慢主机、首次采样、睡眠、断网、认证、主机删除和凭据变更。
|
||||
7. 对代表性的主机规模测试内存、重绘与连接成本;不以原始数字估算替代运行结果。
|
||||
8. 实际运行 Netcatty 走完整新建/编辑/切换/启停/详情流程;检查设计系统、Vault 表单交互、亮暗主题和窄窗口,切换语言验证状态、错误、复数和长文本;确认系统探测复用已有真实路径,再按已有 PR 审查与合并标准交付。
|
||||
|
||||
## 9. Implementation acceptance checklist
|
||||
|
||||
- [x] Named panels: create/edit/copy/delete, ordered cross-group host references, list/cards, search/sort/attention, details and terminal action.
|
||||
- [x] Existing Vault components/themes/forms; every visible addition translated for all five locales.
|
||||
- [x] Config only: local storage, concurrent window field edits, cloud merge/deletions/legacy payloads, JSON import/export and protective backups.
|
||||
- [x] Shared SSH pool, full identity, joined dials, explicit monitor lease, no shell or startup commands; serialized authentication.
|
||||
- [x] One collection/baseline/history per compatible transport across panels/windows/terminal; cancellation releases only owned demand.
|
||||
- [x] Memory-only samples/status/errors; bounded sparse trends, honest first samples, stale readings and unsupported targets.
|
||||
- [x] Lock/sleep/offline/close/quit cleanup; restart restores stopped configuration only.
|
||||
- [x] Controlled SSH integration, cancellation/reuse and representative scale checks; real Electron complete flows, themes, narrow windows, languages.
|
||||
- [ ] Focused and required checks, independent current-head reviews, exact-head remote review, CI and review-thread gates; verify after merge.
|
||||
|
||||
## 10. Implementation notes (2026-10-17)
|
||||
|
||||
- Panel configuration is normalized through an allowlist. It participates in the existing three-way and convergent sync collections, deletion records, auto-sync change notifications, Vault JSON export/import and protective backups. Prepared cloud applies rebase intervening window edits; old payloads without the field preserve local panels.
|
||||
- Monitoring runs in the terminal worker, using the existing SSH registry's full identity and joined dials, with an explicit `monitor` lease. Authentication reuses the existing SSH preparation, host-key, keyboard-interactive and jump-host paths. No terminal session or shell is created for monitoring.
|
||||
- Ordinary terminal opens retain their existing fresh-login policy. If a terminal already exists, monitoring borrows its compatible transport. Distinct transports with the exact same normalized identity share the metrics cache and baseline too, so a required fresh terminal login does not duplicate collection.
|
||||
- Shared collection/history is volatile. Each history keeps at most 1800 points from the last 30 minutes, sampled no faster than every second. At most 256 unique monitoring identities run at once; the metrics identity cache is bounded to 512 entries and periodically prunes expired histories. No readings, errors, running intent or credentials enter the panel storage shape. Renderer polling transfers only new history points and omits unchanged host stats using per-history sample cursors; fresh samples and connection changes include their complete current stats. Trend geometry is memoized across unchanged samples (including after panel resets), reduces dense runs to per-column extrema, and advances idle viewports on a shared five-second clock. Hover still reads the original samples; missing samples and cadence gaps remain separate runs.
|
||||
- Known network devices are rejected using the existing renderer banner rules, extracted into one shared module. Recognized single-channel peers are rejected before an extra command runs. Monitor-only samples omit process and GPU queries. Terminal readers share a separate four-second complete-sample cache; monitor readers continue collecting lightweight samples. The same existing Linux/macOS parser, disk handling, CPU deltas and network deltas serve terminal and monitoring readers.
|
||||
- A real macOS shell exercise found an existing cooked-template escape in the disk awk expression. It now uses a literal prefix comparison, with a generated-command regression test; the actual monitor reads root/volume capacity correctly.
|
||||
|
||||
Verified so far in an isolated Electron profile and controlled SSH fixture (no saved production host logins): creation, validation, cross-group selection, copy starting stopped, two running panels, navigation retention, direct and jump-host real shell sampling, known-host confirmation, unsupported network devices, sequential verification prompts, failed login details, light/dark and Chinese/narrow rendering. Stopping an in-flight slow query produced no additional queries after 12 seconds; another panel and an ordinary terminal remained usable. Starting monitoring against an existing terminal did not add an authenticated connection. Restart restored two saved panels with zero running panels and zero monitor readings.
|
||||
|
||||
Focused configuration, sync/backup round-trip, concurrent edit, collection, pool, SSH authentication and capability checks pass. Build and directory packaging have run; the packaged archive includes the shared vendor rules and monitoring runtime. The repository-wide TypeScript check has 755 pre-existing errors on both this work and an untouched main snapshot, with no new diagnostics after path/line normalization. The Node 26 full suite also exposes a pre-existing transfer FileHandle cleanup failure, reproduced against untouched main; missing nested plugin dependencies were environment setup and were corrected. The complete suite finished with 13,822 tests: 13,791 passed, 30 skipped and the one reproduced baseline FileHandle failure. Follow-up review fixes have focused regression coverage. Current-head review and merge checks are recorded in the PR.
|
||||
|
||||
|
||||
Additional acceptance evidence:
|
||||
|
||||
- Actual two-window execution: both owners watched the same two supported identities with two running panels and only two unique monitoring entries. A stale editor in the second window changed description and host order after the first renamed the panel; all three edits survived. Stopping and then closing the second owner left the first live. Independent tests cover owner closure during active and queued authentication, credential-changing and same-identity retry, and stop during post-authentication work.
|
||||
- Actual UI actions also exercised saved ordering, name sorting, search, attention filtering, duplicate creation starting stopped, confirmed deletion retaining all saved hosts, and JSON restore. Clear All Local Data removes panels. An offline event stops all monitoring; restore recreates five saved panels with zero running panels.
|
||||
- Each of the five locales was rendered in Electron with no untranslated monitoring keys or document overflow. Light/dark and 760px width were checked; the editor's Save button remained inside the viewport.
|
||||
- A controlled SSH fixture supplied 100 distinct identities over real connections. All 100 produced their first readings in 876ms; 100 cards rendered (two trend charts each), with a measured renderer heap increase of 6.69MiB. The fixture recorded 100 successful authentications, 100 collection commands plus capability/disk work, and zero shells for this exercise. This tests fixture throughput and rendering, not a production fleet's network latency.
|
||||
- Accelerated in-memory history exercise through the production sampler retained exactly 18,000 points for 100 identities, with a measured heap increase of 1.92MiB after collection. This measures the real bounded object representation; it does not claim 30 minutes of production observation.
|
||||
- Last successful readings and their timestamp survive sampling failure; missing measurements remain null. A failed host does not block reuse and collection on an already-authenticated host. Capacity is applied after obsolete demand is removed, including replacing a host at the 256-identity bound.
|
||||
|
||||
### Fast-cadence transport validation
|
||||
|
||||
Renderer polling sends a per-host timestamp cursor. Worker snapshots send only newer points after the first full snapshot; renderer memory merges, deduplicates and bounds history to 1,800 points and 30 minutes. Endpoint history identities travel with cursors so replacing a host connection cannot merge readings from different endpoints. Worker/runtime changes replace history, and older responses cannot overwrite newer state. A 256-host test with full retained histories transfers only 256 new points per polling round, below one percent of the full-history payload. Full terminal stats keep their own four-second cache; one-second monitor requests remain lightweight.
|
||||
@@ -52,7 +52,6 @@ export const CONVERGENT_ENTITY_COLLECTIONS = [
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
] as const satisfies readonly CloudSyncPayloadEntityKey[];
|
||||
|
||||
export const CONVERGENT_STRING_COLLECTIONS = [
|
||||
@@ -226,7 +225,6 @@ export function materializeSyncPayloadFromConvergentState(
|
||||
keys: typedCollection<import('../models').SSHKey>(materialized.collections, 'keys'),
|
||||
identities: typedCollection<import('../models').Identity>(materialized.collections, 'identities'),
|
||||
proxyProfiles: typedCollection<import('../models').ProxyProfile>(materialized.collections, 'proxyProfiles'),
|
||||
monitorPanels: typedCollection<import('../monitorPanels').MonitorPanel>(materialized.collections, 'monitorPanels'),
|
||||
snippets: typedCollection<import('../models').Snippet>(materialized.collections, 'snippets'),
|
||||
customGroups: materialized.stringCollections.customGroups ?? [],
|
||||
snippetPackages: materialized.stringCollections.snippetPackages ?? [],
|
||||
|
||||
+48
-2
@@ -1,5 +1,3 @@
|
||||
import { detectVendorFromSshVersion } from './sshServerVendor.mjs';
|
||||
export { detectVendorFromSshVersion } from './sshServerVendor.mjs';
|
||||
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
|
||||
import { Host, Snippet, TerminalSettings } from './models';
|
||||
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
|
||||
@@ -154,7 +152,55 @@ export const normalizeDistroId = (value?: string) => {
|
||||
* Arista EOS, all of which either are POSIX systems or present as
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
if (/^Cisco[-_]/i.test(s)) return 'cisco';
|
||||
if (/^CiscoIOS_/i.test(s)) return 'cisco';
|
||||
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
|
||||
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
|
||||
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
|
||||
// to a specific vendor icon from the banner alone. Users who want a
|
||||
// custom icon for such devices can set one via the Host Details
|
||||
// manual distro override. The stats-polling gate is still handled
|
||||
// correctly via `host.deviceType === 'network'`.
|
||||
|
||||
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
|
||||
// by the fallback failure-counter path in useServerStats).
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
// H3C / 3Com Comware switches
|
||||
if (/^H3C[-_\s]/i.test(s)) return 'h3c';
|
||||
if (/^Comware-/i.test(s)) return 'h3c';
|
||||
if (/^3Com\s*OS/i.test(s)) return 'h3c';
|
||||
|
||||
// HPE iLO
|
||||
if (/^mpSSH_/i.test(s)) return 'hpe';
|
||||
|
||||
// MikroTik RouterOS
|
||||
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
|
||||
|
||||
// Fortinet FortiOS / FortiGate
|
||||
if (/^FortiSSH_/i.test(s)) return 'fortinet';
|
||||
|
||||
// Palo Alto Networks PAN-OS
|
||||
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
|
||||
|
||||
// ZyXEL ZyWALL
|
||||
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
|
||||
|
||||
// Ruijie RGOS
|
||||
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a distro/vendor ID into a high-level device class. Features that
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "./monitorChart";
|
||||
import type { MonitorPoint } from "./monitorRuntime";
|
||||
|
||||
const points: MonitorPoint[] = [
|
||||
{ at: 1000, cpu: 12.5, memory: 40.123, disk: null, rx: null, tx: null },
|
||||
{ at: 2000, cpu: null, memory: 50, disk: null, rx: null, tx: null },
|
||||
{ at: 3000, cpu: 80, memory: null, disk: null, rx: null, tx: null },
|
||||
];
|
||||
test("chart hit testing returns the exact sampled value and timestamp", () => {
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", 2900, 300), points[2]);
|
||||
assert.equal(findMonitorChartPoint(points, "memory", 1100, 300), points[0]);
|
||||
});
|
||||
test("chart hit testing does not fabricate readings in missing or distant history", () => {
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", 2000, 300), null);
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", -5000, 300), null);
|
||||
assert.equal(findMonitorChartPoint([], "cpu", 1000, 300), null);
|
||||
});
|
||||
|
||||
test("full histories fit the chart budget while extrema and raw hover samples survive", () => {
|
||||
const end = 1800000;
|
||||
const samples = Array.from({ length: 1800 }, (_, i) => ({ ...points[0], at: (i + 1) * 1000, cpu: 50 }));
|
||||
samples[700].cpu = 100;
|
||||
samples[701].cpu = 0;
|
||||
const geometry = buildMonitorTrendGeometry(samples, "cpu", 30, 1, end);
|
||||
assert.equal(geometry.segments.length, 1);
|
||||
const path = geometry.segments[0].path;
|
||||
assert.ok(path.split(/ [ML] /).length <= 302);
|
||||
assert.ok(path.includes(" 4.00"));
|
||||
assert.ok(path.includes(" 58.00"));
|
||||
assert.equal(geometry.visible.length, 1800);
|
||||
assert.equal(findMonitorChartPoint(geometry.visible, "cpu", samples[699].at, 0), samples[699]);
|
||||
assert.equal(samples.length, 1800);
|
||||
});
|
||||
|
||||
test("chart reduction preserves missing samples and cadence gaps", () => {
|
||||
const geometry = buildMonitorTrendGeometry([
|
||||
...points,
|
||||
{ ...points[0], at: 20000 },
|
||||
{ ...points[0], at: 21000, cpu: Infinity },
|
||||
{ ...points[0], at: 22000 },
|
||||
], "cpu", 1, 1, 22000);
|
||||
assert.equal(geometry.segments.length, 4);
|
||||
assert.ok(geometry.segments.every((segment) => !segment.path.includes(" L ")));
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { MonitorPoint } from "./monitorRuntime";
|
||||
|
||||
export type MonitorTrendField = "cpu" | "memory" | "rx" | "tx";
|
||||
|
||||
/** Keep extrema within each two-pixel column; hit testing still uses raw samples. */
|
||||
export function buildMonitorTrendGeometry(
|
||||
points: MonitorPoint[], field: MonitorTrendField, range: number, interval: number, end: number,
|
||||
) {
|
||||
const start = end - range * 60000;
|
||||
const visible = points.filter((p) => p.at >= start && p.at <= end);
|
||||
const max = field === "cpu" || field === "memory" ? 100
|
||||
: Math.max(1, ...visible.map((p) => Number.isFinite(p[field]) ? p[field]! : 0));
|
||||
const runs: MonitorPoint[][] = [];
|
||||
let run: MonitorPoint[] = [];
|
||||
for (const p of visible) {
|
||||
if (p[field] == null || !Number.isFinite(p[field])) {
|
||||
if (run.length) runs.push(run);
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
if (run.length && p.at - run[run.length - 1].at > interval * 2500) {
|
||||
runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
run.push(p);
|
||||
}
|
||||
if (run.length) runs.push(run);
|
||||
const segments = runs.map((samples) => {
|
||||
const selected = new Set([samples[0], samples[samples.length - 1]]);
|
||||
const columns = new Map<number, { min: MonitorPoint; max: MonitorPoint }>();
|
||||
for (const p of samples) {
|
||||
const column = Math.min(149, Math.floor(((p.at - start) / (end - start)) * 150));
|
||||
const extremes = columns.get(column);
|
||||
if (!extremes) columns.set(column, { min: p, max: p });
|
||||
else {
|
||||
if (p[field]! < extremes.min[field]!) extremes.min = p;
|
||||
if (p[field]! > extremes.max[field]!) extremes.max = p;
|
||||
}
|
||||
}
|
||||
for (const extremes of columns.values()) {
|
||||
selected.add(extremes.min);
|
||||
selected.add(extremes.max);
|
||||
}
|
||||
const coordinates = samples.filter((p) => selected.has(p)).map((p) => ({
|
||||
x: Math.max(0, Math.min(300, ((p.at - start) / (end - start)) * 300)),
|
||||
y: 58 - (Math.min(max, Math.max(0, p[field]!)) / max) * 54,
|
||||
}));
|
||||
const last = coordinates[coordinates.length - 1];
|
||||
return {
|
||||
path: coordinates.map((p, i) => `${i ? "L" : "M"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" "),
|
||||
firstX: coordinates[0].x, x: last.x, y: last.y,
|
||||
};
|
||||
});
|
||||
return { start, end, visible, max, segments };
|
||||
}
|
||||
|
||||
/** Hit actual samples within cursor tolerance, never an interpolated reading. */
|
||||
export function findMonitorChartPoint(
|
||||
points: MonitorPoint[],
|
||||
field: MonitorTrendField,
|
||||
at: number,
|
||||
tolerance: number,
|
||||
): MonitorPoint | null {
|
||||
let closest: MonitorPoint | null = null;
|
||||
let distance = tolerance;
|
||||
for (const point of points) {
|
||||
const value = point[field];
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
const delta = Math.abs(point.at - at);
|
||||
if (delta <= distance) {
|
||||
closest = point;
|
||||
distance = delta;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
normalizeMonitorPanels,
|
||||
rebaseMonitorPanel,
|
||||
type MonitorPanel,
|
||||
MONITOR_INTERVALS,
|
||||
reorderMonitorHosts,
|
||||
} from "./monitorPanels";
|
||||
import { mergeSyncPayloads } from "./syncMerge";
|
||||
import type { SyncPayload } from "./sync";
|
||||
import {
|
||||
applyLegacySyncPayload,
|
||||
createConvergentSyncState,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
mergeConvergentSyncStates,
|
||||
} from "./convergentSync";
|
||||
const panel: MonitorPanel = {
|
||||
id: "p1",
|
||||
name: "Production",
|
||||
description: "",
|
||||
hostIds: ["h2", "h1"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
const payload = (panels?: MonitorPanel[]): SyncPayload => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
...(panels === undefined ? {} : { monitorPanels: panels }),
|
||||
});
|
||||
test("drag ordering moves either direction without mutating saved references", () => {
|
||||
const saved = ["c", "a", "b", "hidden"];
|
||||
assert.deepEqual(reorderMonitorHosts(saved, "c", "b"), ["a", "b", "c", "hidden"]);
|
||||
assert.deepEqual(reorderMonitorHosts(saved, "b", "c"), ["b", "c", "a", "hidden"]);
|
||||
assert.deepEqual(saved, ["c", "a", "b", "hidden"]);
|
||||
assert.equal(reorderMonitorHosts(saved, "missing", "b"), saved);
|
||||
assert.equal(reorderMonitorHosts(saved, "a", "a"), saved);
|
||||
});
|
||||
test("monitor intervals include each supported cadence and default to five seconds", () => {
|
||||
assert.deepEqual(MONITOR_INTERVALS, [1, 2, 3, 5, 10, 30, 60]);
|
||||
for (const interval of MONITOR_INTERVALS)
|
||||
assert.equal(normalizeMonitorPanels([{ ...panel, interval }])[0].interval, interval);
|
||||
assert.equal(normalizeMonitorPanels([{ ...panel, interval: undefined }])[0].interval, 5);
|
||||
});
|
||||
test("portable panel sanitizer strips all runtime data and preserves ordered references", () => {
|
||||
const clean = normalizeMonitorPanels([
|
||||
{
|
||||
...panel,
|
||||
hostIds: ["h2", "h1", "h2"],
|
||||
stats: { cpu: 42 },
|
||||
points: [1],
|
||||
running: true,
|
||||
error: "secret",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(clean, [panel]);
|
||||
assert.equal(JSON.stringify(clean).includes("secret"), false);
|
||||
});
|
||||
test("concurrent local edits preserve unrelated fields and deletion does not resurrect", () => {
|
||||
const renamed = { ...panel, name: "New name" };
|
||||
const changed = { ...panel, view: "list" as const, hostIds: ["h3", "h2"] };
|
||||
assert.deepEqual(rebaseMonitorPanel([renamed], panel, changed), [
|
||||
{ ...renamed, ...changed, name: renamed.name },
|
||||
]);
|
||||
assert.throws(() => rebaseMonitorPanel([], panel, changed), /deleted/);
|
||||
assert.throws(
|
||||
() => rebaseMonitorPanel([panel], null, { ...panel, id: "p2" }),
|
||||
/duplicateName/,
|
||||
);
|
||||
});
|
||||
test("three-way sync merges panel fields, preserves legacy omission and propagates deletion", () => {
|
||||
const base = payload([panel]);
|
||||
const local = payload([{ ...panel, name: "Renamed" }]);
|
||||
const remote = payload([{ ...panel, hostIds: ["h3"], view: "list" }]);
|
||||
assert.deepEqual(
|
||||
mergeSyncPayloads(base, local, remote).payload.monitorPanels,
|
||||
[{ ...panel, name: "Renamed", hostIds: ["h3"], view: "list" }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeSyncPayloads(base, base, payload()).payload.monitorPanels,
|
||||
[panel],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeSyncPayloads(base, base, payload([])).payload.monitorPanels,
|
||||
[],
|
||||
);
|
||||
});
|
||||
test("convergent replica carries panel creation, edits and deletion", () => {
|
||||
const empty = payload([]),
|
||||
initial = payload([panel]);
|
||||
const state = applyLegacySyncPayload(
|
||||
createConvergentSyncState(),
|
||||
empty,
|
||||
initial,
|
||||
"a",
|
||||
1,
|
||||
);
|
||||
const left = applyLegacySyncPayload(
|
||||
state,
|
||||
initial,
|
||||
payload([{ ...panel, name: "A" }]),
|
||||
"a",
|
||||
2,
|
||||
);
|
||||
const right = applyLegacySyncPayload(
|
||||
state,
|
||||
initial,
|
||||
payload([{ ...panel, hostIds: ["h3"], view: "list" }]),
|
||||
"b",
|
||||
3,
|
||||
);
|
||||
const joined = mergeConvergentSyncStates(left, right);
|
||||
assert.deepEqual(
|
||||
materializeSyncPayloadFromConvergentState(joined, { syncedAt: 3 })
|
||||
.monitorPanels,
|
||||
[{ ...panel, name: "A", hostIds: ["h3"], view: "list" }],
|
||||
);
|
||||
const removed = applyLegacySyncPayload(
|
||||
joined,
|
||||
materializeSyncPayloadFromConvergentState(joined, { syncedAt: 3 }),
|
||||
empty,
|
||||
"a",
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(
|
||||
materializeSyncPayloadFromConvergentState(
|
||||
mergeConvergentSyncStates(removed, right),
|
||||
{ syncedAt: 4 },
|
||||
).monitorPanels,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("independent same-name panels remain distinct after merge/import regardless of order", () => {
|
||||
const left = { ...panel, id: "a", name: "Fleet" };
|
||||
const right = { ...panel, id: "b", name: "fleet" };
|
||||
const existingSuffix = { ...panel, id: "c", name: "Fleet (2)" };
|
||||
const normalizeById = (panels: MonitorPanel[]) =>
|
||||
normalizeMonitorPanels(panels).sort((a, b) => a.id.localeCompare(b.id));
|
||||
const merged = mergeSyncPayloads(
|
||||
payload([]),
|
||||
payload([left, existingSuffix]),
|
||||
payload([right]),
|
||||
).payload.monitorPanels!;
|
||||
const clean = normalizeById(merged);
|
||||
const empty = payload([]);
|
||||
const l = applyLegacySyncPayload(createConvergentSyncState(), empty, payload([left, existingSuffix]), "a", 1);
|
||||
const r = applyLegacySyncPayload(createConvergentSyncState(), empty, payload([right]), "b", 1);
|
||||
const crdt = materializeSyncPayloadFromConvergentState(mergeConvergentSyncStates(l, r), { syncedAt: 2 });
|
||||
assert.deepEqual(normalizeById(crdt.monitorPanels!), clean);
|
||||
assert.deepEqual(
|
||||
clean.map((p) => [p.id, p.name]),
|
||||
[
|
||||
["a", "Fleet"],
|
||||
["b", "fleet (3)"],
|
||||
["c", "Fleet (2)"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(normalizeById([...merged].reverse()), clean);
|
||||
assert.deepEqual(normalizeById(clean), clean);
|
||||
const long = normalizeMonitorPanels([
|
||||
left,
|
||||
{ ...right, name: "x".repeat(120) },
|
||||
{ ...panel, id: "d", name: "x".repeat(120) },
|
||||
]);
|
||||
assert.ok(long.every((p) => p.name.length <= 120));
|
||||
assert.equal(new Set(long.map((p) => p.name.toLowerCase())).size, 3);
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
export const MONITOR_INTERVALS = [1, 2, 3, 5, 10, 30, 60] as const;
|
||||
export const DEFAULT_MONITOR_INTERVAL = 5;
|
||||
|
||||
/** Portable configuration only. Never add samples, connection state or errors here. */
|
||||
export interface MonitorPanel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
hostIds: string[];
|
||||
view: "list" | "charts";
|
||||
interval: (typeof MONITOR_INTERVALS)[number];
|
||||
range: 5 | 15 | 30;
|
||||
sort: "manual" | "name";
|
||||
}
|
||||
|
||||
export function normalizeMonitorPanels(value: unknown): MonitorPanel[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids = new Set<string>();
|
||||
const panels = value.flatMap((raw): MonitorPanel[] => {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const p = raw as Record<string, unknown>;
|
||||
if (
|
||||
typeof p.id !== "string" ||
|
||||
!p.id ||
|
||||
ids.has(p.id) ||
|
||||
typeof p.name !== "string" ||
|
||||
!p.name.trim()
|
||||
)
|
||||
return [];
|
||||
ids.add(p.id);
|
||||
return [
|
||||
{
|
||||
id: p.id,
|
||||
name: p.name.trim().slice(0, 120),
|
||||
description:
|
||||
typeof p.description === "string" ? p.description.slice(0, 1000) : "",
|
||||
hostIds: Array.isArray(p.hostIds)
|
||||
? [
|
||||
...new Set(
|
||||
p.hostIds.filter(
|
||||
(id): id is string => typeof id === "string" && !!id,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
view: p.view === "charts" ? "charts" : "list",
|
||||
interval: MONITOR_INTERVALS.includes(p.interval as MonitorPanel["interval"])
|
||||
? p.interval as MonitorPanel["interval"]
|
||||
: DEFAULT_MONITOR_INTERVAL,
|
||||
range: p.range === 5 || p.range === 15 ? p.range : 30,
|
||||
sort: p.sort === "name" ? "name" : "manual",
|
||||
},
|
||||
];
|
||||
});
|
||||
// Independent devices can create the same name with different IDs. Keep
|
||||
// every configuration, and choose suffixes by ID so merge order cannot
|
||||
// change which panel is renamed. Reserve existing names before allocating.
|
||||
const reserved = new Set(panels.map((p) => p.name.toLowerCase()));
|
||||
const seenNames = new Set<string>();
|
||||
const namesById = new Map<string, string>();
|
||||
for (const p of [...panels].sort((a, b) =>
|
||||
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||
)) {
|
||||
const key = p.name.toLowerCase();
|
||||
if (!seenNames.has(key)) {
|
||||
seenNames.add(key);
|
||||
continue;
|
||||
}
|
||||
let suffix = 2;
|
||||
let name: string;
|
||||
do {
|
||||
const ending = ` (${suffix++})`;
|
||||
name = p.name.slice(0, 120 - ending.length).trimEnd() + ending;
|
||||
} while (reserved.has(name.toLowerCase()));
|
||||
reserved.add(name.toLowerCase());
|
||||
namesById.set(p.id, name);
|
||||
}
|
||||
return panels.map((p) =>
|
||||
namesById.has(p.id) ? { ...p, name: namesById.get(p.id)! } : p,
|
||||
);
|
||||
}
|
||||
|
||||
/** Move within the displayed ordering, then persist it as the saved ordering. */
|
||||
export function reorderMonitorHosts(hostIds: string[], from: string, to: string): string[] {
|
||||
const fromIndex = hostIds.indexOf(from);
|
||||
const toIndex = hostIds.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return hostIds;
|
||||
const next = [...hostIds];
|
||||
next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, from);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Rebase only edited fields onto the latest disk entity; deletion wins over a stale editor. */
|
||||
export function rebaseMonitorPanel(
|
||||
latest: MonitorPanel[],
|
||||
base: MonitorPanel | null,
|
||||
next: MonitorPanel,
|
||||
): MonitorPanel[] {
|
||||
const current = latest.find((p) => p.id === next.id);
|
||||
if (base && !current) throw new Error("monitor.deleted");
|
||||
const merged = { ...(current ?? next) };
|
||||
for (const field of Object.keys(next) as (keyof MonitorPanel)[]) {
|
||||
if (!base || JSON.stringify(base[field]) !== JSON.stringify(next[field])) {
|
||||
Object.assign(merged, { [field]: next[field] });
|
||||
}
|
||||
}
|
||||
if (
|
||||
latest.some(
|
||||
(p) =>
|
||||
p.id !== merged.id &&
|
||||
p.name.toLowerCase() === merged.name.trim().toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error("monitor.duplicateName");
|
||||
}
|
||||
return normalizeMonitorPanels(
|
||||
current
|
||||
? latest.map((p) => (p.id === next.id ? merged : p))
|
||||
: [...latest, merged],
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mergeMonitorSnapshotHistory, monitorStateSince, type MonitorHostState, type MonitorSnapshot } from "./monitorRuntime";
|
||||
|
||||
const state: MonitorHostState = {
|
||||
historyId: "endpoint-a",
|
||||
status: "live", at: 2000, stats: {
|
||||
cpu: 50, cpuCores: 4, cpuPerCore: [], memTotal: 100, memUsed: 25,
|
||||
memFree: 75, memBuffers: 0, memCached: 0, swapTotal: 0, swapUsed: 0,
|
||||
topProcesses: [], diskPercent: 30, diskUsed: 30, diskTotal: 100, disks: [],
|
||||
netRxSpeed: 10, netTxSpeed: 20, latencyMs: 1, netInterfaces: [],
|
||||
},
|
||||
points: [1000, 2000].map((at) => ({ at, cpu: 50, memory: 25, disk: 30, rx: 10, tx: 20 })),
|
||||
};
|
||||
test("starting or changing cadence clears all old readings and chart fields", () => {
|
||||
const cleared = monitorStateSince(state, 2000)!;
|
||||
assert.deepEqual(cleared.points, []);
|
||||
assert.equal(cleared.stats, null);
|
||||
assert.equal(cleared.at, 0);
|
||||
assert.equal(cleared.status, "queued");
|
||||
assert.equal(monitorStateSince(state, undefined), undefined);
|
||||
});
|
||||
test("only current-run readings are visible without mutating shared history", () => {
|
||||
const fresh = monitorStateSince(state, 1500)!;
|
||||
assert.deepEqual(fresh.points.map((p) => p.at), [2000]);
|
||||
assert.equal(fresh.stats, state.stats);
|
||||
assert.equal(fresh.status, "live");
|
||||
assert.equal(monitorStateSince({ ...state, status: "unsupported" }, 2000)?.status, "unsupported");
|
||||
assert.equal(monitorStateSince(state, 0)?.points.length, 2);
|
||||
assert.equal(state.points.length, 2);
|
||||
assert.equal(monitorStateSince(state, 1500)?.points, fresh.points);
|
||||
assert.equal(monitorStateSince({ ...state }, 1500)?.points, fresh.points);
|
||||
assert.notEqual(monitorStateSince(state, 1000)?.points, fresh.points);
|
||||
});
|
||||
|
||||
const snapshot = (host: MonitorHostState): MonitorSnapshot => ({
|
||||
historyMode: "replace", runtimeId: "worker", epoch: 0, takenAt: 3000,
|
||||
running: ["p"], startedAt: { p: 0 }, count: 1, hostCount: 1, activeSessions: [], hosts: { h: host },
|
||||
});
|
||||
test("incremental snapshots retain history, deduplicate races and replace on worker reset", () => {
|
||||
const original = snapshot(state);
|
||||
const point = { ...state.points[1], at: 3000 };
|
||||
const delta: MonitorSnapshot = { ...snapshot({ ...state, points: [state.points[1], point] }), historyMode: "append", takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(original, delta);
|
||||
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [1000, 2000, 3000]);
|
||||
assert.deepEqual(mergeMonitorSnapshotHistory(merged, delta).hosts.h.points, merged.hosts.h.points);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...original, takenAt: 2000 }), merged);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, epoch: 1 }).hosts.h.points.length, 2);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, historyMode: "replace" }).hosts.h.points.length, 2);
|
||||
assert.equal(original.hosts.h.points.length, 2);
|
||||
});
|
||||
test("incremental history expires after thirty minutes and remains bounded", () => {
|
||||
const points = Array.from({ length: 1800 }, (_, i) => ({ ...state.points[0], at: 10000 + i * 1000 }));
|
||||
const previous = snapshot({ ...state, points });
|
||||
const next = { ...snapshot({ ...state, points: [{ ...points.at(-1)!, at: 1810000 }] }), historyMode: "append" as const, takenAt: 1810000 };
|
||||
const merged = mergeMonitorSnapshotHistory(previous, next);
|
||||
assert.equal(merged.hosts.h.points.length, 1800);
|
||||
assert.equal(merged.hosts.h.points[0].at, 11000);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...next, takenAt: 4000000 }).hosts.h.points.length, 0);
|
||||
});
|
||||
|
||||
test("delayed replies cannot restore a stopped epoch or retired worker", () => {
|
||||
const stopped: MonitorSnapshot = { ...snapshot(state), epoch: 1, takenAt: 4000, running: [], hosts: {}, startedAt: {} };
|
||||
const old = { ...snapshot(state), historyMode: "append" as const };
|
||||
assert.equal(mergeMonitorSnapshotHistory(stopped, old), stopped);
|
||||
assert.equal(mergeMonitorSnapshotHistory(stopped, { ...old, takenAt: 5000 }), stopped);
|
||||
const restarted = { ...stopped, runtimeId: "new-worker", epoch: 0 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(restarted, old), restarted);
|
||||
const fresh = { ...restarted, runtimeId: "next-worker", takenAt: 5000 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(restarted, fresh), fresh);
|
||||
});
|
||||
|
||||
test("a new endpoint replaces retained points even when its first delta races a configuration response", () => {
|
||||
const original = snapshot(state);
|
||||
const changed: MonitorSnapshot = { ...snapshot({ ...state, historyId: "endpoint-b", points: [{ ...state.points[0], at: 2500 }] }), historyMode: "append", takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(original, changed);
|
||||
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [2500]);
|
||||
assert.equal(merged.hosts.h.historyId, "endpoint-b");
|
||||
});
|
||||
|
||||
test("unchanged stats are retained by reference while fresh, cleared and new-endpoint stats replace them", () => {
|
||||
const previous = snapshot(state);
|
||||
const delta = { ...snapshot({ ...state, stats: undefined, points: [] }), historyMode: "append" as const, takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(previous, delta);
|
||||
assert.equal(merged.hosts.h.stats, state.stats);
|
||||
assert.equal(merged.hosts.h.points, state.points);
|
||||
assert.equal(monitorStateSince(merged.hosts.h, 0), merged.hosts.h);
|
||||
assert.deepEqual(merged.hosts.h.points, state.points);
|
||||
const fresh = { ...state.stats!, cpu: 67 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: fresh, points: [] } } }).hosts.h.stats, fresh);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: null, points: [] } } }).hosts.h.stats, null);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, historyId: "endpoint-b", stats: null, points: [] } } }).hosts.h.stats, null);
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { MonitorPanel } from "./monitorPanels";
|
||||
export type MonitorStats = NonNullable<
|
||||
Awaited<ReturnType<NonNullable<NetcattyBridge["getServerStats"]>>>["stats"]
|
||||
> & { networkReady?: boolean };
|
||||
export type MonitorPoint = {
|
||||
at: number;
|
||||
cpu: number | null;
|
||||
memory: number | null;
|
||||
disk: number | null;
|
||||
rx: number | null;
|
||||
tx: number | null;
|
||||
};
|
||||
export type MonitorHostState = {
|
||||
historyId: string;
|
||||
status:
|
||||
| "queued"
|
||||
| "connecting"
|
||||
| "live"
|
||||
| "login"
|
||||
| "error"
|
||||
| "unsupported"
|
||||
| "disconnected"
|
||||
| "limit";
|
||||
at: number;
|
||||
/** Omitted by incremental replies when the receiver already has this sample. */
|
||||
stats?: MonitorStats | null;
|
||||
points: MonitorPoint[];
|
||||
};
|
||||
export type MonitorSnapshot = {
|
||||
historyMode: "replace" | "append";
|
||||
running: string[];
|
||||
count: number;
|
||||
hostCount: number;
|
||||
epoch: number;
|
||||
runtimeId: string;
|
||||
takenAt: number;
|
||||
activeSessions: string[];
|
||||
startedAt: Record<string, number>;
|
||||
unavailable?: boolean;
|
||||
hosts: Record<string, MonitorHostState>;
|
||||
};
|
||||
|
||||
export type MonitorHistoryCursor = {
|
||||
runtimeId: string;
|
||||
epoch: number;
|
||||
after: Record<string, { at: number; statsAt: number; historyId: string }>;
|
||||
};
|
||||
|
||||
/** Incremental transport keeps the full bounded history in the renderer. */
|
||||
export function mergeMonitorSnapshotHistory(previous: MonitorSnapshot, next: MonitorSnapshot): MonitorSnapshot {
|
||||
if (previous.runtimeId !== next.runtimeId) {
|
||||
// An old worker reply can still be in transit after its replacement starts.
|
||||
if (previous.runtimeId && next.runtimeId && next.takenAt <= previous.takenAt) return previous;
|
||||
return next;
|
||||
}
|
||||
if (next.epoch < previous.epoch) return previous;
|
||||
if (next.epoch > previous.epoch) return next;
|
||||
if (next.takenAt < previous.takenAt) return previous;
|
||||
if (next.historyMode === "replace") return next;
|
||||
return {
|
||||
...next,
|
||||
hosts: Object.fromEntries(Object.entries(next.hosts).map(([id, host]) => {
|
||||
if (previous.hosts[id]?.historyId !== host.historyId) return [id, host];
|
||||
const retained = previous.hosts[id]?.points ?? [];
|
||||
const last = retained.at(-1)?.at ?? 0;
|
||||
const added = host.points.filter((point) => point.at > last);
|
||||
const points = added.length || (retained[0] && next.takenAt - retained[0].at > 30 * 60000)
|
||||
? [...retained, ...added].filter((point) => next.takenAt - point.at <= 30 * 60000).slice(-1800)
|
||||
: retained;
|
||||
return [id, { ...host, stats: host.stats === undefined ? previous.hosts[id]?.stats : host.stats,
|
||||
points }];
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const filteredHistories = new WeakMap<MonitorPoint[], Map<number, MonitorPoint[]>>();
|
||||
function pointsSince(points: MonitorPoint[], startedAt: number) {
|
||||
if (!points.length || points[0].at > startedAt) return points;
|
||||
let cache = filteredHistories.get(points);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
filteredHistories.set(points, cache);
|
||||
}
|
||||
let filtered = cache.get(startedAt);
|
||||
if (!filtered) {
|
||||
filtered = points.filter((point) => point.at > startedAt);
|
||||
cache.set(startedAt, filtered);
|
||||
if (cache.size > 32) cache.delete(cache.keys().next().value!);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/** A panel only displays samples collected during its current cadence/run. */
|
||||
export function monitorStateSince(state: MonitorHostState | undefined, startedAt: number | undefined): MonitorHostState | undefined {
|
||||
if (!state || typeof startedAt !== "number") return undefined;
|
||||
const fresh = state.at > startedAt;
|
||||
if (fresh && (!state.points.length || state.points[0].at > startedAt)) return state;
|
||||
return {
|
||||
...state,
|
||||
at: fresh ? state.at : 0,
|
||||
stats: fresh ? state.stats : null,
|
||||
status: !fresh && state.status === "live" ? "queued" : state.status,
|
||||
points: pointsSince(state.points, startedAt),
|
||||
};
|
||||
}
|
||||
export type MonitorPanelDemand = {
|
||||
id: string;
|
||||
runId: string;
|
||||
interval: MonitorPanel["interval"];
|
||||
hosts: {
|
||||
id: string;
|
||||
options?: NetcattySSHOptions;
|
||||
status?: "login" | "error";
|
||||
}[];
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export function detectVendorFromSshVersion(softwareVersion?: string): '' | import('./host').NetworkDeviceVendor;
|
||||
@@ -1,50 +0,0 @@
|
||||
// Shared banner rules for renderer capabilities and headless SSH monitoring.
|
||||
export const detectVendorFromSshVersion = (softwareVersion) => {
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
if (/^Cisco[-_]/i.test(s)) return 'cisco';
|
||||
if (/^CiscoIOS_/i.test(s)) return 'cisco';
|
||||
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
|
||||
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
|
||||
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
|
||||
// to a specific vendor icon from the banner alone. Users who want a
|
||||
// custom icon for such devices can set one via the Host Details
|
||||
// manual distro override. The stats-polling gate is still handled
|
||||
// correctly via `host.deviceType === 'network'`.
|
||||
|
||||
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
|
||||
// by the fallback failure-counter path in useServerStats).
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
// H3C / 3Com Comware switches
|
||||
if (/^H3C[-_\s]/i.test(s)) return 'h3c';
|
||||
if (/^Comware-/i.test(s)) return 'h3c';
|
||||
if (/^3Com\s*OS/i.test(s)) return 'h3c';
|
||||
|
||||
// HPE iLO
|
||||
if (/^mpSSH_/i.test(s)) return 'hpe';
|
||||
|
||||
// MikroTik RouterOS
|
||||
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
|
||||
|
||||
// Fortinet FortiOS / FortiGate
|
||||
if (/^FortiSSH_/i.test(s)) return 'fortinet';
|
||||
|
||||
// Palo Alto Networks PAN-OS
|
||||
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
|
||||
|
||||
// ZyXEL ZyWALL
|
||||
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
|
||||
|
||||
// Ruijie RGOS
|
||||
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -282,8 +282,6 @@ export interface SyncPayload {
|
||||
notes?: import('./models').VaultNote[];
|
||||
noteGroups?: string[];
|
||||
|
||||
monitorPanels?: import('./monitorPanels').MonitorPanel[];
|
||||
|
||||
// Group configs (connection defaults per host group)
|
||||
groupConfigs?: import('./models').GroupConfig[];
|
||||
|
||||
@@ -485,7 +483,6 @@ export const SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'browserBookmarks',
|
||||
'knownHosts',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
] as const;
|
||||
|
||||
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
@@ -501,7 +498,6 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
'aiSessions',
|
||||
] as const;
|
||||
|
||||
|
||||
+7
-23
@@ -71,8 +71,6 @@ export interface MergeSyncPayloadsOptions {
|
||||
|
||||
interface EntityMergeOptions {
|
||||
preserveDeletions?: boolean;
|
||||
mergeFields?: boolean;
|
||||
deleteWins?: boolean;
|
||||
}
|
||||
|
||||
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
|
||||
@@ -84,7 +82,6 @@ const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
'aiSessions',
|
||||
]);
|
||||
|
||||
@@ -202,20 +199,10 @@ function mergeEntityArrays<T extends { id: string }>(
|
||||
merged.push(remoteItem);
|
||||
modified.remote++;
|
||||
} else {
|
||||
if (options?.mergeFields) {
|
||||
const combined = { ...remoteItem };
|
||||
let conflicted = false;
|
||||
for (const field of Object.keys(localItem) as (keyof T)[]) {
|
||||
const localChanged = fingerprint(localItem[field]) !== fingerprint(baseItem[field]);
|
||||
const remoteChanged = fingerprint(remoteItem[field]) !== fingerprint(baseItem[field]);
|
||||
if (localChanged) combined[field] = localItem[field];
|
||||
if (localChanged && remoteChanged && fingerprint(localItem[field]) !== fingerprint(remoteItem[field])) conflicted = true;
|
||||
}
|
||||
merged.push(combined);
|
||||
if (conflicted) conflicts++;
|
||||
} else {
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) conflicts++;
|
||||
// Both changed — prefer local
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
conflicts++;
|
||||
}
|
||||
modified.local++;
|
||||
modified.remote++;
|
||||
@@ -223,7 +210,7 @@ function mergeEntityArrays<T extends { id: string }>(
|
||||
} else if (inBase && !inLocal && inRemote) {
|
||||
// Local deleted
|
||||
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
|
||||
if (remoteChanged && !options?.deleteWins) {
|
||||
if (remoteChanged) {
|
||||
// Remote modified + local deleted → keep modification (safer)
|
||||
merged.push(remoteItem);
|
||||
conflicts++;
|
||||
@@ -239,7 +226,7 @@ function mergeEntityArrays<T extends { id: string }>(
|
||||
} else if (inBase && inLocal && !inRemote) {
|
||||
// Remote deleted
|
||||
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
|
||||
if (localChanged && !options?.deleteWins) {
|
||||
if (localChanged) {
|
||||
// Local modified + remote deleted → keep modification (safer)
|
||||
merged.push(localItem);
|
||||
conflicts++;
|
||||
@@ -857,8 +844,6 @@ export function mergeSyncPayloads(
|
||||
mergeOptions,
|
||||
);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? [], tombstones('snippets'), mergeOptions);
|
||||
const baseMonitorPanels = b.monitorPanels ?? [];
|
||||
const monitorPanels = mergeEntityArrays(baseMonitorPanels, entityArray(local, 'monitorPanels', baseMonitorPanels), entityArray(remote, 'monitorPanels', baseMonitorPanels), tombstones('monitorPanels'), { ...mergeOptions, mergeFields: true, deleteWins: true });
|
||||
const baseNotes = b.notes ?? [];
|
||||
const notes = mergeEntityArrays(
|
||||
baseNotes,
|
||||
@@ -910,7 +895,7 @@ export function mergeSyncPayloads(
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
|
||||
[hosts, keys, identities, proxyProfiles, snippets, monitorPanels, notes, portForwardingRules, browserBookmarks, groupConfigsResult, aiSessions];
|
||||
[hosts, keys, identities, proxyProfiles, snippets, notes, portForwardingRules, browserBookmarks, groupConfigsResult, aiSessions];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
@@ -1001,7 +986,6 @@ export function mergeSyncPayloads(
|
||||
identities: identities.merged,
|
||||
proxyProfiles: proxyProfiles.merged,
|
||||
snippets: snippets.merged,
|
||||
monitorPanels: monitorPanels.merged,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes: notes.merged,
|
||||
|
||||
@@ -26,7 +26,6 @@ const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
'aiSessions',
|
||||
]);
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ module.exports = {
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.mjs',
|
||||
'domain/sshServerVendor.mjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -265,11 +265,6 @@ const registerHandler = (ipcMain) => {
|
||||
};
|
||||
|
||||
const getRequests = () => hostKeyRequests;
|
||||
const cancelRequestsForSession = (sessionId) => {
|
||||
for (const [id, pending] of hostKeyRequests) {
|
||||
if (pending.sessionId === sessionId) settleRequest(id, { accept: false, cancelled: true });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
classifyHostKey,
|
||||
@@ -281,5 +276,4 @@ module.exports = {
|
||||
registerHandler,
|
||||
requestHostKeyVerification,
|
||||
getRequests,
|
||||
cancelRequestsForSession,
|
||||
};
|
||||
@@ -1,434 +0,0 @@
|
||||
"use strict";
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const pool = require("./sshConnectionPool.cjs");
|
||||
const {
|
||||
readSharedStats,
|
||||
cancelSharedStats,
|
||||
} = require("./sshBridge/sharedStats.cjs");
|
||||
|
||||
/** Worker-owned volatile demand graph. The SSH registry remains the only connection pool. */
|
||||
function createMonitorService({
|
||||
startConnection,
|
||||
cancelConnection,
|
||||
getStats,
|
||||
prepareOptions,
|
||||
now = Date.now,
|
||||
timers = globalThis,
|
||||
}) {
|
||||
const panels = new Map();
|
||||
const entries = new Map();
|
||||
const revisions = new Map();
|
||||
const retries = new Map();
|
||||
let dialTail = Promise.resolve();
|
||||
let disposed = false;
|
||||
let epoch = 0;
|
||||
const runtimeId = randomUUID();
|
||||
function keyFor(options) {
|
||||
return pool.buildEndpointKey(pool.buildConnectionReuseEndpoint(options));
|
||||
}
|
||||
function publishState(entry, status) {
|
||||
entry.status = status;
|
||||
}
|
||||
function release(entry) {
|
||||
entry.cancelled = true;
|
||||
entry.controller.abort();
|
||||
if (entry.timer) timers.clearTimeout(entry.timer);
|
||||
if (entry.connecting) cancelConnection(entry.sessionId);
|
||||
if (entry.transport) {
|
||||
const identity = pool.buildEndpointKey(entry.transport.endpoint);
|
||||
const stillNeeded = [...entries.values()].some(
|
||||
(other) =>
|
||||
other !== entry &&
|
||||
!other.cancelled &&
|
||||
other.transport &&
|
||||
(other.transport.conn === entry.transport.conn ||
|
||||
pool.buildEndpointKey(other.transport.endpoint) === identity),
|
||||
);
|
||||
if (!stillNeeded) cancelSharedStats(entry.transport.conn);
|
||||
pool.returnTransport(entry.holder);
|
||||
}
|
||||
entries.delete(entry.key);
|
||||
}
|
||||
function intervalFor(entry) {
|
||||
return Math.min(
|
||||
...[...panels.values()]
|
||||
.filter((p) => p.keys.includes(entry.key))
|
||||
.map((p) => p.interval),
|
||||
);
|
||||
}
|
||||
async function collect(entry) {
|
||||
if (entry.cancelled || !entry.transport || entry.sampling) return;
|
||||
entry.sampling = true;
|
||||
if (
|
||||
entry.transport.state === "dead" ||
|
||||
entry.transport.state === "closing"
|
||||
) {
|
||||
entry.sampling = false;
|
||||
publishState(entry, "disconnected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await getStats({
|
||||
conn: entry.transport.conn,
|
||||
hostname: entry.hostname,
|
||||
_monitorStats: true,
|
||||
_monitorIntervalMs: intervalFor(entry) * 1000,
|
||||
_monitorFreshAfter: Math.max(...[...panels.values()].filter((panel) => panel.keys.includes(entry.key)).map((panel) => panel.startedAt)),
|
||||
_reuseEndpoint: entry.transport.endpoint,
|
||||
});
|
||||
if (entry.cancelled) return;
|
||||
if (result.success) {
|
||||
entry.failures = 0;
|
||||
publishState(entry, "live");
|
||||
} else {
|
||||
entry.failures++;
|
||||
publishState(
|
||||
entry,
|
||||
/not supported|unsupported|parse/i.test(result.error ?? "")
|
||||
? "unsupported"
|
||||
: "error",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!entry.cancelled) {
|
||||
entry.failures++;
|
||||
publishState(entry, "error");
|
||||
}
|
||||
}
|
||||
entry.sampling = false;
|
||||
if (!entry.cancelled && entry.status !== "unsupported") {
|
||||
const delay =
|
||||
intervalFor(entry) * 1000 * Math.min(6, 2 ** entry.failures);
|
||||
entry.due = now() + delay;
|
||||
entry.timer = timers.setTimeout(() => {
|
||||
entry.timer = null;
|
||||
void collect(entry);
|
||||
}, delay);
|
||||
entry.timer?.unref?.();
|
||||
}
|
||||
}
|
||||
async function attachTransport(entry, transport) {
|
||||
if (entry.cancelled || disposed) {
|
||||
const holder = {};
|
||||
pool.borrowTransport(transport, {
|
||||
kind: pool.LEASE_KINDS.monitor,
|
||||
holder,
|
||||
});
|
||||
pool.returnTransport(holder);
|
||||
return;
|
||||
}
|
||||
// Borrow before awaiting module loading: stop can always find this lease.
|
||||
entry.transport = transport;
|
||||
pool.borrowTransport(transport, {
|
||||
kind: pool.LEASE_KINDS.monitor,
|
||||
holder: entry.holder,
|
||||
});
|
||||
entry.connecting = false;
|
||||
const { detectVendorFromSshVersion } =
|
||||
await import("../../domain/sshServerVendor.mjs");
|
||||
if (entry.cancelled || disposed) return;
|
||||
if (
|
||||
detectVendorFromSshVersion(transport.conn?._remoteVer) ||
|
||||
require("../../domain/singleChannelSshBanner.shared.cjs").remoteSoftwareRequiresSingleChannel(
|
||||
transport.conn?._remoteVer,
|
||||
)
|
||||
) {
|
||||
pool.returnTransport(entry.holder);
|
||||
entry.transport = null;
|
||||
publishState(entry, "unsupported");
|
||||
return;
|
||||
}
|
||||
entry.ready = true;
|
||||
void collect(entry);
|
||||
}
|
||||
function connect(entry, event, options) {
|
||||
const reusable = pool.findTransportByEndpoint(
|
||||
pool.buildConnectionReuseEndpoint(options),
|
||||
);
|
||||
if (reusable) {
|
||||
void attachTransport(entry, reusable).catch(() => {
|
||||
if (!entry.cancelled) publishState(entry, "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
// All new authentications are serialized. Shared transports need no prompt.
|
||||
dialTail = dialTail
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
if (entry.cancelled || disposed) return;
|
||||
let coordination;
|
||||
try {
|
||||
publishState(entry, "connecting");
|
||||
entry.connecting = true;
|
||||
coordination = pool.beginTransportDial(
|
||||
pool.buildConnectionReuseEndpoint(options),
|
||||
{ kind: "channel" },
|
||||
);
|
||||
let transport;
|
||||
if (coordination.role === "reuse") transport = coordination.transport;
|
||||
else if (coordination.role === "join")
|
||||
transport = await pool.waitForTransportDial(coordination, {
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
else {
|
||||
const result = await startConnection(event, {
|
||||
...options,
|
||||
sessionId: entry.sessionId,
|
||||
reuseTransport: false,
|
||||
_monitorMode: true,
|
||||
sshDebugLogEnabled: false,
|
||||
_passphraseSignal: entry.controller.signal,
|
||||
});
|
||||
transport = result.transport;
|
||||
if (!transport)
|
||||
throw new Error(
|
||||
"Monitor authentication did not produce a transport",
|
||||
);
|
||||
pool.completeTransportDial(coordination, transport);
|
||||
}
|
||||
await attachTransport(entry, transport);
|
||||
} catch (error) {
|
||||
if (coordination?.role === "leader")
|
||||
pool.failTransportDial(coordination, error);
|
||||
entry.connecting = false;
|
||||
if (!entry.cancelled) publishState(entry, "login");
|
||||
}
|
||||
});
|
||||
}
|
||||
function reconcile(event, input) {
|
||||
if (!prepareOptions) return reconcilePrepared(event, input);
|
||||
const owner = event.sender.id;
|
||||
const revision = (revisions.get(owner) ?? 0) + 1;
|
||||
revisions.set(owner, revision);
|
||||
return Promise.all(
|
||||
input.map(async (panel) => ({
|
||||
...panel,
|
||||
hosts: await Promise.all(
|
||||
panel.hosts.map(async (host) => ({
|
||||
...host,
|
||||
options: host.options
|
||||
? await prepareOptions(host.options)
|
||||
: undefined,
|
||||
})),
|
||||
),
|
||||
})),
|
||||
).then((prepared) => {
|
||||
if (revisions.get(owner) !== revision) return snapshot(event);
|
||||
return reconcilePrepared(event, prepared);
|
||||
});
|
||||
}
|
||||
function reconcilePrepared(event, input) {
|
||||
if (!Array.isArray(input)) throw new Error("Invalid monitor configuration");
|
||||
const owner = event.sender.id;
|
||||
const wanted = new Set();
|
||||
const freshKeys = new Set();
|
||||
for (const panel of input) {
|
||||
if (typeof panel.id !== "string" || !Array.isArray(panel.hosts)) continue;
|
||||
const id = `${owner}:${panel.id}`;
|
||||
wanted.add(id);
|
||||
const keys = [];
|
||||
const hosts = [];
|
||||
for (const host of panel.hosts) {
|
||||
if (typeof host.id !== "string") continue;
|
||||
const options = host.options;
|
||||
if (!options || options.singleChannelSsh || !options.hostname) {
|
||||
hosts.push({
|
||||
id: host.id,
|
||||
status:
|
||||
host.status === "login" || host.status === "error"
|
||||
? host.status
|
||||
: "unsupported",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const key = keyFor(options);
|
||||
if (!key) {
|
||||
hosts.push({ id: host.id, status: "unsupported" });
|
||||
continue;
|
||||
}
|
||||
keys.push(key);
|
||||
hosts.push({ id: host.id, key });
|
||||
hosts[hosts.length - 1].options = options;
|
||||
}
|
||||
const interval = [1, 2, 3, 5, 10, 30, 60].includes(panel.interval) ? panel.interval : 5;
|
||||
const previous = panels.get(id);
|
||||
const fresh = !previous || previous.interval !== interval || previous.runId !== panel.runId;
|
||||
if (fresh) for (const key of keys) freshKeys.add(key);
|
||||
panels.set(id, {
|
||||
event,
|
||||
owner,
|
||||
id: panel.id,
|
||||
keys,
|
||||
hosts,
|
||||
interval,
|
||||
runId: panel.runId,
|
||||
startedAt: fresh ? now() : previous.startedAt,
|
||||
});
|
||||
}
|
||||
for (const [id, panel] of panels)
|
||||
if (panel.owner === owner && !wanted.has(id)) panels.delete(id);
|
||||
const needed = new Set([...panels.values()].flatMap((p) => p.keys));
|
||||
const ownKeys = new Set(
|
||||
[...panels.values()]
|
||||
.filter((p) => p.owner === owner)
|
||||
.flatMap((p) => p.keys),
|
||||
);
|
||||
const retryKeys = retries.get(owner) ?? new Set();
|
||||
retries.delete(owner);
|
||||
// Release obsolete demand before applying the limit. A credential-changing
|
||||
// retry keeps other windows' old identity available in its existing state.
|
||||
for (const entry of entries.values())
|
||||
if (
|
||||
!needed.has(entry.key) ||
|
||||
(!entry.transport &&
|
||||
entry.owner === owner &&
|
||||
!ownKeys.has(entry.key)) ||
|
||||
(ownKeys.has(entry.key) && retryKeys.has(entry.key))
|
||||
)
|
||||
release(entry);
|
||||
admitEntries(owner);
|
||||
for (const entry of entries.values()) {
|
||||
if (freshKeys.has(entry.key) && entry.ready && entry.transport && !entry.sampling) {
|
||||
if (entry.timer) timers.clearTimeout(entry.timer);
|
||||
entry.timer = null;
|
||||
void collect(entry);
|
||||
continue;
|
||||
}
|
||||
if (entry.timer && entry.due - now() > intervalFor(entry) * 1000) {
|
||||
timers.clearTimeout(entry.timer);
|
||||
entry.due = now() + intervalFor(entry) * 1000;
|
||||
entry.timer = timers.setTimeout(
|
||||
() => {
|
||||
entry.timer = null;
|
||||
void collect(entry);
|
||||
},
|
||||
intervalFor(entry) * 1000,
|
||||
);
|
||||
entry.timer?.unref?.();
|
||||
}
|
||||
}
|
||||
return snapshot(event);
|
||||
}
|
||||
function admitEntries(preferredOwner) {
|
||||
const candidates = [...panels.values()].sort(
|
||||
(a, b) =>
|
||||
Number(b.owner === preferredOwner) - Number(a.owner === preferredOwner),
|
||||
);
|
||||
for (const panel of candidates)
|
||||
for (const host of panel.hosts) {
|
||||
if (!host.key || entries.has(host.key) || entries.size >= 256) continue;
|
||||
const entry = {
|
||||
key: host.key,
|
||||
historyId: randomUUID(),
|
||||
hostname: host.options.hostname,
|
||||
sessionId: `monitor-${randomUUID()}`,
|
||||
holder: {},
|
||||
owner: panel.owner,
|
||||
status: "queued",
|
||||
failures: 0,
|
||||
ready: false,
|
||||
transport: null,
|
||||
timer: null,
|
||||
cancelled: false,
|
||||
controller: new AbortController(),
|
||||
};
|
||||
entries.set(host.key, entry);
|
||||
connect(entry, panel.event, host.options);
|
||||
}
|
||||
}
|
||||
function snapshot(event, cursor) {
|
||||
const own = [...panels.values()].filter((p) => p.owner === event.sender.id);
|
||||
const append = cursor?.runtimeId === runtimeId && cursor.epoch === epoch;
|
||||
const hosts = {};
|
||||
for (const panel of own)
|
||||
for (const host of panel.hosts) {
|
||||
const entry = entries.get(host.key);
|
||||
const shared = entry?.transport
|
||||
? readSharedStats(entry.transport.conn)
|
||||
: null;
|
||||
const historyId = entry?.historyId ?? "";
|
||||
const last = cursor?.after?.[host.id];
|
||||
hosts[host.id] = {
|
||||
historyId,
|
||||
status: entry?.status ?? host.status ?? "limit",
|
||||
at: shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
|
||||
? {}
|
||||
: { stats: shared?.result?.success ? shared.result.stats : null }),
|
||||
points: append && last?.historyId === historyId
|
||||
? (shared?.points ?? []).filter((point) => point.at > last.at)
|
||||
: shared?.points ?? [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
epoch,
|
||||
historyMode: append ? "append" : "replace",
|
||||
runtimeId,
|
||||
takenAt: now(),
|
||||
activeSessions: [
|
||||
...new Set(
|
||||
own
|
||||
.flatMap((p) => p.keys)
|
||||
.map((key) => entries.get(key))
|
||||
.filter((entry) => entry && !entry.cancelled)
|
||||
.map((entry) => entry.sessionId),
|
||||
),
|
||||
],
|
||||
running: own.map((p) => p.id),
|
||||
startedAt: Object.fromEntries(own.map((p) => [p.id, p.startedAt])),
|
||||
count: panels.size,
|
||||
hostCount: entries.size,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
function removeOwner(owner) {
|
||||
retries.delete(owner);
|
||||
revisions.set(owner, (revisions.get(owner) ?? 0) + 1);
|
||||
for (const [id, p] of panels) if (p.owner === owner) panels.delete(id);
|
||||
const needed = new Set([...panels.values()].flatMap((p) => p.keys));
|
||||
for (const entry of entries.values()) {
|
||||
if (!needed.has(entry.key)) release(entry);
|
||||
else if (!entry.transport && entry.owner === owner) release(entry);
|
||||
}
|
||||
admitEntries();
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
epoch++;
|
||||
for (const [owner, revision] of revisions)
|
||||
revisions.set(owner, revision + 1);
|
||||
panels.clear();
|
||||
retries.clear();
|
||||
for (const entry of entries.values()) release(entry);
|
||||
}
|
||||
function retry(event, hostId) {
|
||||
const keys = retries.get(event.sender.id) ?? new Set();
|
||||
for (const p of panels.values())
|
||||
if (p.owner === event.sender.id)
|
||||
for (const host of p.hosts) {
|
||||
const entry = entries.get(host.key);
|
||||
if (
|
||||
host.id === hostId &&
|
||||
entry &&
|
||||
["login", "error", "unsupported", "disconnected"].includes(
|
||||
entry.status,
|
||||
)
|
||||
)
|
||||
keys.add(host.key);
|
||||
}
|
||||
retries.set(event.sender.id, keys);
|
||||
}
|
||||
|
||||
return {
|
||||
reconcile,
|
||||
snapshot,
|
||||
removeOwner,
|
||||
stopAll,
|
||||
retry,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
stopAll();
|
||||
},
|
||||
};
|
||||
}
|
||||
module.exports = { createMonitorService };
|
||||
@@ -1,754 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const pool = require("./sshConnectionPool.cjs");
|
||||
const { createMonitorService } = require("./monitorBridge.cjs");
|
||||
const {
|
||||
sampleSharedStats,
|
||||
readSharedStats,
|
||||
cancelSharedStats,
|
||||
resetSharedStatsForTests,
|
||||
} = require("./sshBridge/sharedStats.cjs");
|
||||
const event = (id) => ({ sender: { id } });
|
||||
const options = {
|
||||
hostId: "h",
|
||||
hostname: "test",
|
||||
username: "alice",
|
||||
password: "fixture-password",
|
||||
verifyHostKeys: true,
|
||||
};
|
||||
const demand = (id = "p", opts = options) => ({
|
||||
id,
|
||||
runId: "first-run",
|
||||
interval: 10,
|
||||
hosts: [{ id: "h", options: opts }],
|
||||
});
|
||||
|
||||
test("each start and interval change begins fresh panel history while other panels keep theirs", () => {
|
||||
let now = 1000;
|
||||
const f = fixture({ now: () => now });
|
||||
const a = { ...demand("a"), hosts: [] };
|
||||
const b = { ...demand("b"), hosts: [] };
|
||||
f.service.reconcile(event(1), [a, b]);
|
||||
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 1000, b: 1000 });
|
||||
now = 2000;
|
||||
f.service.reconcile(event(1), [a, b]);
|
||||
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 1000, b: 1000 });
|
||||
const changed = { ...a, interval: 5 };
|
||||
f.service.reconcile(event(1), [changed, b]);
|
||||
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 2000, b: 1000 });
|
||||
now = 3000;
|
||||
f.service.reconcile(event(1), [{ ...changed, runId: "second-run" }, b]);
|
||||
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 3000, b: 1000 });
|
||||
f.service.reconcile(event(1), [b]);
|
||||
now = 4000;
|
||||
f.service.reconcile(event(1), [changed, b]);
|
||||
assert.deepEqual(f.service.snapshot(event(1)).startedAt, { a: 4000, b: 1000 });
|
||||
f.service.dispose();
|
||||
});
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
async function until(predicate) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!predicate() && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.ok(predicate(), "asynchronous monitor state did not settle");
|
||||
}
|
||||
function conn() {
|
||||
const c = new EventEmitter();
|
||||
c._sock = { destroyed: false };
|
||||
c.end = () => {
|
||||
c.ended = true;
|
||||
};
|
||||
return c;
|
||||
}
|
||||
function fixture(overrides = {}) {
|
||||
let dials = 0,
|
||||
samples = 0;
|
||||
const timers = [];
|
||||
const c = conn();
|
||||
const service = createMonitorService({
|
||||
startConnection: async (_e, opts) => {
|
||||
dials++;
|
||||
return {
|
||||
transport: pool.createTransport({
|
||||
conn: c,
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
};
|
||||
},
|
||||
cancelConnection: () => {},
|
||||
getStats: (context) =>
|
||||
sampleSharedStats(context, async () => {
|
||||
samples++;
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
cpu: null,
|
||||
memTotal: 100,
|
||||
memUsed: 50,
|
||||
networkReady: false,
|
||||
disks: [],
|
||||
},
|
||||
};
|
||||
}),
|
||||
timers: {
|
||||
setTimeout(fn, ms) {
|
||||
const t = { fn, ms };
|
||||
timers.push(t);
|
||||
return t;
|
||||
},
|
||||
clearTimeout(t) {
|
||||
t.cancelled = true;
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return {
|
||||
service,
|
||||
c,
|
||||
timers,
|
||||
get dials() {
|
||||
return dials;
|
||||
},
|
||||
get samples() {
|
||||
return samples;
|
||||
},
|
||||
};
|
||||
}
|
||||
test.beforeEach(() => {
|
||||
resetSharedStatsForTests();
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
});
|
||||
test.afterEach(() => pool.resetSshTransportRegistryForTests());
|
||||
test("two panels and windows share one transport/sample; stopping preserves a terminal lease", async () => {
|
||||
const f = fixture();
|
||||
f.service.reconcile(event(1), [demand("a"), demand("b")]);
|
||||
f.service.reconcile(event(2), [demand("a")]);
|
||||
await tick();
|
||||
await tick();
|
||||
await until(() => f.samples === 1);
|
||||
assert.equal(f.dials, 1);
|
||||
assert.equal(f.samples, 1);
|
||||
assert.equal(f.service.snapshot(event(1)).count, 3);
|
||||
const transport = pool.findTransportByEndpoint(
|
||||
pool.buildConnectionReuseEndpoint(options),
|
||||
);
|
||||
const terminal = {};
|
||||
pool.borrowTransport(transport, {
|
||||
kind: pool.LEASE_KINDS.shell,
|
||||
holder: terminal,
|
||||
});
|
||||
f.service.reconcile(event(1), []);
|
||||
assert.equal(f.service.snapshot(event(2)).hostCount, 1);
|
||||
f.service.removeOwner(2);
|
||||
assert.equal(transport.count, 1);
|
||||
assert.equal(f.c.ended, undefined);
|
||||
assert.ok(f.timers.every((t) => t.cancelled));
|
||||
pool.returnTransport(terminal);
|
||||
f.service.dispose();
|
||||
});
|
||||
test("different credentials never share an entry or physical dial", async () => {
|
||||
const f = fixture();
|
||||
f.service.reconcile(event(1), [demand("a")]);
|
||||
f.service.reconcile(event(2), [
|
||||
demand("b", { ...options, password: "different" }),
|
||||
]);
|
||||
await tick();
|
||||
await tick();
|
||||
await until(() => f.dials === 2);
|
||||
assert.equal(f.dials, 2);
|
||||
assert.equal(f.service.snapshot(event(1)).hostCount, 2);
|
||||
f.service.dispose();
|
||||
});
|
||||
test("stopping while queued prevents dial; stopping during dial returns late transport", async () => {
|
||||
let finish,
|
||||
opens = 0,
|
||||
cancels = 0;
|
||||
const c = conn();
|
||||
const f = fixture({
|
||||
startConnection: (_e, opts) => {
|
||||
opens++;
|
||||
return new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
transport: pool.createTransport({
|
||||
conn: c,
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
cancelConnection: () => cancels++,
|
||||
});
|
||||
f.service.reconcile(event(1), [demand("a")]);
|
||||
await tick();
|
||||
f.service.reconcile(event(1), [demand("b", { ...options, username: "bob" })]);
|
||||
f.service.reconcile(event(1), []);
|
||||
finish();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(opens, 1);
|
||||
assert.equal(cancels, 1);
|
||||
assert.equal(f.samples, 0);
|
||||
assert.equal(f.service.snapshot(event(1)).count, 0);
|
||||
const t = pool.findTransportByEndpoint(
|
||||
pool.buildConnectionReuseEndpoint(options),
|
||||
);
|
||||
assert.equal(t.state, "idle");
|
||||
assert.equal(t.count, 0);
|
||||
f.service.dispose();
|
||||
});
|
||||
test("terminal and monitor join in-flight collection; stop never aborts a terminal reader", async () => {
|
||||
const c = conn();
|
||||
let finish,
|
||||
calls = 0,
|
||||
aborted = false;
|
||||
const collect = (ctx) => {
|
||||
calls++;
|
||||
ctx.statsSignal.addEventListener("abort", () => (aborted = true));
|
||||
if (!ctx._monitorStats)
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
stats: { cpu: 1, memTotal: 1, memUsed: 1 },
|
||||
});
|
||||
return new Promise((resolve) => (finish = resolve));
|
||||
};
|
||||
const a = sampleSharedStats({ conn: c, _monitorStats: true }, collect);
|
||||
const b = sampleSharedStats({ conn: c }, collect);
|
||||
cancelSharedStats(c);
|
||||
assert.equal(aborted, false);
|
||||
finish({ success: true, stats: { cpu: null, memTotal: 1, memUsed: 1 } });
|
||||
await Promise.all([a, b]);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(readSharedStats(c).points.length, 1);
|
||||
});
|
||||
test("sparse trends keep a finite time and point budget under sustained samples", async () => {
|
||||
const c = conn();
|
||||
const original = Date.now;
|
||||
let now = 100000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
await sampleSharedStats(
|
||||
{ conn: c, _monitorStats: true },
|
||||
async () => ({
|
||||
success: true,
|
||||
stats: { cpu: i % 100, memTotal: 100, memUsed: 50 },
|
||||
}),
|
||||
now,
|
||||
);
|
||||
now += 10000;
|
||||
}
|
||||
const history = readSharedStats(c).points;
|
||||
assert.equal(history.length, 180);
|
||||
assert.ok(history.every((p) => now - p.at <= 1800000));
|
||||
assert.equal(history[0].at, 800000);
|
||||
} finally {
|
||||
Date.now = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("fresh terminal logins with the exact same pool identity share the monitor's sampler", async () => {
|
||||
const a = conn(),
|
||||
b = conn();
|
||||
const endpoint = pool.normalizeEndpoint(
|
||||
pool.buildConnectionReuseEndpoint(options),
|
||||
);
|
||||
let calls = 0;
|
||||
const collect = async () => {
|
||||
calls++;
|
||||
return { success: true, stats: { cpu: 42, memTotal: 100, memUsed: 50 } };
|
||||
};
|
||||
await sampleSharedStats(
|
||||
{ conn: a, _reuseEndpoint: endpoint, _monitorStats: true },
|
||||
collect,
|
||||
);
|
||||
await sampleSharedStats({ conn: b, _reuseEndpoint: endpoint }, collect);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(readSharedStats(a).points, readSharedStats(b).points);
|
||||
});
|
||||
|
||||
test("full terminal readers upgrade a lightweight cached sample and keep subsequent queries shared", async () => {
|
||||
const c = conn();
|
||||
let calls = 0;
|
||||
const collect = async (ctx) => {
|
||||
calls++;
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
cpu: 42,
|
||||
memTotal: 100,
|
||||
memUsed: null,
|
||||
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
|
||||
gpu: ctx._monitorStats ? null : 75,
|
||||
},
|
||||
};
|
||||
};
|
||||
await sampleSharedStats({ conn: c, _monitorStats: true }, collect);
|
||||
const terminal = await sampleSharedStats({ conn: c }, collect);
|
||||
assert.equal(terminal.stats.topProcesses[0].name, "full-process");
|
||||
assert.equal(terminal.stats.gpu, 75);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(readSharedStats(c).points[0].memory, null);
|
||||
await sampleSharedStats(
|
||||
{ conn: c, _monitorStats: true },
|
||||
collect,
|
||||
Date.now() + 5000,
|
||||
);
|
||||
await sampleSharedStats({ conn: c }, collect);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test("a failed sample preserves last good measurements, time and sparse history", async () => {
|
||||
const c = conn();
|
||||
const session = { conn: c, _monitorStats: true };
|
||||
await sampleSharedStats(session, async () => ({
|
||||
success: true,
|
||||
stats: { cpu: 42 },
|
||||
}));
|
||||
const before = readSharedStats(c);
|
||||
const failed = await sampleSharedStats(
|
||||
session,
|
||||
async () => ({ success: false, error: "timeout" }),
|
||||
Date.now() + 5000,
|
||||
);
|
||||
assert.equal(failed.success, false);
|
||||
const after = readSharedStats(c);
|
||||
assert.equal(after.at, before.at);
|
||||
assert.equal(after.result.stats.cpu, 42);
|
||||
assert.deepEqual(after.points, before.points);
|
||||
});
|
||||
|
||||
test("stopping at the post-authentication await cannot orphan a transport lease", async () => {
|
||||
let transport;
|
||||
const f = fixture({
|
||||
startConnection: async (_event, opts) => {
|
||||
transport = pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
});
|
||||
return { transport };
|
||||
},
|
||||
});
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
while (!transport) await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
f.service.reconcile(event(1), []);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(transport.count, 0);
|
||||
assert.equal(f.service.snapshot(event(1)).hostCount, 0);
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("credential-changing retry leaves the other window's original identity actionable", async () => {
|
||||
const f = fixture({
|
||||
startConnection: async (_event, opts) => {
|
||||
if (opts.password !== "corrected") throw new Error("login failed");
|
||||
return {
|
||||
transport: pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
f.service.reconcile(event(2), [demand()]);
|
||||
await tick();
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.h.status, "login");
|
||||
f.service.retry(event(1), "h");
|
||||
f.service.reconcile(event(1), [
|
||||
demand("p", { ...options, password: "corrected" }),
|
||||
]);
|
||||
await tick();
|
||||
await tick();
|
||||
await until(() => f.service.snapshot(event(1)).hosts.h.status === "live");
|
||||
assert.equal(f.service.snapshot(event(1)).hosts.h.status, "live");
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.h.status, "login");
|
||||
assert.equal(f.service.snapshot(event(2)).activeSessions.length, 1);
|
||||
f.service.retry(event(2), "h");
|
||||
f.service.reconcile(event(2), [
|
||||
demand("p", { ...options, password: "corrected" }),
|
||||
]);
|
||||
await tick();
|
||||
await until(() => f.service.snapshot(event(2)).hosts.h.status === "live");
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.h.status, "live");
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("same-identity retry restarts shared failed demand for both windows", async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture({
|
||||
startConnection: async (_event, opts) => {
|
||||
if (++attempts === 1) throw new Error("login failed");
|
||||
return {
|
||||
transport: pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
f.service.reconcile(event(2), [demand()]);
|
||||
await tick();
|
||||
f.service.retry(event(1), "h");
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
await tick();
|
||||
await tick();
|
||||
await until(() => f.service.snapshot(event(1)).hosts.h.status === "live");
|
||||
assert.equal(f.service.snapshot(event(1)).hosts.h.status, "live");
|
||||
await until(() => f.service.snapshot(event(2)).hosts.h.status === "live");
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.h.status, "live");
|
||||
await until(() => attempts === 2);
|
||||
assert.equal(attempts, 2);
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("closing the owner during authentication reconnects demand for the surviving window", async () => {
|
||||
let rejectFirst,
|
||||
attempts = 0;
|
||||
const f = fixture({
|
||||
startConnection: (_event, opts) => {
|
||||
if (++attempts === 1)
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
return Promise.resolve({
|
||||
transport: pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
});
|
||||
},
|
||||
cancelConnection: () => rejectFirst(new Error("cancelled")),
|
||||
});
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
f.service.reconcile(event(2), [demand()]);
|
||||
await tick();
|
||||
f.service.removeOwner(1);
|
||||
await tick();
|
||||
await tick();
|
||||
await until(() => attempts === 2);
|
||||
assert.equal(attempts, 2);
|
||||
await until(() => f.service.snapshot(event(2)).hosts.h.status === "live");
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.h.status, "live");
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("replacing a host at the capacity limit admits its replacement immediately", async () => {
|
||||
const f = fixture();
|
||||
const hosts = Array.from({ length: 257 }, (_, i) => ({
|
||||
id: `h${i}`,
|
||||
options: { ...options, hostname: `test-${i}` },
|
||||
}));
|
||||
f.service.reconcile(event(1), [{ id: "p", hosts: hosts.slice(0, 256) }]);
|
||||
f.service.reconcile(event(1), [{ id: "p", hosts: hosts.slice(1) }]);
|
||||
const result = f.service.snapshot(event(1));
|
||||
assert.equal(result.hostCount, 256);
|
||||
assert.equal(result.hosts.h256.status, "queued");
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("closing a window also moves queued shared authentication to the surviving window", async () => {
|
||||
let rejectFirst;
|
||||
const dials = [];
|
||||
const f = fixture({
|
||||
startConnection: (e, opts) => {
|
||||
dials.push({ owner: e.sender.id, hostname: opts.hostname });
|
||||
if (opts.hostname === "first")
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
return Promise.resolve({
|
||||
transport: pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
});
|
||||
},
|
||||
cancelConnection: () => rejectFirst?.(new Error("cancelled")),
|
||||
});
|
||||
const second = { id: "second", options: { ...options, hostname: "second" } };
|
||||
f.service.reconcile(event(1), [
|
||||
{
|
||||
id: "p",
|
||||
hosts: [
|
||||
{ id: "first", options: { ...options, hostname: "first" } },
|
||||
second,
|
||||
],
|
||||
},
|
||||
]);
|
||||
f.service.reconcile(event(2), [{ id: "p", hosts: [second] }]);
|
||||
await tick();
|
||||
f.service.removeOwner(1);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual(dials, [
|
||||
{ owner: 1, hostname: "first" },
|
||||
{ owner: 2, hostname: "second" },
|
||||
]);
|
||||
await until(
|
||||
() => f.service.snapshot(event(2)).hosts.second.status === "live",
|
||||
);
|
||||
assert.equal(f.service.snapshot(event(2)).hosts.second.status, "live");
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("existing terminal connections sample while an unrelated host awaits authentication", async () => {
|
||||
const c = conn();
|
||||
const existing = pool.createTransport({
|
||||
conn: c,
|
||||
endpoint: pool.buildConnectionReuseEndpoint(options),
|
||||
});
|
||||
const terminal = {};
|
||||
pool.borrowTransport(existing, {
|
||||
kind: pool.LEASE_KINDS.shell,
|
||||
holder: terminal,
|
||||
});
|
||||
let rejectDial;
|
||||
const f = fixture({
|
||||
startConnection: () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectDial = reject;
|
||||
}),
|
||||
cancelConnection: () => rejectDial(new Error("cancelled")),
|
||||
});
|
||||
f.service.reconcile(event(1), [
|
||||
{
|
||||
id: "p",
|
||||
hosts: [
|
||||
{ id: "pending", options: { ...options, hostname: "pending" } },
|
||||
{ id: "existing", options },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(f.service.snapshot(event(1)).hosts.pending.status, "connecting");
|
||||
await until(
|
||||
() => f.service.snapshot(event(1)).hosts.existing.status === "live",
|
||||
);
|
||||
assert.equal(f.service.snapshot(event(1)).hosts.existing.status, "live");
|
||||
assert.equal(f.samples, 1);
|
||||
f.service.dispose();
|
||||
pool.returnTransport(terminal);
|
||||
});
|
||||
|
||||
for (const sharedHost of ["first", "second"]) {
|
||||
test(`stopping the original owner transfers ${sharedHost === "first" ? "active" : "queued"} shared authentication`, async () => {
|
||||
let rejectFirst;
|
||||
const dials = [];
|
||||
const f = fixture({
|
||||
startConnection: (e, opts) => {
|
||||
dials.push({ owner: e.sender.id, hostname: opts.hostname });
|
||||
if (dials.length === 1)
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
return Promise.resolve({
|
||||
transport: pool.createTransport({
|
||||
conn: conn(),
|
||||
endpoint: pool.buildConnectionReuseEndpoint(opts),
|
||||
}),
|
||||
});
|
||||
},
|
||||
cancelConnection: () => rejectFirst?.(new Error("cancelled")),
|
||||
});
|
||||
const hosts = ["first", "second"].map((hostname) => ({
|
||||
id: hostname,
|
||||
options: { ...options, hostname },
|
||||
}));
|
||||
f.service.reconcile(event(1), [{ id: "p", hosts }]);
|
||||
f.service.reconcile(event(2), [
|
||||
{ id: "p", hosts: hosts.filter((h) => h.id === sharedHost) },
|
||||
]);
|
||||
await tick();
|
||||
f.service.reconcile(event(1), []);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual(dials, [
|
||||
{ owner: 1, hostname: "first" },
|
||||
{ owner: 2, hostname: sharedHost },
|
||||
]);
|
||||
await until(
|
||||
() => f.service.snapshot(event(2)).hosts[sharedHost].status === "live",
|
||||
);
|
||||
assert.equal(f.service.snapshot(event(2)).hosts[sharedHost].status, "live");
|
||||
assert.equal(f.service.snapshot(event(1)).activeSessions.length, 0);
|
||||
f.service.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
for (const interval of [1, 2, 3, 5, 10, 30, 60]) {
|
||||
test(`monitor schedules ${interval}s collection and passes its cache demand`, async () => {
|
||||
let requested;
|
||||
const f = fixture({ getStats: async (context) => {
|
||||
requested = context._monitorIntervalMs;
|
||||
return { success: true, stats: {} };
|
||||
} });
|
||||
f.service.reconcile(event(1), [{ ...demand(), interval }]);
|
||||
await until(() => f.timers.length > 0);
|
||||
assert.equal(f.timers.at(-1).ms, interval * 1000);
|
||||
assert.equal(requested, interval * 1000);
|
||||
f.service.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("one-second monitoring bypasses the terminal's four-second cache and records bounded history", async () => {
|
||||
const c = conn();
|
||||
const original = Date.now;
|
||||
let now = 100000, calls = 0;
|
||||
Date.now = () => now;
|
||||
const session = { conn: c, _monitorStats: true, _monitorIntervalMs: 1000 };
|
||||
const collect = async () => {
|
||||
calls++;
|
||||
return { success: true, stats: { cpu: calls % 100, memTotal: 100, memUsed: 50 } };
|
||||
};
|
||||
try {
|
||||
await sampleSharedStats(session, collect, now);
|
||||
now += 500;
|
||||
await sampleSharedStats(session, collect, now);
|
||||
assert.equal(calls, 1);
|
||||
now += 500;
|
||||
await sampleSharedStats(session, collect, now);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(readSharedStats(c).points.length, 2);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
now += 1000;
|
||||
await sampleSharedStats(session, collect, now);
|
||||
}
|
||||
const history = readSharedStats(c).points;
|
||||
assert.equal(history.length, 1800);
|
||||
assert.ok(history.every((p) => now - p.at <= 1800000));
|
||||
} finally {
|
||||
Date.now = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("fast monitoring stays lightweight while terminal full reads retain their own cache", async (t) => {
|
||||
let now = 100000;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const c = conn();
|
||||
const fullTimes = [], lightTimes = [];
|
||||
const collect = async (ctx) => {
|
||||
(ctx._monitorStats ? lightTimes : fullTimes).push(now);
|
||||
return { success: true, stats: { cpu: 25, memTotal: 100, memUsed: 50 } };
|
||||
};
|
||||
await sampleSharedStats({ conn: c }, collect);
|
||||
for (let second = 1; second <= 10; second++) {
|
||||
now += 1000;
|
||||
await sampleSharedStats({ conn: c, _monitorStats: true, _monitorIntervalMs: 1000 }, collect);
|
||||
if ([2, 5, 7, 10].includes(second)) await sampleSharedStats({ conn: c }, collect);
|
||||
}
|
||||
assert.deepEqual(fullTimes, [100000, 105000, 110000]);
|
||||
assert.equal(lightTimes.length, 10);
|
||||
});
|
||||
|
||||
test("changing to a long cadence immediately records a fresh point instead of reusing pre-reset cache", async () => {
|
||||
let calls = 0;
|
||||
const f = fixture({ getStats: (ctx) => sampleSharedStats(ctx, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
calls++;
|
||||
return { success: true, stats: { cpu: calls, memTotal: 100, memUsed: 50 } };
|
||||
}) });
|
||||
f.service.reconcile(event(1), [{ ...demand(), interval: 1 }]);
|
||||
await until(() => f.timers.length === 1);
|
||||
const previousAt = f.service.snapshot(event(1)).hosts.h.points[0].at;
|
||||
f.service.reconcile(event(1), [{ ...demand(), interval: 60 }]);
|
||||
await until(() => calls === 2 && f.timers.at(-1).ms === 60000);
|
||||
const snapshot = f.service.snapshot(event(1));
|
||||
assert.equal(snapshot.hosts.h.stats.cpu, 2);
|
||||
assert.ok(snapshot.hosts.h.at > snapshot.startedAt.p);
|
||||
assert.equal(snapshot.hosts.h.points.length, 2);
|
||||
assert.ok(snapshot.hosts.h.points[1].at - previousAt < 1000);
|
||||
assert.ok(f.timers[0].cancelled);
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test("reset joining a terminal query still records its first fresh point within one second", async (t) => {
|
||||
let now = 100000;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const c = conn();
|
||||
const result = { success: true, stats: { cpu: 50, memTotal: 100, memUsed: 25 } };
|
||||
await sampleSharedStats({ conn: c, _monitorStats: true }, async () => result);
|
||||
now = 100100;
|
||||
let finish;
|
||||
const terminal = sampleSharedStats({ conn: c }, () => new Promise((resolve) => { finish = resolve; }));
|
||||
now = 100200;
|
||||
const monitor = sampleSharedStats({ conn: c, _monitorStats: true, _monitorIntervalMs: 60000, _monitorFreshAfter: now }, async () => { throw new Error("must join terminal query"); });
|
||||
now = 100300;
|
||||
finish(result);
|
||||
await Promise.all([terminal, monitor]);
|
||||
assert.deepEqual(readSharedStats(c).points.map((point) => point.at), [100000, 100300]);
|
||||
});
|
||||
|
||||
test("fresh monitoring never samples a reused terminal before its restricted banner check", async () => {
|
||||
const f = fixture();
|
||||
f.c._remoteVer = "SSH-2.0-FortiSSH_1.0";
|
||||
const transport = pool.createTransport({ conn: f.c, endpoint: pool.buildConnectionReuseEndpoint(options) });
|
||||
const terminal = {};
|
||||
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder: terminal });
|
||||
f.service.reconcile(event(1), [demand()]);
|
||||
await until(() => f.service.snapshot(event(1)).hosts.h.status === "unsupported");
|
||||
assert.equal(f.samples, 0);
|
||||
assert.equal(transport.count, 1);
|
||||
f.service.dispose();
|
||||
pool.returnTransport(terminal);
|
||||
});
|
||||
|
||||
test("snapshot deltas send new points only, even with 256 full retained histories", async (t) => {
|
||||
const connections = [];
|
||||
const f = fixture({
|
||||
startConnection: async (_event, opts) => {
|
||||
const c = conn();
|
||||
connections.push(c);
|
||||
return { transport: pool.createTransport({ conn: c, endpoint: pool.buildConnectionReuseEndpoint(opts) }) };
|
||||
},
|
||||
});
|
||||
const hosts = Array.from({ length: 256 }, (_, index) => ({ id: `h${index}`, options: { ...options, hostname: `host${index}` } }));
|
||||
f.service.reconcile(event(1), [{ ...demand(), hosts }]);
|
||||
await until(() => f.timers.length === 256);
|
||||
t.mock.timers.enable({ apis: ["Date"], now: Date.now() });
|
||||
const at = Date.now();
|
||||
for (const c of connections) {
|
||||
const shared = readSharedStats(c);
|
||||
shared.result.stats = { ...shared.result.stats,
|
||||
disks: Array.from({ length: 32 }, (_, index) => ({ mount: `/disk/${index}`, total: 1024, used: 512 })),
|
||||
netInterfaces: Array.from({ length: 32 }, (_, index) => ({ name: `eth${index}`, rx: 10, tx: 20 })),
|
||||
cpuPerCore: Array.from({ length: 64 }, () => 50),
|
||||
};
|
||||
const points = shared.points;
|
||||
points.length = 0;
|
||||
for (let i = 0; i < 1800; i++) points.push({ at: at - (1799 - i) * 1000, cpu: i % 100, memory: 50, disk: 40, rx: 10, tx: 20 });
|
||||
}
|
||||
const initial = f.service.snapshot(event(1));
|
||||
const initialBytes = JSON.stringify(initial).length;
|
||||
const cursor = { runtimeId: initial.runtimeId, epoch: initial.epoch, after: Object.fromEntries(hosts.map((host) => [host.id, { at, statsAt: initial.hosts[host.id].at, historyId: initial.hosts[host.id].historyId }])) };
|
||||
for (const c of connections) {
|
||||
const points = readSharedStats(c).points;
|
||||
points.shift();
|
||||
points.push({ at: at + 1000, cpu: 51, memory: 52, disk: 40, rx: 10, tx: 20 });
|
||||
}
|
||||
const delta = f.service.snapshot(event(1), cursor);
|
||||
assert.equal(initial.historyMode, "replace");
|
||||
assert.equal(delta.historyMode, "append");
|
||||
assert.equal(Object.values(delta.hosts).reduce((count, host) => count + host.points.length, 0), 256);
|
||||
assert.ok(Object.values(delta.hosts).every((host) => !Object.hasOwn(host, "stats")));
|
||||
assert.ok(JSON.stringify(delta).length < initialBytes / 100);
|
||||
const caughtUp = { ...cursor, after: Object.fromEntries(hosts.map((host) => [host.id, { ...cursor.after[host.id], at: at + 1000 }])) };
|
||||
assert.ok(Object.values(f.service.snapshot(event(1), caughtUp).hosts).every((host) => host.points.length === 0));
|
||||
const staleIdentity = { ...cursor, after: { ...cursor.after, h0: { at: at + 999999, historyId: "old-endpoint" } } };
|
||||
assert.equal(f.service.snapshot(event(1), staleIdentity).hosts.h0.points.length, 1800);
|
||||
assert.deepEqual(f.service.snapshot(event(1), staleIdentity).hosts.h0.stats, initial.hosts.h0.stats);
|
||||
assert.equal(f.service.snapshot(event(1), { ...cursor, runtimeId: "old-worker" }).historyMode, "replace");
|
||||
const shared = readSharedStats(connections[0]);
|
||||
t.mock.timers.tick(5);
|
||||
await sampleSharedStats({ conn: connections[0], _monitorStats: true }, async () =>
|
||||
({ success: true, stats: { ...shared.result.stats, cpu: 67 } }), shared.at + 5000);
|
||||
const fresh = f.service.snapshot(event(1), caughtUp);
|
||||
assert.equal(fresh.hosts.h0.stats.cpu, 67);
|
||||
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
|
||||
f.service.dispose();
|
||||
});
|
||||
@@ -1970,7 +1970,7 @@ function createKeyboardInteractiveHandler(options) {
|
||||
OTP_PROMPT_PATTERN.test(
|
||||
[contextText, singlePromptText].filter(Boolean).join("\n"),
|
||||
);
|
||||
const allowSavePassword = options.allowSavePassword !== false && !(skipAutoFill || singleSecondaryChallenge);
|
||||
const allowSavePassword = !(skipAutoFill || singleSecondaryChallenge);
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
try { onPromptShown?.(); } catch (err) { console.warn(`${logPrefix} onPromptShown callback threw`, err); }
|
||||
|
||||
@@ -863,7 +863,6 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
scope: keyboardInteractiveScope,
|
||||
allowSavePassword: options._monitorMode !== true,
|
||||
bootEpoch: options.bootEpoch,
|
||||
getAuthBanner: () => authBanner,
|
||||
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
|
||||
@@ -1440,7 +1439,6 @@ const sessionOpsApi = createSessionOpsApi({
|
||||
getServerStats: undefined,
|
||||
});
|
||||
const {
|
||||
getStatsForContext,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
readRemoteHistory,
|
||||
@@ -1453,18 +1451,6 @@ const {
|
||||
setSessionEncoding,
|
||||
} = sessionOpsApi;
|
||||
|
||||
const { createMonitorService } = require("./monitorBridge.cjs");
|
||||
const monitorService = createMonitorService({ startConnection: startSSHSession, cancelConnection: cancelTestConnection, getStats: getStatsForContext,
|
||||
prepareOptions: (options) => require("./sshBridge/startSession.cjs").prepareAgentForwardingOptions(options, getAvailableForwardingAgentSocket),
|
||||
});
|
||||
const monitorHandlers = {
|
||||
"netcatty:monitor:update": (event, payload) => monitorService.reconcile(event, payload),
|
||||
"netcatty:monitor:snapshot": (event, cursor) => monitorService.snapshot(event, cursor),
|
||||
"netcatty:monitor:stop-all": () => monitorService.stopAll(),
|
||||
"netcatty:monitor:owner-close": (_event, owner) => monitorService.removeOwner(owner),
|
||||
"netcatty:monitor:retry": (event, hostId) => monitorService.retry(event, hostId),
|
||||
};
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SSH operations
|
||||
*/
|
||||
@@ -1505,24 +1491,6 @@ function registerOwnedAuthResponseHandler(ipcMain, terminalWorkerManager, channe
|
||||
|
||||
function registerHandlers(ipcMain, options = {}) {
|
||||
const terminalWorkerManager = options.terminalWorkerManager || null;
|
||||
const trackedMonitorOwners = new WeakSet();
|
||||
for (const [channel, handler] of Object.entries(monitorHandlers)) {
|
||||
ipcMain.handle(channel, (event, payload) => {
|
||||
const owner = event.sender.id;
|
||||
if (typeof event.sender.once === "function" && !trackedMonitorOwners.has(event.sender)) {
|
||||
trackedMonitorOwners.add(event.sender);
|
||||
event.sender.once("destroyed", () => {
|
||||
if (terminalWorkerManager) {
|
||||
void terminalWorkerManager.request("netcatty:monitor:owner-close", owner, { webContentsId: owner }).catch(() => {});
|
||||
} else monitorService.removeOwner(owner);
|
||||
});
|
||||
}
|
||||
return terminalWorkerManager
|
||||
? terminalWorkerManager.request(channel, payload, { webContentsId: owner })
|
||||
: handler(event, payload);
|
||||
});
|
||||
}
|
||||
|
||||
if (terminalWorkerManager) {
|
||||
[
|
||||
"netcatty:start",
|
||||
@@ -1619,7 +1587,6 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
stopAllMonitoring: () => monitorService.stopAll(),
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
buildAlgorithms,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const bridge = require("./sshBridge.cjs");
|
||||
const pool = require("./sshConnectionPool.cjs");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function register(terminalWorkerManager) {
|
||||
const handlers = new Map();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers(
|
||||
{ handle: (channel, handler) => handlers.set(channel, handler), on() {} },
|
||||
{ terminalWorkerManager },
|
||||
);
|
||||
return handlers;
|
||||
}
|
||||
function event(id) {
|
||||
const sender = new EventEmitter();
|
||||
sender.id = id;
|
||||
return { sender };
|
||||
}
|
||||
test.afterEach(() => {
|
||||
bridge.stopAllMonitoring();
|
||||
pool.resetSshTransportRegistryForTests();
|
||||
});
|
||||
test("direct IPC owner teardown releases only closed windows' monitoring demand and leases", async () => {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const handlers = register();
|
||||
const a = event(1),
|
||||
b = event(2);
|
||||
const options = {
|
||||
hostname: "owner-fixture",
|
||||
username: "fixture",
|
||||
password: "fixture-only",
|
||||
useSshAgent: false,
|
||||
agentForwarding: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
const conn = new EventEmitter();
|
||||
conn._sock = { destroyed: false };
|
||||
conn.end = () => {};
|
||||
conn.exec = (_command, callback) =>
|
||||
callback(new Error("fixture has no stats"));
|
||||
const transport = pool.createTransport({
|
||||
conn,
|
||||
endpoint: pool.buildConnectionReuseEndpoint(options),
|
||||
});
|
||||
const terminal = {};
|
||||
pool.borrowTransport(transport, {
|
||||
kind: pool.LEASE_KINDS.shell,
|
||||
holder: terminal,
|
||||
});
|
||||
const demand = [{ id: "p", hosts: [{ id: "h", options }] }];
|
||||
await handlers.get("netcatty:monitor:update")(a, demand);
|
||||
await handlers.get("netcatty:monitor:update")(b, demand);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(transport.count, 2);
|
||||
handlers.get("netcatty:monitor:snapshot")(a);
|
||||
assert.equal(a.sender.listenerCount("destroyed"), 1);
|
||||
a.sender.emit("destroyed");
|
||||
let snapshot = handlers.get("netcatty:monitor:snapshot")(b);
|
||||
assert.equal(snapshot.count, 1);
|
||||
assert.equal(snapshot.hostCount, 1);
|
||||
assert.equal(transport.count, 2);
|
||||
b.sender.emit("destroyed");
|
||||
snapshot = handlers.get("netcatty:monitor:snapshot")(b);
|
||||
assert.equal(snapshot.count, 0);
|
||||
assert.equal(snapshot.hostCount, 0);
|
||||
assert.equal(transport.count, 1, "existing terminal keeps its own lease");
|
||||
pool.returnTransport(terminal);
|
||||
});
|
||||
test("worker IPC installs one owner teardown across all monitor handlers", async () => {
|
||||
const requests = [];
|
||||
const handlers = register({
|
||||
request: async (...args) => {
|
||||
requests.push(args);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const a = event(42);
|
||||
await handlers.get("netcatty:monitor:update")(a, []);
|
||||
await handlers.get("netcatty:monitor:snapshot")(a);
|
||||
await handlers.get("netcatty:monitor:retry")(a, "h");
|
||||
assert.equal(a.sender.listenerCount("destroyed"), 1);
|
||||
a.sender.emit("destroyed");
|
||||
assert.deepEqual(requests.at(-1), [
|
||||
"netcatty:monitor:owner-close",
|
||||
42,
|
||||
{ webContentsId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
|
||||
const { createWorkerSender } = require("../terminalWorker/process.cjs");
|
||||
const handlers = register();
|
||||
const e = { sender: createWorkerSender({ postMessage() {} }, 73) };
|
||||
assert.equal(e.sender.once, undefined);
|
||||
await handlers.get("netcatty:monitor:update")(e, [{ id: "p", hosts: [] }]);
|
||||
assert.equal(handlers.get("netcatty:monitor:snapshot")(e).count, 1);
|
||||
handlers.get("netcatty:monitor:retry")(e, "missing");
|
||||
handlers.get("netcatty:monitor:owner-close")(e, 73);
|
||||
assert.equal(handlers.get("netcatty:monitor:snapshot")(e).count, 0);
|
||||
handlers.get("netcatty:monitor:stop-all")(e);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
@@ -1061,15 +1060,10 @@ function createSessionOpsApi(ctx) {
|
||||
async function getServerStats(event, payload) {
|
||||
const { sessionId } = payload;
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return { success: false, error: 'Session not found or not connected' };
|
||||
return getStatsForContext(session, event, sessionId);
|
||||
}
|
||||
|
||||
async function getStatsForContext(session, event, sessionId) {
|
||||
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
}
|
||||
|
||||
async function collectServerStats(session, event, sessionId) {
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
const bastionStatsBlock = extraExecUnsupportedError(session);
|
||||
if (bastionStatsBlock) return bastionStatsBlock;
|
||||
|
||||
@@ -1188,7 +1182,7 @@ function createSessionOpsApi(ctx) {
|
||||
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
// Disk: -P keeps a stable six-column POSIX layout on macOS.
|
||||
`mounts=$(mount 2>/dev/null || true)`,
|
||||
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if(index($1,"/dev/disk")==1&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
|
||||
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if($1~/^\/dev\/disk/&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
|
||||
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
|
||||
`net=$(netstat -ibn 2>/dev/null | awk 'NR==1{for(i=1;i<=NF;i++){if($i=="Ibytes")ib=i;if($i=="Obytes")ob=i}next} ib&&ob&&$1~/^[[:alpha:]]/&&$1!~/^lo/&&$0~/<Link#/{name=$1;gsub(/[*]/,"",name);rx=$(ib)+0;tx=$(ob)+0;if(rx>0||tx>0){seen[name]=rx ":" tx}} END{for(name in seen)printf "%s:%s,",name,seen[name]}' | sed 's/,$//')`,
|
||||
`hostname_value=$(hostname 2>/dev/null || uname -n 2>/dev/null || echo "")`,
|
||||
@@ -1199,7 +1193,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('; ');
|
||||
].join('; ');
|
||||
|
||||
// Prefer df's filesystem type column so FUSE mounts can be identified
|
||||
// even when their source name looks like an ordinary remote path.
|
||||
@@ -1305,7 +1299,7 @@ function createSessionOpsApi(ctx) {
|
||||
`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 "")`,
|
||||
// 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('; ');
|
||||
].join('; ');
|
||||
|
||||
// Client-side run bound shared by the stats and disk probes; the remote
|
||||
// watchdog uses the same value so an abandoned probe self-kills instead
|
||||
@@ -1335,8 +1329,6 @@ function createSessionOpsApi(ctx) {
|
||||
statsRunTimeoutMs,
|
||||
);
|
||||
const statsExecOptions = {
|
||||
signal: session.statsSignal,
|
||||
invalidateTransportOnAbort: false,
|
||||
openingTimeoutMs: statsRunTimeoutMs,
|
||||
runTimeoutMs: statsRunTimeoutMs,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
@@ -1740,7 +1732,6 @@ function createSessionOpsApi(ctx) {
|
||||
diskUsed, // Disk used in GB for root partition (backward compat)
|
||||
diskTotal, // Total disk in GB for root partition (backward compat)
|
||||
disks, // Array of all mounted disks
|
||||
networkReady: prevNet.timestamp > 0 && prevNet.interfaces.length > 0,
|
||||
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)
|
||||
@@ -1792,7 +1783,6 @@ function createSessionOpsApi(ctx) {
|
||||
restoreRemoteModes,
|
||||
listSessionDir,
|
||||
getServerStats,
|
||||
getStatsForContext,
|
||||
setSessionEncoding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1422,30 +1422,3 @@ test("zmodem remote exec helpers do nothing on single-channel SSH", async () =>
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
|
||||
test("real macOS stats shell parses the root disk without broken awk escaping", async () => {
|
||||
const sessions = new Map([["mac", { type: "ssh", conn: {
|
||||
exec(command, cb) {
|
||||
const script = [
|
||||
'uname() { printf "%s\\n" Darwin; }',
|
||||
'sysctl() { case "$*" in *hw.logicalcpu*) echo 4;; *hw.pagesize*) echo 4096;; *hw.memsize*) echo 8589934592;; *kern.boottime*) echo "{ sec = 1700000000, usec = 0 }";; *vm.loadavg*) echo "{ 1.0 0.5 0.2 }";; esac; }',
|
||||
'vm_stat() { printf "%s\\n" "Pages free: 10000." "Pages inactive: 20000."; }',
|
||||
'ps() { case "$*" in *%cpu*) echo 25;; *%mem*) :;; *) command ps "$@";; esac; }',
|
||||
'mount() { echo "/dev/disk3s1s1 on / (apfs, sealed, local, read-only)"; }',
|
||||
'df() { printf "%s\\n" "Filesystem 1024-blocks Used Available Capacity Mounted on" "/dev/disk3s1s1 1000000 100000 500000 20% /"; }',
|
||||
'netstat() { :; }',
|
||||
'sw_vers() { echo macOS; }',
|
||||
unwrapExecShC(command),
|
||||
].join("\n");
|
||||
const execution = spawnSync("sh", ["-c", script], { encoding: "utf8", timeout: 5000 });
|
||||
assert.equal(execution.status, 0, execution.stderr);
|
||||
assert.doesNotMatch(execution.stderr, /awk:.*(syntax|regular expression)/);
|
||||
cb(null, fakeStream(execution.stdout));
|
||||
},
|
||||
} }]]);
|
||||
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "mac" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.disks[0].mountPoint, "/");
|
||||
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
|
||||
assert.equal(result.stats.diskPercent, 50);
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
"use strict";
|
||||
// One in-memory sampler per physical authenticated transport. No persistence/logging.
|
||||
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
|
||||
let states = new WeakMap();
|
||||
const identities = new Map();
|
||||
const MAX_SAMPLERS = 512;
|
||||
function prune() {
|
||||
const now = Date.now();
|
||||
for (const [key, state] of identities) {
|
||||
state.points = state.points.filter((p) => now - p.at <= 30 * 60000);
|
||||
if (!state.pending && now - state.at > 30 * 60000) identities.delete(key);
|
||||
}
|
||||
}
|
||||
const cleanupTimer = setInterval(prune, 60000);
|
||||
cleanupTimer.unref?.();
|
||||
function stateFor(conn, endpoint) {
|
||||
const key = endpoint ? buildEndpointKey(endpoint) : null;
|
||||
let state = (key ? identities.get(key) : null) || states.get(conn);
|
||||
if (!state) {
|
||||
state = {
|
||||
context: { conn },
|
||||
pending: null,
|
||||
latest: null,
|
||||
at: 0,
|
||||
points: [],
|
||||
controller: null,
|
||||
latestFull: false,
|
||||
pendingFull: false,
|
||||
fullResult: null,
|
||||
fullAt: 0,
|
||||
};
|
||||
}
|
||||
states.set(conn, state);
|
||||
if (key) {
|
||||
identities.delete(key);
|
||||
identities.set(key, state);
|
||||
if (identities.size > MAX_SAMPLERS) {
|
||||
for (const [oldKey, oldState] of identities) {
|
||||
if (!oldState.pending && oldKey !== key) {
|
||||
oldState.points = [];
|
||||
identities.delete(oldKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function appendPoint(state, result, now, freshAfter = 0) {
|
||||
if (
|
||||
!result.success ||
|
||||
!result.stats ||
|
||||
(now - (state.points.at(-1)?.at ?? 0) < 1000 && (state.points.at(-1)?.at ?? 0) > freshAfter)
|
||||
)
|
||||
return;
|
||||
const s = result.stats;
|
||||
state.points.push({
|
||||
at: now,
|
||||
cpu: s.cpu,
|
||||
memory:
|
||||
s.memTotal > 0 && Number.isFinite(s.memUsed)
|
||||
? (s.memUsed / s.memTotal) * 100
|
||||
: null,
|
||||
disk: s.diskPercent,
|
||||
rx: s.networkReady ? s.netRxSpeed : null,
|
||||
tx: s.networkReady ? s.netTxSpeed : null,
|
||||
});
|
||||
state.points = state.points
|
||||
.filter((p) => now - p.at <= 30 * 60000)
|
||||
.slice(-1800);
|
||||
}
|
||||
async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
|
||||
if (!conn) {
|
||||
const result = await collect(session);
|
||||
const companion = session.moshStatsConn || session.etStatsConn;
|
||||
if (companion && result.success) {
|
||||
const state = stateFor(companion);
|
||||
state.context.prevNetStats = session.prevNetStats;
|
||||
state.context.prevCpuStats = session.prevCpuStats;
|
||||
state.latest = result;
|
||||
state.at = Date.now();
|
||||
state.latestFull = true;
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const state = stateFor(conn, session._reuseEndpoint);
|
||||
const full = !session._monitorStats;
|
||||
const cacheMs = full ? 4000 : Math.min(4000, session._monitorIntervalMs ?? 5000);
|
||||
if (full && state.fullResult?.success && now - state.fullAt < 4000)
|
||||
return state.fullResult;
|
||||
if (state.pending) {
|
||||
if (full) state.terminalJoined = true;
|
||||
if (!full || state.pendingFull) {
|
||||
const result = await state.pending;
|
||||
// A reset can join a terminal query that began before the new boundary.
|
||||
if (!full && state.at > session._monitorFreshAfter)
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process/GPU fields omitted from a monitor-only query.
|
||||
// Finish the shared lightweight query, then upgrade once for all full readers.
|
||||
await state.pending;
|
||||
return sampleSharedStats(session, collect);
|
||||
}
|
||||
if (
|
||||
state.latest?.success &&
|
||||
now - state.at < cacheMs &&
|
||||
(full || state.at > (session._monitorFreshAfter ?? 0)) &&
|
||||
(!full || state.latestFull)
|
||||
)
|
||||
return state.latest;
|
||||
const collectFull = full;
|
||||
// Keep transport capabilities on the collection context; baseline is independent of a terminal.
|
||||
const companion = !session.conn;
|
||||
Object.assign(state.context, {
|
||||
conn: companion ? null : conn,
|
||||
moshStatsConn: session.moshStatsConn,
|
||||
etStatsConn: session.etStatsConn,
|
||||
type: session.type,
|
||||
singleChannelSsh: session.singleChannelSsh,
|
||||
hostname: session.hostname,
|
||||
_monitorStats: !collectFull,
|
||||
});
|
||||
state.controller = new AbortController();
|
||||
state.terminalJoined = full;
|
||||
state.pendingFull = collectFull;
|
||||
state.context.statsSignal = state.controller.signal;
|
||||
state.pending = Promise.resolve(collect(state.context))
|
||||
.then((result) => {
|
||||
if (!state.controller.signal.aborted && result.success) {
|
||||
state.latest = result;
|
||||
state.latestFull = collectFull;
|
||||
state.at = Date.now();
|
||||
if (collectFull) {
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
}
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
state.pending = null;
|
||||
state.controller = null;
|
||||
// The cache retains measurements and baselines, never a live connection.
|
||||
state.context.conn = null;
|
||||
state.context.moshStatsConn = null;
|
||||
state.context.etStatsConn = null;
|
||||
});
|
||||
return state.pending;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
const state = states.get(conn);
|
||||
if (state && !state.terminalJoined) state.controller?.abort();
|
||||
}
|
||||
module.exports = {
|
||||
sampleSharedStats,
|
||||
readSharedStats,
|
||||
cancelSharedStats,
|
||||
resetSharedStatsForTests() {
|
||||
identities.clear();
|
||||
states = new WeakMap();
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,6 @@ for (const keepaliveInterval of [10000, 0]) {
|
||||
let connections = 0;
|
||||
let authenticated = 0;
|
||||
let pings = 0;
|
||||
let statsRequests = 0;
|
||||
const peers = new Set();
|
||||
const server = new Server({ hostKeys: [hostKey] }, (peer) => {
|
||||
peers.add(peer);
|
||||
@@ -42,7 +41,6 @@ for (const keepaliveInterval of [10000, 0]) {
|
||||
});
|
||||
peer.on("session", (accept) => {
|
||||
accept().on("exec", (acceptExec) => {
|
||||
statsRequests++;
|
||||
const stream = acceptExec();
|
||||
stream.exit(0);
|
||||
stream.end("NC_LATENCY_MARK|CPU:5|CORES:2|MEMINFO:8000 4000 100 900 0 0|DISKS:|NET:");
|
||||
@@ -91,8 +89,7 @@ for (const keepaliveInterval of [10000, 0]) {
|
||||
}
|
||||
assert.equal(connections, baseline, "stats must not create unauthenticated connections");
|
||||
assert.equal(authenticated, 1);
|
||||
assert.equal(pings, keepaliveInterval > 0 ? 1 : 0);
|
||||
assert.equal(statsRequests, 1, "immediate readers share one cached collection");
|
||||
assert.equal(pings, keepaliveInterval > 0 ? 3 : 0);
|
||||
assert.equal(conn._callbacks.length, 0);
|
||||
});
|
||||
}
|
||||
@@ -68,9 +68,6 @@ function unregisterTestConnectionTransport(sessionId) {
|
||||
}
|
||||
|
||||
function cancelTestConnection(sessionId) {
|
||||
require("../keyboardInteractiveHandler.cjs").cancelRequestsForSession(sessionId);
|
||||
require("../passphraseHandler.cjs").cancelPassphraseRequestsForSession(sessionId);
|
||||
require("../hostKeyVerifier.cjs").cancelRequestsForSession(sessionId);
|
||||
const transport = testConnectionTransports.get(sessionId);
|
||||
if (!transport) return { success: false, error: "Test connection not found" };
|
||||
const end = (item) => {
|
||||
@@ -1308,8 +1305,7 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
async function startSSHSession(event, options) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
const monitorMode = options._monitorMode === true;
|
||||
const testMode = options.testMode === true || monitorMode;
|
||||
const testMode = options.testMode === true;
|
||||
const sender = event.sender;
|
||||
const log = createSshDiagnosticLogger(
|
||||
!!options.sshDebugLogEnabled || process.env.NETCATTY_SSH_DEBUG === "1",
|
||||
@@ -2359,14 +2355,6 @@ function createStartSessionApi(ctx) {
|
||||
// auth-method cache write above is intentionally kept so a
|
||||
// successful test warms the same cache a real connection uses.
|
||||
clearAuthReadyTimer();
|
||||
if (monitorMode) {
|
||||
const { createTransport } = require("../sshConnectionPool.cjs");
|
||||
connRef = createTransport({ conn, chainConnections, endpoint: reuseEndpoint });
|
||||
unregisterTestConnectionTransport(sessionId);
|
||||
settled = true;
|
||||
resolve({ transport: connRef });
|
||||
return;
|
||||
}
|
||||
sendConnectionTestResult(sender, sessionId, true);
|
||||
settled = true;
|
||||
try { conn.end(); } catch { /* ignore */ }
|
||||
@@ -2605,7 +2593,7 @@ function createStartSessionApi(ctx) {
|
||||
// Destroy the connection to prevent further socket errors from leaking
|
||||
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
||||
try { conn.destroy(); } catch { }
|
||||
if (testMode && !monitorMode) {
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(sender, sessionId, false, visibleError);
|
||||
}
|
||||
settled = true;
|
||||
@@ -2636,7 +2624,7 @@ function createStartSessionApi(ctx) {
|
||||
sessionDecoders.delete(sessionId);
|
||||
teardownTransport();
|
||||
try { conn.destroy(); } catch { }
|
||||
if (testMode && !monitorMode) {
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(sender, sessionId, false, err.message);
|
||||
}
|
||||
settled = true;
|
||||
@@ -2723,7 +2711,7 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
if (!settled) {
|
||||
if (testMode && !monitorMode) {
|
||||
if (testMode) {
|
||||
sendConnectionTestResult(
|
||||
sender,
|
||||
sessionId,
|
||||
@@ -2752,7 +2740,6 @@ function createStartSessionApi(ctx) {
|
||||
// would auto-reject the prompt. Use the external scope so the
|
||||
// host-editor test dialog can still render password/2FA prompts.
|
||||
scope: testMode ? "external" : "terminal",
|
||||
allowSavePassword: !monitorMode,
|
||||
bootEpoch: options.bootEpoch,
|
||||
getAuthBanner: () => authBanner,
|
||||
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
|
||||
@@ -2804,7 +2791,7 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
// If authHandler is a function, it already handles keyboard-interactive
|
||||
|
||||
if (testMode && !monitorMode) {
|
||||
if (testMode) {
|
||||
// A connection test exercises the single configured credential,
|
||||
// skipping the full agent / ~/.ssh key / method fallback chain.
|
||||
// Password credentials still allow keyboard-interactive after
|
||||
|
||||
@@ -370,13 +370,3 @@ test("terminal starts keep the default terminal scope for jump-host prompts", as
|
||||
|
||||
assert.equal(captured.scope, undefined);
|
||||
});
|
||||
|
||||
test("monitor authentication returns a pooled transport without a shell, terminal session or test-result event", async () => {
|
||||
const { api, sent, calls, sender } = createHarness({ onConnect: conn => queueMicrotask(() => conn.emit("ready")) });
|
||||
const result = await api.startSSHSession({ sender }, { ...baseOptions(), testMode: false, _monitorMode: true });
|
||||
assert.ok(result.transport?.conn);
|
||||
assert.equal(calls.ends, 0);
|
||||
assert.equal(typeof result.transport.conn.shell, "undefined", "the harness cannot open a shell");
|
||||
assert.equal(sent.some(e => e.channel === "netcatty:test:result" || e.channel === "netcatty:exit"), false);
|
||||
require("../sshConnectionPool.cjs").discardTransport(result.transport);
|
||||
});
|
||||
@@ -53,7 +53,6 @@ const LEASE_KINDS = Object.freeze({
|
||||
sftp: "sftp",
|
||||
transfer: "transfer",
|
||||
forward: "forward",
|
||||
monitor: "monitor",
|
||||
});
|
||||
|
||||
/** @type {Map<string, object>} transportId -> transport */
|
||||
|
||||
@@ -457,11 +457,6 @@ function createBridgeRegistrar(context) {
|
||||
|
||||
// Register all IPC handlers
|
||||
sshBridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const stopMonitoring = () => terminalWorkerManager
|
||||
? terminalWorkerManager.request("netcatty:monitor:stop-all", {}, {}).catch(() => {})
|
||||
: sshBridge.stopAllMonitoring();
|
||||
electronModule.powerMonitor?.on("suspend", stopMonitoring);
|
||||
require("../plugins/shutdownCoordinator.cjs").registerPluginShutdown(stopMonitoring);
|
||||
sftpBridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
localFsBridge.registerHandlers(ipcMain);
|
||||
require("../bridges/localFileDragBridge.cjs").registerHandlers(ipcMain, {
|
||||
|
||||
@@ -303,10 +303,6 @@ function createPreloadApi(ctx) {
|
||||
markTerminalDataSessionOpen(result?.sessionId);
|
||||
return result.sessionId;
|
||||
},
|
||||
monitorUpdate: (panels) => ipcRenderer.invoke("netcatty:monitor:update", panels),
|
||||
monitorSnapshot: (cursor) => ipcRenderer.invoke("netcatty:monitor:snapshot", cursor),
|
||||
monitorStopAll: () => ipcRenderer.invoke("netcatty:monitor:stop-all"),
|
||||
monitorRetry: (hostId) => ipcRenderer.invoke("netcatty:monitor:retry", hostId),
|
||||
testConnection: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:test-connection", options);
|
||||
return result.sessionId;
|
||||
|
||||
@@ -2130,8 +2130,3 @@ html[data-theme-transition] *::after {
|
||||
--terminal-host-tree-drop-bg: var(--nc-term-host-tree-drop-bg, transparent);
|
||||
--terminal-host-tree-folder-fg: var(--nc-term-host-tree-folder-fg, var(--foreground));
|
||||
}
|
||||
|
||||
/* Monitoring's single-row header follows the available pane width. */
|
||||
@container monitor-main (max-width: 760px) {
|
||||
.monitor-header-action-label { display: none; }
|
||||
}
|
||||
@@ -320,6 +320,3 @@ export const STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS = 'netcatty_available_p
|
||||
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
|
||||
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
|
||||
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';
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
export {};
|
||||
declare global {
|
||||
interface NetcattyBridge {
|
||||
monitorUpdate?(
|
||||
panels: import("../../domain/monitorRuntime").MonitorPanelDemand[],
|
||||
): Promise<import("../../domain/monitorRuntime").MonitorSnapshot>;
|
||||
monitorSnapshot?(cursor?: import("../../domain/monitorRuntime").MonitorHistoryCursor): Promise<
|
||||
import("../../domain/monitorRuntime").MonitorSnapshot
|
||||
>;
|
||||
monitorStopAll?(): Promise<void>;
|
||||
monitorRetry?(hostId: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user