mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e0d029bb | ||
|
|
c2c4b6ec77 | ||
|
|
3f35872638 | ||
|
|
bace30578c | ||
|
|
8aeb6ade6f | ||
|
|
b6bff4df6b |
No files matched your search
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -45,9 +47,11 @@ 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:', request);
|
||||
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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>();
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { startPortForward } from '../infrastructure/services/portForwardingService';
|
||||
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
|
||||
interface OpenHostBrowserOptions {
|
||||
host: Host;
|
||||
sessionId?: string;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
const pendingOpens = new Map<string, Promise<void>>();
|
||||
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
|
||||
|
||||
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
|
||||
const value = style.getPropertyValue(`--${token}`).trim();
|
||||
return isValidHslToken(value) ? [[token, value]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
|
||||
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
|
||||
}
|
||||
|
||||
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
|
||||
await bridge.openHostBrowserUrl({ browserId, url });
|
||||
}
|
||||
|
||||
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
|
||||
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
|
||||
const existing = pendingOpens.get(options.browserId);
|
||||
if (existing) return existing;
|
||||
const opening = (async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
|
||||
throw new Error('The host browser is unavailable in this build');
|
||||
}
|
||||
const local = host.protocol === 'local';
|
||||
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
|
||||
id: `host-browser-${crypto.randomUUID()}`,
|
||||
label: `Browser: ${host.label || host.hostname}`,
|
||||
type: 'dynamic',
|
||||
hostId: host.id,
|
||||
localPort: 0,
|
||||
bindAddress: '127.0.0.1',
|
||||
status: 'inactive',
|
||||
createdAt: Date.now(),
|
||||
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
|
||||
knownHosts, { dedicatedTransport: true, prepareOnly: true });
|
||||
if (!result.success || (!local && !result.connectionOptions)) {
|
||||
throw new Error(result.error || 'Could not prepare the browser connection');
|
||||
}
|
||||
await bridge.openHostBrowser({
|
||||
browserId: options.browserId,
|
||||
embedded: true,
|
||||
local,
|
||||
hostId: host.id,
|
||||
label: host.label || host.hostname,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
themeTokens: currentBrowserThemeTokens(),
|
||||
connectionOptions: result.connectionOptions,
|
||||
});
|
||||
})();
|
||||
pendingOpens.set(options.browserId, opening);
|
||||
void opening.finally(() => {
|
||||
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
|
||||
}).catch(() => {});
|
||||
return opening;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
|
||||
let cachedBookmarks: BrowserBookmark[] | undefined;
|
||||
|
||||
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
|
||||
return cachedBookmarks?.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
|
||||
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
|
||||
if (!reader) return cachedBookmarks;
|
||||
const items = await reader();
|
||||
cachedBookmarks = items;
|
||||
return getCachedHostBrowserBookmarks();
|
||||
}
|
||||
|
||||
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
|
||||
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
|
||||
if (!writer) return;
|
||||
await writer(items);
|
||||
cachedBookmarks = items;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enMonitorMessages } from './en/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
@@ -9,6 +10,7 @@ import { enScriptsMessages } from './en/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const en: Messages = {
|
||||
...enMonitorMessages,
|
||||
...enCoreMessages,
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
|
||||
@@ -767,6 +767,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Browser',
|
||||
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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",
|
||||
};
|
||||
@@ -20,6 +20,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.browser.title': 'Browser',
|
||||
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
|
||||
'terminal.browser.open': 'Open Browser',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
@@ -531,6 +534,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { esMonitorMessages } from './es/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
@@ -9,6 +10,7 @@ import { esScriptsMessages } from './es/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esMonitorMessages,
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
|
||||
@@ -682,6 +682,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Navegador',
|
||||
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
|
||||
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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",
|
||||
};
|
||||
@@ -20,6 +20,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.browser.title': 'Navegador',
|
||||
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
|
||||
'terminal.browser.open': 'Abrir navegador',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
@@ -530,6 +533,7 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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,3 +1,4 @@
|
||||
import { ruMonitorMessages } from './ru/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
@@ -9,6 +10,7 @@ import { ruScriptsMessages } from './ru/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruMonitorMessages,
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
|
||||
@@ -718,6 +718,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Анимировать прокрутку области терминала вместо мгновенного перехода',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Браузер',
|
||||
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
|
||||
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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": "Сервер удалён",
|
||||
};
|
||||
@@ -41,6 +41,9 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.browser.title': 'Браузер',
|
||||
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
|
||||
'terminal.browser.open': 'Открыть браузер',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
@@ -549,6 +552,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhCNMonitorMessages } from './zh-CN/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhCN: Messages = {
|
||||
...zhCNMonitorMessages,
|
||||
...zhCNCoreMessages,
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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": "主机已删除",
|
||||
};
|
||||
@@ -403,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.sidePanel.pane.browser': '浏览器',
|
||||
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
|
||||
@@ -468,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.browser.title': '浏览器',
|
||||
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
|
||||
'terminal.browser.open': '打开浏览器',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -933,6 +936,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分组设置',
|
||||
'cloudSync.conflict.entity.settings': '设置',
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhTWMonitorMessages } from './zh-TW/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhTWCoreMessages } from './zh-TW/core';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhTW: Messages = {
|
||||
...zhTWMonitorMessages,
|
||||
...zhTWCoreMessages,
|
||||
...zhTWVaultMessages,
|
||||
...zhTWTerminalMessages,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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": "主機已刪除",
|
||||
};
|
||||
@@ -403,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
|
||||
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
|
||||
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
|
||||
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
|
||||
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
|
||||
|
||||
@@ -468,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '開啟 SFTP',
|
||||
'terminal.browser.title': '瀏覽器',
|
||||
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
|
||||
'terminal.browser.open': '開啟瀏覽器',
|
||||
'terminal.toolbar.availableAfterConnect': '連線後可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -933,6 +936,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '群組',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
|
||||
'cloudSync.conflict.entity.groupConfigs': '群組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
|
||||
|
||||
test('detached terminal requests reach the main window instead of being dropped', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const forwarded: HostBrowserPanelRequest[] = [];
|
||||
const delivered: HostBrowserPanelRequest[] = [];
|
||||
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
|
||||
let removed = false;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
|
||||
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
|
||||
nativeListener = listener;
|
||||
return () => { removed = true; nativeListener = undefined; };
|
||||
},
|
||||
} } });
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
|
||||
requestHostBrowserPanel(request);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(forwarded, [request]);
|
||||
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
|
||||
assert.ok(nativeListener);
|
||||
nativeListener(request);
|
||||
assert.deepEqual(delivered, [request]);
|
||||
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
|
||||
unsubscribe(); unsubscribe = undefined;
|
||||
assert.equal(removed, true);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
|
||||
type Listener = (request: HostBrowserPanelRequest) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: HostBrowserPanelRequest | null = null;
|
||||
let unsubscribeNative: (() => void) | undefined;
|
||||
|
||||
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
|
||||
if (!listeners.size) {
|
||||
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
|
||||
if (forward) {
|
||||
void forward(request).catch(() => { pending = request; });
|
||||
} else pending = request;
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) listener(request);
|
||||
}
|
||||
|
||||
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
|
||||
for (const current of listeners) current(request);
|
||||
});
|
||||
if (pending) {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
listener(request);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,6 +7,55 @@ 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,7 +721,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
|
||||
|
||||
@@ -1137,7 +1136,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
} finally {
|
||||
// Walk registry cleanup is owned by transferRuntime.runWalk.
|
||||
// Drop ephemeral pool holds opened when browse was soft-closed.
|
||||
|
||||
@@ -5,6 +5,8 @@ import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
@@ -20,6 +22,60 @@ const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('workspace browser follows focus without replacing the earlier terminal context', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const browserSlot = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'SidePanelBrowserSlot');
|
||||
assert.ok(browserSlot);
|
||||
const slotCode = ts.transpile(browserSlot.getText(ast), { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React });
|
||||
const Slot = vm.runInNewContext(`${slotCode}; SidePanelBrowserSlot`, {
|
||||
React, useRef, useSidePanelLiveSnapshotForTab: useSnapshot, resolveSystemSidebarSession, collectSessionIds,
|
||||
sidePanelHiddenPanelClassName: (hidden: boolean) => hidden ? 'hidden' : 'visible',
|
||||
HostBrowserSidePanel: (props: object) => React.createElement('div', { ...props, 'data-browser-context': true }),
|
||||
}) as React.ComponentType<{ tabId: string; ctx: object; isVisible: boolean }>;
|
||||
const sessionA = { id: 'session-a', hostId: 'host-a', protocol: 'ssh' };
|
||||
const sessionB = { id: 'session-b', hostId: 'host-b', protocol: 'ssh' };
|
||||
const workspace = { id: 'workspace', focusedSessionId: sessionA.id, root: {
|
||||
id: 'split', type: 'split', direction: 'vertical', sizes: [0.5, 0.5], children: [
|
||||
{ id: 'a', type: 'pane', sessionId: sessionA.id }, { id: 'b', type: 'pane', sessionId: sessionB.id },
|
||||
],
|
||||
} } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const ctx = { sessions: [sessionA, sessionB], workspaceById: new Map([['workspace', workspace]]),
|
||||
sessionHostsMap: new Map([['session-a', { id: 'host-a', hostname: 'a' }], ['session-b', { id: 'host-b', hostname: 'b' }]]),
|
||||
sftpAuthHostBySessionId: new Map(), effectiveHosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, t: (key: string) => key };
|
||||
const publish = (session: typeof sessionA) => sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
activeWorkspace: workspace, focusedSessionId: session.id,
|
||||
activeTerminalSessionForSystem: session as SidePanelLiveSnapshot['activeTerminalSessionForSystem'] });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
publish(sessionA);
|
||||
await act(async () => { renderer = create(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: true })); });
|
||||
const contexts = () => renderer!.root.findAll((node) => node.type === 'div' && node.props['data-browser-context'] === true);
|
||||
const first = contexts()[0];
|
||||
assert.equal(first.props.host.id, 'host-a');
|
||||
await act(async () => publish(sessionB));
|
||||
assert.equal(contexts().length, 2);
|
||||
assert.equal(contexts()[0], first, 'the first terminal keeps the same browser mount');
|
||||
assert.equal(first.props.isVisible, false);
|
||||
assert.equal(contexts()[1].props.host.id, 'host-b');
|
||||
assert.equal(contexts()[1].props.isVisible, true);
|
||||
await act(async () => publish(sessionA));
|
||||
assert.equal(first.props.isVisible, true);
|
||||
assert.equal(contexts()[1].props.isVisible, false);
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.ok(contexts().every((context) => !context.props.isVisible), 'hidden tabs hide every native browser context');
|
||||
ctx.workspaceById.set('workspace', { ...workspace, focusedSessionId: sessionB.id,
|
||||
root: { id: 'b', type: 'pane', sessionId: sessionB.id } });
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.equal(contexts().length, 1, 'a detached session must leave the old workspace even while it still exists globally');
|
||||
assert.equal(contexts()[0].props.host.id, 'host-b', 'the remaining workspace browser stays mounted');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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,3 +1,5 @@
|
||||
import { readMonitorPanels } from './monitorPanelsStorage';
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
|
||||
/**
|
||||
* useAutoSync - Auto-sync Hook for Cloud Sync
|
||||
*
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
} from './autoSyncRemoteSchedule';
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
import { getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import { collectHostBrowserBookmarks } from '../hostBrowserBookmarks';
|
||||
|
||||
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
|
||||
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
|
||||
@@ -150,6 +153,7 @@ const isRestoreInProgress = (): boolean => {
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
monitorPanels: payload.monitorPanels,
|
||||
hosts: sanitizeHostsForSync(payload.hosts),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
@@ -160,6 +164,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
notes: payload.notes,
|
||||
noteGroups: payload.noteGroups,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
browserBookmarks: payload.browserBookmarks,
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
@@ -224,6 +229,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
const [hostBrowserBookmarksVersion, setHostBrowserBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onHostBrowserBookmarksChanged?.(() => {
|
||||
setHostBrowserBookmarksVersion((version) => version + 1);
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handler = () => setBookmarksVersion((v) => v + 1);
|
||||
window.addEventListener('sftp-bookmarks-changed', handler);
|
||||
@@ -252,7 +264,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 && !chatChanged) return;
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -274,6 +286,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
return {
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts: sanitizeHostsForSync(config.hosts),
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
@@ -309,9 +322,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
const base: SyncPayload = {
|
||||
...getSyncSnapshot(),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1125,6 +1140,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
@@ -1142,6 +1158,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
browserId: string;
|
||||
host: Host;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts: KnownHost[];
|
||||
terminalSettings: TerminalSettings;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
const opened = useRef(false);
|
||||
const mountedBrowserId = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { browserId, isVisible } = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let previous = '';
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native page views must leave the screen with their owning pane and
|
||||
// must not cover a renderer dialog or a toolbar's open menu.
|
||||
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || opened.current) return;
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt, browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedBrowserId.current = browserId;
|
||||
return () => {
|
||||
opened.current = false;
|
||||
mountedBrowserId.current = null;
|
||||
// StrictMode replays setup immediately. Do not destroy its pending URL
|
||||
// or native browser unless the owning panel actually remains unmounted.
|
||||
queueMicrotask(() => {
|
||||
if (mountedBrowserId.current === browserId) return;
|
||||
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
|
||||
});
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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,3 +1,5 @@
|
||||
import { readMonitorPanels, writeMonitorPanels } from './monitorPanelsStorage';
|
||||
import type { MonitorPanel } from '../../domain/monitorPanels';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
hostsEqualForIdentityReuse,
|
||||
@@ -124,6 +126,7 @@ import {
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
monitorPanels?: MonitorPanel[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
@@ -1188,6 +1191,7 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
void withVaultImportLock('vault', async () => writeMonitorPanels([]));
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
@@ -1972,6 +1976,7 @@ export const useVaultState = () => {
|
||||
|
||||
const exportData = useCallback(
|
||||
(): ExportableVaultData => ({
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -1990,6 +1995,7 @@ 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
|
||||
|
||||
@@ -86,6 +86,13 @@ test('saved default resolves per session with fresh ids and protocol-aware pruni
|
||||
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(again);
|
||||
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
|
||||
await act(async () => {
|
||||
state?.saveWorkspaceLayoutAsDefault(createSidePanelLayout('browser', 'browser-pane'));
|
||||
});
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'telnet' }), null);
|
||||
for (const protocol of ['ssh', 'mosh', 'local']) {
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol })?.focusedTool, 'browser');
|
||||
}
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
|
||||
@@ -35,7 +35,7 @@ export type ResolvedWorkspaceLayoutPreset = {
|
||||
export function useWorkspaceLayoutPresetState() {
|
||||
/**
|
||||
* Resolve the saved default preset into a fresh layout for a newly
|
||||
* connected session. Panes the session cannot serve (SFTP on non-file
|
||||
* connected session. Panes the session cannot serve (SFTP/Browser on non-file
|
||||
* protocols) are pruned, and ids are rekeyed so several workspaces can
|
||||
* run the same preset in parallel. Returns null when there is no preset
|
||||
* or nothing survives availability filtering.
|
||||
@@ -48,7 +48,7 @@ export function useWorkspaceLayoutPresetState() {
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const usable = withoutUnavailableSidePanelTools(
|
||||
preset.layout,
|
||||
(tool) => tool !== 'sftp' || canServeSftp(proto),
|
||||
(tool) => (tool !== 'sftp' && tool !== 'browser') || canServeSftp(proto),
|
||||
);
|
||||
if (!usable) return null;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
@@ -8,6 +9,8 @@ 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,3 +1,4 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -38,6 +39,8 @@ function installLocalStorage(): LocalStorageMock {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
@@ -1420,6 +1423,26 @@ test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault reco
|
||||
);
|
||||
});
|
||||
|
||||
test("surviving browser bookmarks do not suppress empty-vault recovery", () => {
|
||||
const local: SyncPayload = {
|
||||
...vault([]), syncedAt: 1,
|
||||
browserBookmarks: [{
|
||||
id: "app", title: "App", url: "https://app.example.com/", scope: "all-hosts",
|
||||
hostId: null, icon: "A", color: "#247969", order: 0, createdAt: 1, updatedAt: 1,
|
||||
}],
|
||||
};
|
||||
assert.equal(hasMeaningfulCloudSyncData(local), true, "bookmarks must remain syncable");
|
||||
assert.equal(hasCloudSyncEntityData(local), false);
|
||||
const host = {
|
||||
id: "remote", label: "Remote", hostname: "remote.example.com", username: "test",
|
||||
port: 22, protocol: "ssh" as const, tags: [], os: "linux" as const,
|
||||
};
|
||||
for (const hosts of [[host], [host, { ...host, id: "second" }]]) {
|
||||
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault([]), hosts, syncedAt: 2 }), true);
|
||||
}
|
||||
assert.equal(shouldPromptCloudVaultRecovery({ ...vault([]), syncedAt: 1 }, local), false);
|
||||
});
|
||||
|
||||
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
|
||||
const settingsOnlyPayload: SyncPayload = {
|
||||
hosts: [],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
readCommandBlocklistSetting,
|
||||
} from './state/commandBlocklistSettings';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import { applyHostBrowserBookmarks, collectHostBrowserBookmarks, getCachedHostBrowserBookmarks } from './hostBrowserBookmarks';
|
||||
import {
|
||||
clampTerminalFontSizeValue,
|
||||
nextTerminalFontSizeSyncVersion,
|
||||
@@ -213,11 +216,11 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
|
||||
/**
|
||||
* Returns true only when the payload contains synced vault entities.
|
||||
* Settings and chat history are ignored: neither proves that the vault's
|
||||
* Settings, chat history, and browser bookmarks are ignored: none proves that the vault's
|
||||
* hosts and keys survived local data loss.
|
||||
*/
|
||||
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions'));
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions' && key !== 'browserBookmarks'));
|
||||
}
|
||||
|
||||
export function shouldPromptCloudVaultRecovery(
|
||||
@@ -1077,6 +1080,7 @@ function buildVaultSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
const browserBookmarks = getCachedHostBrowserBookmarks();
|
||||
return {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
keys: vault.keys,
|
||||
@@ -1088,7 +1092,9 @@ function buildVaultSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1163,6 +1169,7 @@ export async function buildCloudSyncPayload(
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
const base: SyncPayload = {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
keys: vault.keys,
|
||||
@@ -1174,8 +1181,10 @@ export async function buildCloudSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1226,6 +1235,8 @@ export async function buildLocalVaultPayloadAsync(
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
const base = buildLocalVaultPayload(vault, portForwardingRules);
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
if (browserBookmarks !== undefined) base.browserBookmarks = browserBookmarks;
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
@@ -1352,6 +1363,7 @@ 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;
|
||||
@@ -1393,6 +1405,7 @@ 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
|
||||
@@ -1400,6 +1413,9 @@ async function preparePayloadApply(
|
||||
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
}
|
||||
if (sanitizedPayload.browserBookmarks !== undefined) {
|
||||
await applyHostBrowserBookmarks(sanitizedPayload.browserBookmarks);
|
||||
}
|
||||
|
||||
// AI chat sessions merge append-only: a cloud snapshot may be missing
|
||||
// turns that only exist on this device, so union instead of replace.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
|
||||
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
|
||||
const { elementRef, error, retry } = useHostBrowserPanel(props);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{error && <div className="p-4 text-sm text-muted-foreground">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
FolderPlus,
|
||||
PlugZap,
|
||||
Plus,
|
||||
Settings2,
|
||||
Tag,
|
||||
@@ -61,6 +62,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
import {
|
||||
ChainPanel,
|
||||
@@ -1015,16 +1017,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostConnectionTest.title")}
|
||||
>
|
||||
<PlugZap size={16} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("hostConnectionTest.title")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -1346,19 +1371,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="flex flex-row gap-2">
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-4"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("hostConnectionTest.test")}
|
||||
</Button>
|
||||
)}
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
className="flex-1 h-10"
|
||||
className="w-full h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
|
||||
@@ -282,6 +282,7 @@ interface HostTreeViewProps {
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -322,6 +323,7 @@ interface TreeNodeProps {
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -368,6 +370,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -584,6 +587,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -627,6 +631,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -660,6 +665,7 @@ interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -706,6 +712,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -871,6 +878,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyHostname={onCopyHostname}
|
||||
@@ -890,6 +898,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -1199,6 +1208,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -1240,6 +1250,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
host={item.host}
|
||||
depth={item.depth}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -1289,6 +1300,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onExpandAll === next.onExpandAll
|
||||
&& prev.onCollapseAll === next.onCollapseAll
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onOpenBrowser === next.onOpenBrowser
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onDuplicateHost === next.onDuplicateHost
|
||||
&& prev.onDeleteHost === next.onDeleteHost
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SelectHostPanelProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onBack: () => void;
|
||||
onClose?: () => void;
|
||||
onContinue?: () => void;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
@@ -25,6 +26,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
@@ -42,6 +44,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onBack,
|
||||
onClose,
|
||||
onContinue,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
@@ -53,6 +56,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
headerClassName,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
@@ -78,8 +82,9 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onBack}
|
||||
onClose={onClose ?? onBack}
|
||||
title={panelTitle}
|
||||
headerClassName={headerClassName}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
|
||||
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
@@ -487,9 +487,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
aria-label={t('selectHost.toggleHost', { name: host.label })}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
// Keep keyboard cursor visible even when the host is already selected.
|
||||
isActive && 'ring-1 ring-primary/40',
|
||||
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
|
||||
+20
-1
@@ -130,6 +130,7 @@ import {
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { openHostBrowser } from "../application/hostBrowser";
|
||||
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
|
||||
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
@@ -4187,6 +4188,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
|
||||
}, [sessionId, t]);
|
||||
|
||||
const handleOpenHostBrowser = useCallback(async () => {
|
||||
try {
|
||||
await openHostBrowser({
|
||||
sessionId,
|
||||
host,
|
||||
hosts: [host, ...resolvedChainHosts],
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Could not open the host browser');
|
||||
}
|
||||
}, [sessionId, host, resolvedChainHosts, keys, identities, terminalSettings, knownHosts]);
|
||||
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
sessionId={sessionId}
|
||||
@@ -4199,6 +4216,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onDeleteSnippets={onDeleteSnippets}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onOpenBrowser={(!host.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void handleOpenHostBrowser(); } : undefined}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
@@ -4252,6 +4270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
compactToolbar,
|
||||
executeSnippet,
|
||||
handleOpenSFTP,
|
||||
handleOpenHostBrowser,
|
||||
handleReceiveYmodem,
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
@@ -4629,7 +4648,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { subscribeHostBrowserPanelRequests } from '../application/state/hostBrowserPanelRequests';
|
||||
import { openHostBrowserPanelUrl } from '../application/hostBrowser';
|
||||
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
|
||||
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
@@ -488,6 +490,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'sftp') {
|
||||
@@ -659,6 +663,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
@@ -770,6 +776,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
|
||||
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
|
||||
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
|
||||
const [browserMountedTabIds, setBrowserMountedTabIds] = useState<string[]>([]);
|
||||
const [notesMountedTabIds, setNotesMountedTabIds] = useState<string[]>([]);
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
@@ -1009,6 +1016,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesOpenNoteByTab((previous) => {
|
||||
const next = new Map(previous);
|
||||
@@ -1549,6 +1557,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
return;
|
||||
}
|
||||
if (panel === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
return;
|
||||
}
|
||||
if (panel === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
@@ -1888,6 +1900,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
if (closingPane?.tool === 'browser') {
|
||||
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
}
|
||||
if (shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: closingPane?.tool,
|
||||
closesWholePanel: closesWholePanel,
|
||||
@@ -1974,6 +1989,39 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('system');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
handleSwitchSidePanelTab('browser');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
useEffect(() => subscribeHostBrowserPanelRequests(({ host, sessionId, url }) => {
|
||||
const session = sessionsRef.current.find((candidate) => sessionId
|
||||
? candidate.id === sessionId : !candidate.hiddenFromTabs && candidate.hostId === host.id
|
||||
&& (!candidate.protocol || ['ssh', 'mosh', 'local'].includes(candidate.protocol)));
|
||||
let tabId: string | null = null;
|
||||
if (session) {
|
||||
tabId = session.workspaceId ?? session.id;
|
||||
if (session.workspaceId) onSetWorkspaceFocusedSessionRef.current?.(session.workspaceId, session.id);
|
||||
activeTabStore.setActiveTabId(tabId);
|
||||
} else {
|
||||
const opened = onConnectToHost(host);
|
||||
if (typeof opened === 'string') tabId = opened;
|
||||
}
|
||||
if (!tabId) return;
|
||||
if (url) {
|
||||
void openHostBrowserPanelUrl(`${tabId}:${session?.id ?? tabId}`, url)
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
setBrowserMountedTabIds((previous) => addMountedSidePanelTabId(previous, tabId));
|
||||
setSidePanelOpenTabs((previous) => new Map(previous).set(tabId, 'browser'));
|
||||
}), [onConnectToHost, setSidePanelOpenTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserMountedTabIds((previous) => {
|
||||
const next = previous.filter((tabId) => validAIScopeTargetIds.has(tabId));
|
||||
return next.length === previous.length ? previous : next;
|
||||
});
|
||||
}, [validAIScopeTargetIds]);
|
||||
|
||||
const handleOpenNotes = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
@@ -2546,6 +2594,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
handleRestoreMagnifiedPane,
|
||||
@@ -2612,6 +2661,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
mountedSftpTabIds,
|
||||
magnifiedPane,
|
||||
notesMountedTabIds,
|
||||
browserMountedTabIds,
|
||||
notesOpenNoteByTab,
|
||||
scriptsMountedTabIds,
|
||||
systemMountedTabIds,
|
||||
|
||||
@@ -170,6 +170,7 @@ const SnippetsManager = lazy(() => import("./SnippetsManager"));
|
||||
|
||||
export type VaultSection =
|
||||
| "hosts"
|
||||
| "monitor"
|
||||
| "keys"
|
||||
| "proxies"
|
||||
| "snippets"
|
||||
@@ -1615,6 +1616,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
NotebookText,
|
||||
NotesManager,
|
||||
onCommitPluginImporterData,
|
||||
onConnect,
|
||||
onConnectSerial,
|
||||
onCreateLocalTerminal,
|
||||
onDeleteHost,
|
||||
|
||||
@@ -575,7 +575,9 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
|
||||
customGroups: 'cloudSync.conflict.entity.customGroups',
|
||||
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Globe2, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -14,6 +14,7 @@ import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
|
||||
export interface HostTreeHostContextMenuHandlers {
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameHost?: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
@@ -33,11 +34,18 @@ export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenHostBrowserMenuItem: React.FC<{ host: Host; onOpenBrowser?: (host: Host) => void }> = ({ host, onOpenBrowser }) => {
|
||||
const { t } = useI18n();
|
||||
if (!onOpenBrowser || (host.protocol && !['ssh', 'mosh', 'local'].includes(host.protocol))) return null;
|
||||
return <ContextMenuItem onClick={() => onOpenBrowser(host)}><Globe2 className="mr-2 h-4 w-4" /> {t('terminal.browser.open')}</ContextMenuItem>;
|
||||
};
|
||||
|
||||
export const HostTreeHostContextMenuContent: React.FC<
|
||||
HostTreeHostContextMenuHandlers & { host: Host }
|
||||
> = ({
|
||||
host,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onRenameHost,
|
||||
onDuplicateHost,
|
||||
@@ -65,6 +73,7 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={safeHost} />
|
||||
<OpenHostBrowserMenuItem host={safeHost} onOpenBrowser={onOpenBrowser} />
|
||||
{onEditHost && (
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
|
||||
|
||||
@@ -34,13 +34,9 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
|
||||
assert.match(source, /navigableIndexByKey/);
|
||||
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
|
||||
assert.doesNotMatch(source, /navigable\.findIndex/);
|
||||
// Selected hosts must still show the keyboard active ring.
|
||||
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
|
||||
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
|
||||
);
|
||||
// The keyboard cursor stays visible on selected hosts through background alone.
|
||||
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
|
||||
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
|
||||
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
|
||||
// One listbox tab stop — not per-host tabIndex under virtualization.
|
||||
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
|
||||
|
||||
@@ -367,6 +367,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-link-browser"
|
||||
label={t("settings.terminal.behavior.linkBrowser")}
|
||||
description={t("settings.terminal.behavior.linkBrowser.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.linkBrowser ?? 'netcatty'}
|
||||
options={[
|
||||
{ value: 'netcatty', label: t('settings.terminal.behavior.linkBrowser.netcatty') },
|
||||
{ value: 'system', label: t('settings.terminal.behavior.linkBrowser.system') },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting('linkBrowser', v as 'netcatty' | 'system')}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.linkModifier")}
|
||||
description={t("settings.terminal.behavior.linkModifier.desc")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Globe2,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
@@ -65,6 +66,7 @@ export interface TerminalContextMenuProps {
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -224,6 +226,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
onClose,
|
||||
onOpenBrowser,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
@@ -410,6 +413,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onOpenBrowser && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onOpenBrowser}>
|
||||
<Globe2 size={14} className="mr-2" /> {t('terminal.browser.open')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReconnectAction && (
|
||||
<>
|
||||
|
||||
@@ -68,6 +68,21 @@ test("keeps SFTP visible before the terminal overflow menu for SSH sessions", ()
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("browser action remains available when an SSH terminal is disconnected", () => {
|
||||
const disconnectedSsh = renderToolbar(sshHost, "disconnected", { onOpenBrowser: () => {} });
|
||||
const serial = renderToolbar(serialHost, "connected", { onOpenBrowser: () => {} });
|
||||
assert.match(disconnectedSsh, /aria-label="Open Browser"/);
|
||||
assert.doesNotMatch(disconnectedSsh, /aria-label="Open Browser"[^>]*disabled/);
|
||||
assert.doesNotMatch(serial, /aria-label="Open Browser"/);
|
||||
});
|
||||
|
||||
test("browser action is available for supported local and mosh terminals", () => {
|
||||
for (const protocol of ['local', 'mosh'] as const) {
|
||||
const markup = renderToolbar({ ...sshHost, protocol }, "connected", { onOpenBrowser: () => {} });
|
||||
assert.match(markup, /aria-label="Open Browser"/);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps Scripts visible before the terminal overflow menu", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FileText,
|
||||
FolderInput,
|
||||
FolderSync,
|
||||
Globe2,
|
||||
Highlighter,
|
||||
History,
|
||||
Languages,
|
||||
@@ -44,6 +45,7 @@ import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'highlight',
|
||||
'sftp',
|
||||
'browser',
|
||||
'ymodemSend',
|
||||
'ymodemReceive',
|
||||
'compose',
|
||||
@@ -65,6 +67,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
placement: {
|
||||
highlight: 'show',
|
||||
sftp: 'show',
|
||||
browser: 'show',
|
||||
ymodemSend: 'show',
|
||||
ymodemReceive: 'show',
|
||||
compose: 'show',
|
||||
@@ -94,6 +97,7 @@ export interface TerminalToolbarProps {
|
||||
/** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */
|
||||
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
|
||||
onOpenSFTP: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
onOpenScripts: () => void;
|
||||
@@ -133,6 +137,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSnippetClick,
|
||||
onDeleteSnippets,
|
||||
onOpenSFTP,
|
||||
onOpenBrowser,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
onOpenScripts,
|
||||
@@ -238,6 +243,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
'terminalSettings',
|
||||
];
|
||||
if (!hidesSftp) ids.push('sftp');
|
||||
if (onOpenBrowser && (!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol))) ids.push('browser');
|
||||
if (isSerialTerminal) {
|
||||
ids.push('ymodemSend', 'ymodemReceive');
|
||||
}
|
||||
@@ -253,6 +259,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
historySupported,
|
||||
isSerialTerminal,
|
||||
onConfigureOsc7,
|
||||
onOpenBrowser,
|
||||
host?.protocol,
|
||||
onSetTerminalEncoding,
|
||||
onStartRecording,
|
||||
showLogButton,
|
||||
@@ -262,6 +270,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
(): Record<TerminalToolbarItemId, string> => ({
|
||||
highlight: t('terminal.toolbar.hostHighlight.title'),
|
||||
sftp: t('terminal.toolbar.openSftp'),
|
||||
browser: t('terminal.browser.open'),
|
||||
ymodemSend: t('terminal.toolbar.sendYmodem'),
|
||||
ymodemReceive: t('terminal.toolbar.receiveYmodem'),
|
||||
compose: t('terminal.toolbar.composeBar'),
|
||||
@@ -283,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
(): Record<TerminalToolbarItemId, React.ReactNode> => ({
|
||||
highlight: <Highlighter size={14} />,
|
||||
sftp: <FolderInput size={14} />,
|
||||
browser: <Globe2 size={14} />,
|
||||
ymodemSend: <Upload size={14} />,
|
||||
ymodemReceive: <Download size={14} />,
|
||||
compose: <TextCursorInput size={14} />,
|
||||
@@ -316,7 +326,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
);
|
||||
|
||||
const { shown, collapsed } = useMemo(() => {
|
||||
const available = new Set(availableIds);
|
||||
const available = new Set<string>(availableIds);
|
||||
const shownIds: string[] = [];
|
||||
const collapsedIds: string[] = [];
|
||||
for (const id of toolbarLayout.layout.order) {
|
||||
@@ -519,6 +529,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'browser':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className={buttonBase}
|
||||
aria-label={t('terminal.browser.open')} onClick={onOpenBrowser}>
|
||||
<Globe2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.browser.open')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'ymodemSend':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -818,6 +840,15 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'browser':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenBrowser}>
|
||||
<Globe2 size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t('terminal.browser.open')}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'ymodemSend':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
|
||||
@@ -586,6 +586,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
|
||||
onSaveScreen={terminalContextActions.onSaveScreen}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
} from "./terminalUserPaste";
|
||||
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
|
||||
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
|
||||
import { requestHostBrowserPanel } from "../../../application/state/hostBrowserPanelRequests";
|
||||
import {
|
||||
consumeOsc133CommandCompletion,
|
||||
type PromptLineBreakState,
|
||||
@@ -576,6 +577,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
const terminalLinkHandler = createTerminalLinkHandler({
|
||||
canActivate: canActivateTerminalLink,
|
||||
useInternal: () => ctx.terminalSettingsRef.current?.linkBrowser !== 'system',
|
||||
openInternal: ['ssh', 'mosh', 'local'].includes(ctx.host.protocol ?? 'ssh')
|
||||
? (url) => requestHostBrowserPanel({ host: ctx.host, sessionId: ctx.sessionId, url })
|
||||
: undefined,
|
||||
openExternalAvailable: ctx.terminalBackend.openExternalAvailable,
|
||||
openExternal: ctx.terminalBackend.openExternal,
|
||||
confirmOscLink: (uri) => window.confirm(
|
||||
|
||||
@@ -11,6 +11,42 @@ const click = {
|
||||
shiftKey: false,
|
||||
} as MouseEvent;
|
||||
|
||||
test("terminal links default to the internal browser and apply settings immediately", async () => {
|
||||
const internal: string[] = [];
|
||||
const external: string[] = [];
|
||||
let systemBrowser = false;
|
||||
const handler = createTerminalLinkHandler({
|
||||
canActivate: () => true,
|
||||
openExternalAvailable: () => true,
|
||||
openExternal: async (url) => { external.push(url); },
|
||||
openInternal: (url) => { internal.push(url); },
|
||||
useInternal: () => !systemBrowser,
|
||||
confirmOscLink: () => true,
|
||||
});
|
||||
await handler.open('http://localhost:3000/');
|
||||
systemBrowser = true;
|
||||
await handler.open('https://example.com/');
|
||||
assert.deepEqual(internal, ['http://localhost:3000/']);
|
||||
assert.deepEqual(external, ['https://example.com/']);
|
||||
});
|
||||
|
||||
test("internal browser failures never fall back to the local system browser", async () => {
|
||||
const errors: unknown[] = [];
|
||||
let external = 0;
|
||||
const handler = createTerminalLinkHandler({
|
||||
canActivate: () => true,
|
||||
openExternalAvailable: () => true,
|
||||
openExternal: async () => { external += 1; },
|
||||
openInternal: () => { throw new Error('Disconnected'); },
|
||||
confirmOscLink: () => true,
|
||||
onError: (error) => errors.push(error),
|
||||
warn: () => {},
|
||||
});
|
||||
await handler.open('http://localhost:3000/');
|
||||
assert.equal(external, 0);
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
|
||||
test("OSC 8 links use the Netcatty external browser bridge", async () => {
|
||||
const opened: string[] = [];
|
||||
const confirmed: string[] = [];
|
||||
|
||||
@@ -7,6 +7,8 @@ export type TerminalLinkHandlerOptions = {
|
||||
canActivate: (event: TerminalLinkClickEvent) => boolean;
|
||||
openExternalAvailable: () => boolean;
|
||||
openExternal: (uri: string) => Promise<void>;
|
||||
openInternal?: (uri: string) => Promise<void> | void;
|
||||
useInternal?: () => boolean;
|
||||
confirmOscLink: (uri: string) => boolean;
|
||||
openWindow?: (uri: string) => unknown;
|
||||
onError?: (error: unknown) => void;
|
||||
@@ -31,6 +33,10 @@ export function createTerminalLinkHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.openInternal && (options.useInternal?.() ?? true)) {
|
||||
await options.openInternal(uri);
|
||||
return;
|
||||
}
|
||||
if (options.openExternalAvailable()) {
|
||||
await options.openExternal(uri);
|
||||
return;
|
||||
|
||||
@@ -84,6 +84,7 @@ test('side panel tab order falls back to the default order', () => {
|
||||
assert.deepEqual(normalizeTerminalSidePanelTabOrder(['scripts', 'bad-tab']), [
|
||||
'scripts',
|
||||
'sftp',
|
||||
'browser',
|
||||
'history',
|
||||
'theme',
|
||||
'system',
|
||||
@@ -93,7 +94,7 @@ test('side panel tab order falls back to the default order', () => {
|
||||
});
|
||||
|
||||
test('side panel tab order accepts a stored permutation', () => {
|
||||
const stored = ['scripts', 'sftp', 'history', 'theme', 'system', 'notes', 'ai'];
|
||||
const stored = ['scripts', 'sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai'];
|
||||
|
||||
assert.deepEqual(normalizeTerminalSidePanelTabOrder(stored), stored);
|
||||
});
|
||||
@@ -105,7 +106,7 @@ test('side panel tab order moves the dragged tab before the target tab', () => {
|
||||
'notes',
|
||||
'scripts',
|
||||
),
|
||||
['sftp', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
|
||||
['sftp', 'browser', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,7 +118,7 @@ test('side panel tab order can move the dragged tab after the target tab', () =>
|
||||
'ai',
|
||||
'after',
|
||||
),
|
||||
['sftp', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
|
||||
['sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,7 +131,7 @@ test('narrow side panels keep the active tool visible and move extra tools into
|
||||
});
|
||||
|
||||
assert.deepEqual(fitted.shown, ['sftp', 'ai']);
|
||||
assert.deepEqual(fitted.collapsed, ['scripts', 'history', 'theme', 'system', 'notes']);
|
||||
assert.deepEqual(fitted.collapsed, ['browser', 'scripts', 'history', 'theme', 'system', 'notes']);
|
||||
});
|
||||
|
||||
test('notes side panel forwards repeated open-note requests', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import { Activity, FolderTree, Globe2, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import {
|
||||
buildSidePanelChromeThemeFromTerminalTheme,
|
||||
buildTerminalSidePanelCssVars,
|
||||
@@ -555,6 +555,7 @@ export function getTerminalSidePanelShellWidth({
|
||||
function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
const {
|
||||
mountedAiTabIds,
|
||||
browserMountedTabIds = [],
|
||||
mountedSftpTabIds,
|
||||
notesMountedTabIds,
|
||||
scriptsMountedTabIds,
|
||||
@@ -569,7 +570,8 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
|
||||
|
||||
return !(
|
||||
mountedSftpTabIds.length === 0
|
||||
browserMountedTabIds.length === 0
|
||||
&& mountedSftpTabIds.length === 0
|
||||
&& mountedAiTabIds.length === 0
|
||||
&& notesMountedTabIds.length === 0
|
||||
&& scriptsMountedTabIds.length === 0
|
||||
@@ -628,6 +630,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
handleOpenTheme,
|
||||
@@ -1036,6 +1039,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
|
||||
const sidePanelTabItems = useMemo<SidePanelTabItem[]>(() => [
|
||||
{ id: 'sftp' as const, label: t('terminal.layer.sftp'), icon: <FolderTree size={15} />, onClick: handleToggleSftpFromBar },
|
||||
{ id: 'browser' as const, label: t('terminal.browser.title'), icon: <Globe2 size={15} />, onClick: handleOpenBrowser },
|
||||
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Play size={15} />, onClick: handleOpenScripts },
|
||||
{ id: 'history' as const, label: t('terminal.layer.history'), icon: <History size={15} />, onClick: handleOpenHistory },
|
||||
{ id: 'theme' as const, label: t('terminal.layer.theme'), icon: <Palette size={15} />, onClick: handleOpenTheme },
|
||||
@@ -1046,6 +1050,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
handleOpenTheme,
|
||||
@@ -1297,6 +1302,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
draggable
|
||||
aria-label={item.label}
|
||||
data-tab-id={item.id}
|
||||
data-tab-type="sidepanel"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
|
||||
@@ -489,6 +489,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
|
||||
handleOpenAI: s.handleOpenAI,
|
||||
handleOpenNotes: s.handleOpenNotes,
|
||||
handleOpenBrowser: s.handleOpenBrowser,
|
||||
handleOpenSystem: s.handleOpenSystem,
|
||||
handleOpenHistory: s.handleOpenHistory,
|
||||
handleOpenScripts: s.handleOpenScripts,
|
||||
@@ -558,6 +559,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
mountedAiTabIds: s.mountedAiTabIds,
|
||||
mountedSftpTabIds: s.mountedSftpTabIds,
|
||||
notesMountedTabIds: s.notesMountedTabIds,
|
||||
browserMountedTabIds: s.browserMountedTabIds,
|
||||
notesOpenNoteByTab: s.notesOpenNoteByTab,
|
||||
NotesManager: s.NotesManager,
|
||||
scriptsMountedTabIds: s.scriptsMountedTabIds,
|
||||
@@ -602,6 +604,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
ScriptsSidePanel: s.ScriptsSidePanel,
|
||||
sessionChainHostsMap: s.sessionChainHostsMap,
|
||||
sessionHostsMap,
|
||||
sftpAuthHostBySessionId,
|
||||
resolvedSessionHostIds: s.resolvedSessionHostIds,
|
||||
sessionLogConfig: s.sessionLogConfig,
|
||||
sessionSudoAutofillPasswordsMap: s.sessionSudoAutofillPasswordsMap,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { collectSidePanelPanes, closeSidePanelPane, type SidePanelLayout } from '../../domain/sidePanelLayout';
|
||||
import { resolveSessionSidePanelAutoOpen } from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
const source = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('const handleCloseSidePanelPane = useCallback(');
|
||||
const end = source.indexOf('const handleResizeSidePanelSplit =', start);
|
||||
assert.ok(start > 0 && end > start);
|
||||
const ast = ts.createSourceFile('TerminalLayer.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const helper = ast.statements.find((node) => ts.isVariableStatement(node)
|
||||
&& node.declarationList.declarations.some((declaration) => declaration.name.getText(ast) === 'removeMountedSidePanelTabId'));
|
||||
assert.ok(helper);
|
||||
const code = ts.transpile(`${helper.getText(ast)};${source.slice(start, end)};handleCloseSidePanelPane;`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
function closePane(tool: 'browser' | 'notes') {
|
||||
const layout: SidePanelLayout = { focusedPaneId: 'browser-pane', root: {
|
||||
type: 'split', id: 'split', direction: 'horizontal', sizes: [0.5, 0.5], children: [
|
||||
{ type: 'pane', id: 'browser-pane', tool: 'browser' },
|
||||
{ type: 'pane', id: 'notes-pane', tool: 'notes' },
|
||||
],
|
||||
} };
|
||||
const layouts = new Map([['terminal', layout]]);
|
||||
let mounted = ['terminal', 'other-terminal'];
|
||||
const close = vm.runInNewContext(code, {
|
||||
useCallback: (callback: unknown) => callback,
|
||||
activeTabIdRef: { current: 'terminal' }, sidePanelLayoutsRef: { current: layouts },
|
||||
collectSidePanelPanes,
|
||||
setMagnifiedPane: () => {},
|
||||
closeSidePanelPaneForTab: (tabId: string, paneId: string) => {
|
||||
const next = closeSidePanelPane(layouts.get(tabId)!, paneId);
|
||||
assert.ok(next);
|
||||
layouts.set(tabId, next);
|
||||
return false;
|
||||
},
|
||||
handleCloseSidePanel: () => { throw new Error('the sibling pane must remain open'); },
|
||||
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
|
||||
shouldMarkSftpPaneClosed: () => false,
|
||||
}) as (paneId: string) => void;
|
||||
close(`${tool}-pane`);
|
||||
return { mounted, tools: collectSidePanelPanes(layouts.get('terminal')!.root).map((pane) => pane.tool) };
|
||||
}
|
||||
|
||||
test('closing a browser split pane releases only that terminal browser', () => {
|
||||
const result = closePane('browser');
|
||||
assert.deepEqual(result.mounted, ['other-terminal']);
|
||||
assert.deepEqual(result.tools, ['notes']);
|
||||
});
|
||||
|
||||
test('closing a sibling pane keeps the browser mounted', () => {
|
||||
const result = closePane('notes');
|
||||
assert.deepEqual(result.mounted, ['terminal', 'other-terminal']);
|
||||
assert.deepEqual(result.tools, ['browser']);
|
||||
});
|
||||
|
||||
for (const protocol of ['local', 'ssh']) {
|
||||
test(`browser auto-open mounts content for a ${protocol} terminal`, () => {
|
||||
const statusStart = source.indexOf('const handleStatusChange = useCallback(');
|
||||
const statusEnd = source.indexOf('const handleSessionExit =', statusStart);
|
||||
const addHelper = ast.statements.find((node) => ts.isVariableStatement(node)
|
||||
&& node.declarationList.declarations.some((declaration) => declaration.name.getText(ast) === 'addMountedSidePanelTabId'));
|
||||
assert.ok(addHelper && statusStart > 0 && statusEnd > statusStart);
|
||||
let mounted: string[] = [];
|
||||
let opened = new Map<string, string>();
|
||||
const statusCode = ts.transpile(`${addHelper.getText(ast)};${source.slice(statusStart, statusEnd)};handleStatusChange;`, { target: ts.ScriptTarget.ES2022 });
|
||||
const statusChange = vm.runInNewContext(statusCode, {
|
||||
useCallback: (callback: unknown) => callback,
|
||||
onUpdateSessionStatus: () => {},
|
||||
setSftpAuthHostBySessionId: () => {},
|
||||
sessionsRef: { current: [{ id: 'terminal', protocol }] },
|
||||
sidePanelOpenTabsRef: { current: opened },
|
||||
sftpHostSourceSessionForTabRef: { current: new Map() },
|
||||
lastSidePanelTabRef: { current: new Map() },
|
||||
terminalSidePanelAutoOpenRef: { current: true },
|
||||
terminalSidePanelAutoOpenTabRef: { current: 'browser' },
|
||||
localShellSidePanelAutoOpenRef: { current: true },
|
||||
localShellSidePanelAutoOpenTabRef: { current: 'browser' },
|
||||
sftpAutoOpenSidebarRef: { current: false },
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
applyWorkspaceLayoutPresetForSession: () => false,
|
||||
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
|
||||
setSidePanelOpenTabs: (update: (previous: Map<string, string>) => Map<string, string>) => { opened = update(opened); },
|
||||
}) as (sessionId: string, status: string) => void;
|
||||
statusChange('terminal', 'connected');
|
||||
assert.equal(opened.get('terminal'), 'browser');
|
||||
assert.deepEqual(Array.from(mounted), ['terminal']);
|
||||
});
|
||||
}
|
||||
|
||||
test('host-only browser requests skip hidden and unsupported sessions', () => {
|
||||
const requestStart = source.indexOf('useEffect(() => subscribeHostBrowserPanelRequests(');
|
||||
const requestEnd = source.indexOf('useEffect(() => {', requestStart);
|
||||
assert.ok(requestStart > 0 && requestEnd > requestStart);
|
||||
const requestCode = ts.transpile(source.slice(requestStart, requestEnd), { target: ts.ScriptTarget.ES2022 });
|
||||
const hidden = { id: 'hidden', hostId: 'host', hiddenFromTabs: true };
|
||||
const unsupported = { id: 'telnet', hostId: 'host', protocol: 'telnet' };
|
||||
const visible = { id: 'visible', hostId: 'host' };
|
||||
for (const sessions of [[hidden, unsupported, visible], [hidden, unsupported]]) {
|
||||
let active: string | undefined;
|
||||
let mounted: string[] = [];
|
||||
let opened = new Map<string, string>();
|
||||
vm.runInNewContext(requestCode, {
|
||||
useEffect: (callback: () => void) => callback(),
|
||||
subscribeHostBrowserPanelRequests: (callback: (request: unknown) => void) => callback({ host: { id: 'host' } }),
|
||||
sessionsRef: { current: sessions },
|
||||
activeTabStore: { setActiveTabId: (tabId: string) => { active = tabId; } },
|
||||
onConnectToHost: () => 'new-visible',
|
||||
addMountedSidePanelTabId: (previous: string[], tabId: string) => [...previous, tabId],
|
||||
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
|
||||
setSidePanelOpenTabs: (update: (previous: Map<string, string>) => Map<string, string>) => { opened = update(opened); },
|
||||
});
|
||||
const expected = sessions.includes(visible) ? 'visible' : 'new-visible';
|
||||
assert.deepEqual(mounted, [expected]);
|
||||
assert.equal(opened.get(expected), 'browser');
|
||||
assert.notEqual(active, 'hidden');
|
||||
}
|
||||
});
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
import { useScriptExecution } from '../../application/state/useScriptExecution';
|
||||
import { useRemoteHistoryState } from '../../application/state/useRemoteHistoryState';
|
||||
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHibernate';
|
||||
import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts';
|
||||
import type { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { HostBrowserSidePanel } from '../HostBrowserSidePanel';
|
||||
import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel';
|
||||
import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
@@ -243,6 +245,57 @@ function SidePanelSftpSlotInner({
|
||||
export const SidePanelSftpSlot = memo(SidePanelSftpSlotInner);
|
||||
SidePanelSftpSlot.displayName = 'SidePanelSftpSlot';
|
||||
|
||||
function SidePanelBrowserSlot({ tabId, ctx, isVisible }: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
const sessions = ctx.sessions as TerminalSession[];
|
||||
const workspace = (ctx.workspaceById as Map<string, Workspace>).get(tabId);
|
||||
const current = (isVisible ? live.activeTerminalSessionForSystem : null)
|
||||
?? resolveSystemSidebarSession(sessions, workspace, workspace?.focusedSessionId,
|
||||
sessions.find((session) => session.id === tabId));
|
||||
const retainedSessionIds = useRef(new Set<string>());
|
||||
if (isVisible && current) retainedSessionIds.current.add(current.id);
|
||||
const workspaceSessionIds = workspace ? new Set(collectSessionIds(workspace.root)) : null;
|
||||
for (const id of retainedSessionIds.current) {
|
||||
if (!sessions.some((session) => session.id === id && (workspaceSessionIds
|
||||
? workspaceSessionIds.has(id) : session.id === tabId && !session.workspaceId))) retainedSessionIds.current.delete(id);
|
||||
}
|
||||
const sessionHosts = ctx.sessionHostsMap as Map<string, Host>;
|
||||
const authHosts = ctx.sftpAuthHostBySessionId as Map<string, Host>;
|
||||
const currentHost = current ? authHosts.get(current.id) ?? sessionHosts.get(current.id) : null;
|
||||
const supportsBrowser = currentHost && ['ssh', 'mosh', 'local'].includes(currentHost.protocol ?? 'ssh');
|
||||
return (
|
||||
<div className={sidePanelHiddenPanelClassName(!isVisible)} data-section="terminal-browser-panel">
|
||||
{workspace && supportsBrowser && <div className="h-8 shrink-0 border-b border-border/50 px-3 flex items-center text-xs text-muted-foreground truncate">
|
||||
{currentHost.label || currentHost.hostname}
|
||||
</div>}
|
||||
<div className={workspace && supportsBrowser ? 'relative h-[calc(100%-2rem)] min-h-0 overflow-hidden' : 'relative h-full min-h-0 overflow-hidden'}>
|
||||
{!supportsBrowser && <p className="p-4 text-sm text-muted-foreground">{ctx.t('terminal.browser.sshOnly')}</p>}
|
||||
{[...retainedSessionIds.current].map((sessionId) => {
|
||||
const host = authHosts.get(sessionId) ?? sessionHosts.get(sessionId);
|
||||
if (!host || !['ssh', 'mosh', 'local'].includes(host.protocol ?? 'ssh')) return null;
|
||||
const visible = isVisible && current?.id === sessionId;
|
||||
return <div key={sessionId} className={visible ? 'absolute inset-0 overflow-hidden' : 'hidden'}>
|
||||
<HostBrowserSidePanel
|
||||
browserId={`${tabId}:${sessionId}`}
|
||||
host={host}
|
||||
hosts={ctx.effectiveHosts}
|
||||
keys={ctx.keys}
|
||||
identities={ctx.identities}
|
||||
knownHosts={ctx.knownHosts}
|
||||
terminalSettings={ctx.terminalSettings}
|
||||
isVisible={visible}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelSystemSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
@@ -690,6 +743,7 @@ export function SidePanelMountedContent({
|
||||
}) {
|
||||
const {
|
||||
mountedSftpTabIds,
|
||||
browserMountedTabIds = [],
|
||||
systemMountedTabIds,
|
||||
scriptsMountedTabIds,
|
||||
themeMountedTabIds,
|
||||
@@ -723,6 +777,11 @@ export function SidePanelMountedContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
{browserMountedTabIds.map((tabId: string) => (
|
||||
<PersistentSidePanelPortal key={`browser-${tabId}`} portalKey={`browser-${tabId}`} target={portalTarget(tabId, 'browser')}>
|
||||
<SidePanelBrowserSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'browser')} />
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
{mountedSftpTabIds.map((tabId: string) => (
|
||||
<PersistentSidePanelPortal
|
||||
key={`sftp-${tabId}`}
|
||||
|
||||
@@ -228,6 +228,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'mountedSftpTabIds',
|
||||
'mountedAiTabIds',
|
||||
'notesMountedTabIds',
|
||||
'browserMountedTabIds',
|
||||
'notesOpenNoteByTab',
|
||||
'scriptsMountedTabIds',
|
||||
'systemMountedTabIds',
|
||||
@@ -253,6 +254,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
// SFTP Connected picker reads live terminal sessions from stable ctx.
|
||||
'sessions',
|
||||
'sessionHostsMap',
|
||||
'sftpAuthHostBySessionId',
|
||||
'workspaceById',
|
||||
'keys',
|
||||
'identities',
|
||||
@@ -314,6 +316,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handleOpenTheme',
|
||||
'handleOpenAI',
|
||||
'handleOpenNotes',
|
||||
'handleOpenBrowser',
|
||||
'handleBackFromNotes',
|
||||
'handleOpenHostFromNotes',
|
||||
'handleOpenSystem',
|
||||
@@ -352,6 +355,7 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'handleWorkspaceDrop',
|
||||
'sessions',
|
||||
'sessionHostsMap',
|
||||
'sftpAuthHostBySessionId',
|
||||
'sessionChainHostsMap',
|
||||
'sessionSudoAutofillPasswordsMap',
|
||||
'sessionSudoAutofillCandidatesMap',
|
||||
|
||||
@@ -83,6 +83,7 @@ interface AsidePanelProps extends AsidePanelResizeProps {
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
@@ -99,6 +100,7 @@ interface AsidePanelHeaderProps {
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
showBackButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Header Component
|
||||
@@ -109,9 +111,10 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
onBack,
|
||||
onClose,
|
||||
showBackButton = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
|
||||
<div className={cn("px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0", className)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showBackButton && onBack && (
|
||||
<button
|
||||
@@ -170,16 +173,18 @@ export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: strin
|
||||
// Action Menu Component (for the ... button)
|
||||
interface AsideActionMenuProps {
|
||||
children: ReactNode;
|
||||
triggerClassName?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children, triggerClassName, ariaLabel }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
|
||||
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -345,6 +350,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
onBack,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
resizable = false,
|
||||
@@ -414,7 +420,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && !usesPixelWidth && width,
|
||||
isResizing && 'transition-none',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
@@ -435,6 +441,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
className={headerClassName}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import type { MonitorStats } from "../../domain/monitorRuntime";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const diskCapacity = (value: number) => value >= 1024
|
||||
? `${(value / 1024).toFixed(2)} TiB`
|
||||
: value > 0 && value < 1
|
||||
? `${(value * 1024).toFixed(1)} MiB`
|
||||
: `${value.toFixed(1)} GiB`;
|
||||
|
||||
/** Cards show the largest capacities; the list keeps every reported mount. */
|
||||
export function MonitorDiskUsage({
|
||||
stats,
|
||||
limit,
|
||||
}: {
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const reported = stats?.disks.length
|
||||
? stats.disks
|
||||
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
|
||||
const disks = limit == null
|
||||
? reported
|
||||
: [...reported].sort((a, b) =>
|
||||
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
|
||||
).slice(0, limit);
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
|
||||
{disks.map((disk, index) => (
|
||||
<Tooltip key={`${disk.mountPoint}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<ResourceBar label="" value={disk.percent} className="flex-1" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
|
||||
<div className="mb-1 break-all font-medium">{disk.mountPoint}</div>
|
||||
<div className="tabular-nums text-muted-foreground">
|
||||
{typeof disk.used === "number" && Number.isFinite(disk.used) && disk.used >= 0 && typeof disk.total === "number" && Number.isFinite(disk.total) && disk.total > 0
|
||||
? t("monitor.diskCapacity", { used: diskCapacity(disk.used), total: diskCapacity(disk.total) })
|
||||
: t("monitor.diskCapacityUnknown")}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider";
|
||||
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
|
||||
import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const point = (ago: number, cpu: number | null): MonitorPoint => ({
|
||||
at: now - ago,
|
||||
cpu,
|
||||
memory: null,
|
||||
disk: null,
|
||||
rx: null,
|
||||
tx: null,
|
||||
});
|
||||
const trend = (points: MonitorPoint[]) => new JSDOM(renderToStaticMarkup(
|
||||
<MonitorTrend clock={0} points={points} field="cpu" range={5} interval={10} label="CPU trend" />,
|
||||
)).window.document;
|
||||
|
||||
test("unchanged series reuse geometry but age out when the viewport clock advances", async (t) => {
|
||||
const dom = new JSDOM("<div id='root'></div>");
|
||||
const globals = { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const originals = Object.fromEntries(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
let current = now;
|
||||
t.mock.method(Date, "now", () => current);
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const samples = [point(1000, 42)];
|
||||
try {
|
||||
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU" />));
|
||||
const path = dom.window.document.querySelector("svg g path")!.getAttribute("d");
|
||||
current += 1000;
|
||||
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
|
||||
assert.equal(dom.window.document.querySelector("svg g path")!.getAttribute("d"), path);
|
||||
current += 6 * 60000;
|
||||
await act(async () => root.render(<MonitorTrend clock={1} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
|
||||
assert.equal(dom.window.document.querySelectorAll("svg g path, svg circle").length, 0);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const key of Object.keys(globals)) {
|
||||
const descriptor = originals[key];
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("empty trends do not draw a reading", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const document = trend([]);
|
||||
assert.equal(document.querySelectorAll("svg g path, svg circle").length, 0);
|
||||
});
|
||||
|
||||
test("a first reading is visible at its actual time without invented history", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const document = trend([point(1000, 40)]);
|
||||
const dot = document.querySelector("circle")!;
|
||||
assert.equal(Number(dot.getAttribute("cx")), 299);
|
||||
assert.equal(document.querySelector('g path[fill="none"]')!.getAttribute("d"), "M 299.00 36.40");
|
||||
const area = document.querySelector("g path")!.getAttribute("d")!;
|
||||
assert.equal(area, "M 299.00 36.40 L 299.00 58 L 299.00 58 Z");
|
||||
});
|
||||
|
||||
test("trend lines and shaded areas break at missing readings and collection gaps", (t) => {
|
||||
t.mock.method(Date, "now", () => now);
|
||||
for (const points of [
|
||||
[point(40000, 20), point(30000, null), point(20000, 30), point(10000, 40)],
|
||||
[point(50000, 20), point(10000, 30), point(0, 40)],
|
||||
]) {
|
||||
const document = trend(points);
|
||||
const segments = [...document.querySelectorAll("svg g")];
|
||||
assert.equal(segments.length, 2);
|
||||
assert.equal(segments[0].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), false);
|
||||
assert.equal(segments[1].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), true);
|
||||
// Each area starts at its own segment, rather than extending to the chart origin.
|
||||
for (const segment of segments) {
|
||||
const firstX = segment.querySelector("path")!.getAttribute("d")!.split(" ")[1];
|
||||
assert.ok(Number(firstX) > 0);
|
||||
assert.ok(segment.querySelector("path")!.getAttribute("d")!.endsWith(`L ${firstX} 58 Z`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple host charts use independent gradient references", () => {
|
||||
const document = new JSDOM(renderToStaticMarkup(<>
|
||||
<MonitorTrend clock={0} points={[]} field="cpu" range={5} interval={10} label="CPU" />
|
||||
<MonitorTrend clock={0} points={[]} field="memory" range={5} interval={10} label="Memory" />
|
||||
</>)).window.document;
|
||||
const ids = [...document.querySelectorAll("linearGradient")].map((g) => g.id);
|
||||
assert.equal(new Set(ids).size, 2);
|
||||
});
|
||||
|
||||
const hosts: Host[] = [
|
||||
{ id: "custom", label: "Cloud host", hostname: "cloud.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, iconMode: "custom", iconId: "cloud", iconColorMode: "manual", iconColorCustom: "#9333EA" },
|
||||
{ id: "brand", label: "Ubuntu host", hostname: "ubuntu.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, distro: "ubuntu" },
|
||||
];
|
||||
|
||||
test("disk usage shows every reported partition, including healthy and empty disks", () => {
|
||||
const disks = [
|
||||
{ mountPoint: "/", used: 25, total: 100, percent: 25 },
|
||||
{ mountPoint: "/data", used: 95, total: 100, percent: 95 },
|
||||
{ mountPoint: "/backup", used: 0, total: 100, percent: 0 },
|
||||
];
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
|
||||
)).window.document;
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
|
||||
for (const value of ["25.0%", "95.0%", "0.0%"])
|
||||
assert.ok(document.body.textContent!.includes(value));
|
||||
});
|
||||
|
||||
test("disk usage retains a root-only reading and leaves missing data unknown", () => {
|
||||
const known = renderToStaticMarkup(<MonitorDiskUsage stats={{ disks: [], diskPercent: 42 }} />);
|
||||
const unknown = renderToStaticMarkup(<MonitorDiskUsage />);
|
||||
assert.ok(known.includes("42.0%"));
|
||||
assert.ok(unknown.includes("--"));
|
||||
assert.ok(!unknown.includes("0.0%"));
|
||||
});
|
||||
|
||||
test("cards show the two largest capacities without changing the source disk order", () => {
|
||||
const disks = [
|
||||
{ mountPoint: "/small", used: 49, total: 50, percent: 98 },
|
||||
{ mountPoint: "/data", used: 100, total: 1000, percent: 10 },
|
||||
{ mountPoint: "/backup", used: 0, total: 500, percent: 0 },
|
||||
];
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<MonitorDiskUsage stats={{ disks, diskPercent: 98 }} limit={2} />,
|
||||
)).window.document;
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/data", "/backup"]);
|
||||
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
|
||||
});
|
||||
|
||||
for (const view of ["list", "charts"] as const) {
|
||||
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
|
||||
const storage = new Map<string, string>();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
} });
|
||||
t.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, "localStorage", original);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
writeMonitorPanels([{ id: "panel", name: "Panel", description: "", hostIds: hosts.map((h) => h.id), view, interval: 10, range: 15, sort: "manual" }]);
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorPanels hosts={hosts} customGroups={[]} onConnect={() => {}} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const icon = document.querySelector(".lucide-cloud")!;
|
||||
assert.ok(icon);
|
||||
assert.equal((icon.parentElement as HTMLElement).style.backgroundColor, "rgb(147, 51, 234)");
|
||||
assert.ok(document.querySelector('img[src="/distro/ubuntu.svg"]'));
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,7 +26,7 @@ import {
|
||||
} from "../../domain/hostClickBehavior";
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
@@ -56,7 +56,7 @@ const isRelatedTargetInside = (
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
@@ -611,6 +611,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={host} />
|
||||
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
@@ -731,6 +732,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={host} />
|
||||
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
@@ -967,6 +969,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onOpenBrowser={handleOpenHostBrowser}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={handleTreeDeleteHost}
|
||||
@@ -1112,6 +1115,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={host} />
|
||||
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
@@ -1262,6 +1266,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={host} />
|
||||
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -26,6 +28,7 @@ import { useNotesStore } from "../../application/state/notesStore";
|
||||
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||
import { toast } from "../ui/toast";
|
||||
import { AppWordmark } from "../AppWordmark";
|
||||
import { openHostBrowser } from "../../application/hostBrowser";
|
||||
|
||||
type VaultViewLayoutContext = Record<string, any>;
|
||||
|
||||
@@ -248,6 +251,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
onConnect,
|
||||
onConnectSerial,
|
||||
onCreateLocalTerminal,
|
||||
onDeleteHost,
|
||||
@@ -620,6 +624,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<MonitorNavButton active={currentSection === "monitor"} collapsed={sidebarCollapsed} onClick={() => setCurrentSection("monitor")} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
@@ -1215,6 +1220,11 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
handleEditGroupConfig,
|
||||
handleEditHost,
|
||||
handleHostConnect,
|
||||
handleOpenHostBrowser: (host: any) => {
|
||||
if (host.protocol && !['ssh', 'mosh', 'local'].includes(host.protocol)) return;
|
||||
void openHostBrowser({ host, hosts, keys, identities, knownHosts, terminalSettings })
|
||||
.catch((error) => toast.error(error instanceof Error ? error.message : 'Could not open the host browser'));
|
||||
},
|
||||
handleUnmanageGroup,
|
||||
hasHostsSidePanel,
|
||||
hostListScrollRef,
|
||||
@@ -1401,6 +1411,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
{currentSection === "monitor" && <MonitorPanels hosts={hosts} customGroups={customGroups} onConnect={onConnect} />}
|
||||
{currentSection === "port" && (
|
||||
<LazyLoadBoundary
|
||||
name="Port forwarding"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Host-routed browser for issue #2972
|
||||
|
||||
Status: terminal sidebar integration in `codex/issue-2972-host-browser`, 2026-10-16.
|
||||
The macOS sidebar and workspace focus switching have been exercised in the real
|
||||
app. Electron fixtures verify two independent SSH routes, retained pages,
|
||||
panel resizing, bookmarks, downloads, and closing without closing the terminal.
|
||||
Windows/Linux and the remaining network acceptance cases below are not yet
|
||||
runtime-verified.
|
||||
|
||||
## Goal and completion criteria
|
||||
|
||||
From a connected SSH terminal, the user opens the Browser tool in the terminal sidebar, alongside SFTP
|
||||
and Notes. Its context follows the focused terminal in a workspace. All supported page traffic, including access to that host's
|
||||
`127.0.0.1`, must use the selected host's network path. Loss of that path
|
||||
stops browsing without switching to a local direct connection. Bookmarks sync
|
||||
through the existing encrypted cloud-sync payload.
|
||||
|
||||
The feature is complete only after a packaged desktop build demonstrates:
|
||||
|
||||
1. Remote `127.0.0.1`, remote-only LAN DNS, a public exit-IP endpoint, and
|
||||
HTTPS all behave as seen from the selected SSH host.
|
||||
2. HTTP, HTTPS, WebSocket, redirects, popups, downloads, and background tab
|
||||
requests remain on the same route. Requests using an unsupported transport
|
||||
are denied rather than sent directly.
|
||||
3. Disconnect, proxy setup failure, reconnection, host deletion, browser close,
|
||||
and app quit leave no direct fallback or orphaned listener.
|
||||
4. Tabs on two different hosts cannot share network state, cookies, local
|
||||
storage, service workers, or `127.0.0.1` data.
|
||||
5. Bookmarks survive restart and encrypted multi-device sync, including
|
||||
simultaneous additions, edits, and deletions on two devices. Browsing
|
||||
history, cookies, passwords, open tabs, and local tunnel ports do not sync.
|
||||
6. The real UI is checked on macOS, Windows, and Linux: opening from a terminal,
|
||||
navigation, tabs, focus, resizing, loading/errors, reconnect, and bookmarks.
|
||||
|
||||
## Product behavior
|
||||
|
||||
### Entry and terminal ownership
|
||||
|
||||
- Browser is a normal terminal side-panel tool, alongside SFTP and Notes. It
|
||||
participates in the existing nested split layout, position chooser, and
|
||||
saved layout presets. Terminal toolbar and host-menu actions open this tool.
|
||||
- Each terminal keeps its own browser tabs and route. Within a workspace,
|
||||
focusing another terminal reveals that terminal's browser context. Returning
|
||||
restores its earlier tabs and pages without retargeting their network route.
|
||||
- Do not show a permanent `Via <host>` badge in the address toolbar. A workspace
|
||||
uses the same focused-host heading pattern as other side-panel tools.
|
||||
- Switching tools or terminal tabs hides the native views and retains pages.
|
||||
Closing the whole side panel or its terminal releases its browser views and
|
||||
dedicated SSH connections. Renderer menus, dialogs, and magnified panes must
|
||||
never be covered by a hidden browser view.
|
||||
|
||||
### Terminal links
|
||||
|
||||
HTTP(S) links in SSH/Mosh and local terminals open in the Netcatty sidebar by
|
||||
default. Settings > Terminal > Behavior > Open terminal links in can select the
|
||||
system default browser instead; the choice applies immediately to existing
|
||||
terminals. SSH contexts keep the host route; local terminal contexts use an
|
||||
explicit direct session. A failed SSH route never becomes a local route.
|
||||
Detached terminal windows forward browser requests to the main window's sidebar.
|
||||
Other terminal protocols keep their existing system-browser behavior.
|
||||
|
||||
### Browsing surface
|
||||
|
||||
- The sidebar can be resized or magnified. The trusted top chrome has a tab
|
||||
strip, new/close/reorder tab controls, back, forward, reload/stop, one address
|
||||
bar and a bookmarks menu. Page title and favicon appear on
|
||||
tabs. The start tab is a plain app-icon grid with no duplicate address field.
|
||||
It follows the main window's flat tab styling and normal desktop shortcuts.
|
||||
- Website favicons are fetched through the host route and stored locally by
|
||||
host and website. Users can right-click a tab to set an icon image or glyph;
|
||||
that override persists until they restore the website icon. Bookmark app
|
||||
icons can also use a custom image, which is included in bookmark sync.
|
||||
- `127.0.0.1:80` or `localhost:80` defaults to HTTP and refers to the SSH host.
|
||||
External names default to HTTPS. The address field displays the real page URL
|
||||
and the focused terminal determines its host route.
|
||||
- All page-created tabs and permitted window-open requests stay in this host's
|
||||
browser context/profile. External protocols and "open in system browser" need
|
||||
an explicit warning that the destination will use the local computer's route.
|
||||
- While disconnected, show a full-panel error state and a Retry action. Do not
|
||||
load cached pages as though the route were connected; stale content, if ever
|
||||
shown, needs a separate offline label.
|
||||
|
||||
### Bookmarks
|
||||
|
||||
- Each item has an id, title, URL, scope (`host` or `all-hosts`), optional
|
||||
hostId, display order, and timestamps. The default scope is the current host,
|
||||
especially for `localhost` and private addresses. Users can explicitly make
|
||||
a public bookmark available from every host.
|
||||
- The menu has `This host` and `All hosts`, add/edit/delete, and a quick search.
|
||||
Bookmark opening always uses the current terminal's host route. A missing host
|
||||
reference is shown as unavailable; never silently attach it to another host.
|
||||
- Sync bookmark metadata as its own id-based payload entity with the same
|
||||
merge, deletion, backup/restore, shrink guard, and encrypted provider handling
|
||||
as other vault entities. Browser cookies, history, passwords, page content,
|
||||
and per-device settings remain local. Host-scoped bookmarks retain the synced
|
||||
host id. Old sync payloads treat a missing bookmarks array as legacy absence,
|
||||
not as a delete-all operation.
|
||||
|
||||
## Network and security model
|
||||
|
||||
- The browser chrome is trusted local UI. Each untrusted website is rendered in
|
||||
a sandboxed `WebContentsView` with no Node integration, no Netcatty preload,
|
||||
and no access to terminal, vault, or plugin bridges. Use an Electron session
|
||||
partition separate from the application and separate for each host profile.
|
||||
Tabs for one host may share website cookies; different hosts must not.
|
||||
- The main process owns a `HostBrowserManager` with a small interface:
|
||||
openForHost(hostId), create/close/navigate tab, bookmark operations, and
|
||||
close host browser. The UI sends intent and receives state. The manager owns
|
||||
panel views, tabs, session partitions, route state, and cleanup.
|
||||
- Use a dedicated, temporary SSH connection for the browser, honoring
|
||||
Netcatty's existing host authentication, known-host policy, jump hosts, and
|
||||
connection settings. A dynamic SOCKS5 listener binds only to `127.0.0.1` on
|
||||
an OS-assigned port. It is not a saved port-forward rule. Expose the actual
|
||||
assigned port only inside the main process; close it with the owning browser panel.
|
||||
- A loopback SOCKS listener is not exclusive to this browser: another local
|
||||
process could use it while open, and Chromium does not support SOCKS5 proxy
|
||||
authentication. Keep its lifetime short, never bind a public interface,
|
||||
and describe this limitation honestly. If exclusive access becomes a product
|
||||
requirement, the local listener design must be revisited.
|
||||
- Before creating any page view, create its isolated session with a dead-end
|
||||
proxy and the loopback bypass disabled. After SSH/SOCKS is ready, install a
|
||||
single SOCKS5 proxy with no `DIRECT` alternative. Chromium routes names to
|
||||
the SOCKS5 side and can proxy `localhost` only when its implicit bypass is
|
||||
explicitly removed. On route loss, first prevent new navigation and requests,
|
||||
switch to the dead-end proxy, close pooled connections, then close/replace
|
||||
the SSH route. Resume only after the new route and proxy are verified.
|
||||
- The existing dynamic SOCKS implementation is a starting point, not a ready
|
||||
browser transport: it currently assumes caller-chosen local ports, parses
|
||||
SOCKS messages from single socket chunks, and rejects IPv6 targets. Extract a
|
||||
bounded, incremental SOCKS parser/listener shared with saved forwarding;
|
||||
support IPv4, hostnames, and IPv6, timeouts, cancellation, and cleanup.
|
||||
- Allow HTTP(S) and WS(S). Restrict or explicitly deny page capabilities that
|
||||
can use a direct route outside the SOCKS TCP path (notably WebRTC/UDP), along
|
||||
with unexpected URL schemes and unrestricted device permissions. Downloads
|
||||
must use the isolated browser session and a reviewed destination flow.
|
||||
Browser-started operations must not call the app's normal Node `fetch`, system
|
||||
browser, DNS resolver, or default Electron session.
|
||||
- SSH encrypts local-computer-to-host transport. Traffic from the SSH host to
|
||||
the destination follows that URL's HTTP or HTTPS security; SSH alone does not
|
||||
make a remote-to-website HTTP leg encrypted. The product copy must say this
|
||||
accurately and must not promise anonymity or unrestricted internet access.
|
||||
|
||||
## Existing code seams
|
||||
|
||||
- Toolbar: `components/terminal/TerminalToolbar.tsx` and its caller in
|
||||
`components/Terminal.tsx`.
|
||||
- SSH forwarding: `electron/bridges/portForwardingBridge.cjs` and
|
||||
`infrastructure/services/portForwardingService.ts`. The saved rule's fixed
|
||||
port and persistence model should not own temporary browser routes.
|
||||
- Isolated web content and fail-closed proxy patterns already exist in
|
||||
`electron/plugins/pluginViewHost.cjs`; reuse the principles, not the plugin
|
||||
permissions or protocol.
|
||||
- Sync entity/payload: `domain/sync.ts`, `domain/syncMerge.ts`,
|
||||
`application/syncPayload.ts`, and the auto/cloud sync application hooks.
|
||||
Existing SFTP global bookmarks live inside settings, but browser bookmarks
|
||||
need host scope and per-item conflict/deletion handling.
|
||||
|
||||
## Delivery order
|
||||
|
||||
1. Network proof in the actual Electron runtime: isolated session, SSH SOCKS5,
|
||||
`localhost` through the host, remote DNS, public exit IP, WS(S), disconnect
|
||||
kill switch, and packet-capture checks for direct DNS/WebRTC/UDP leakage.
|
||||
This is a release gate, not a mock-only test.
|
||||
2. Browser manager and safe window/tab lifecycle, including page isolation,
|
||||
navigation, popups, permissions, downloads, and multi-host separation.
|
||||
3. Toolbar entry and full browsing UI, with real click-through QA.
|
||||
4. Bookmark storage and encrypted sync migration/merge; cross-device QA.
|
||||
5. Packaged smoke tests on all supported desktop platforms and resource cleanup.
|
||||
|
||||
Primary references: [Netcatty issue #2972](https://github.com/binaricat/Netcatty/issues/2972),
|
||||
[Electron web embeds](https://www.electronjs.org/docs/latest/tutorial/web-embeds),
|
||||
[Electron session proxy](https://www.electronjs.org/docs/latest/api/session),
|
||||
[Chromium proxy behavior](https://chromium.googlesource.com/chromium/src/+/main/net/docs/proxy.md),
|
||||
[Electron security guidance](https://www.electronjs.org/docs/latest/tutorial/security).
|
||||
@@ -0,0 +1,228 @@
|
||||
# 自定义监控面板:研究与页面雏形
|
||||
|
||||
日期: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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# WaveTerm embedded browser and remote localhost
|
||||
|
||||
Checked 2026-09-29. Scope: WaveTerm's own website/docs, GitHub discussions/issues, and the 2026-09-25 source commit `c58bf7f346d0a638f3e5d7c77b2688122d36fede`. No runtime verification.
|
||||
|
||||
## Confirmed user workflow
|
||||
|
||||
- Wave has an **inline web browser** for sites, dashboards, and apps. Its website says web views can be arranged alongside terminals and editors. The docs say the right-side widget bar adds a web widget to the **current tab**; widgets can be moved, resized, and magnified. [Website](https://www.waveterm.dev/) · [Widgets](https://docs.waveterm.dev/widgets)
|
||||
- A user can enter a URL in the web widget, or run `wsh web open <url>` from a Wave terminal. `wsh web open` creates a web block; `-m` magnifies it and `-r <blockid>` replaces an existing block. The docs' local development example starts a server and then opens `http://localhost:3000` in a web block. [wsh overview](https://docs.waveterm.dev/wsh) · [wsh reference](https://docs.waveterm.dev/wsh-reference)
|
||||
- Web navigation has a URL field, back/forward, reload, in-page find, and bookmarks; the documented shortcuts include `Alt+L`, `Alt+Left/Right`, `Alt+R`, `Alt+F`, and `Alt+O`. A home page can be set globally or per widget. `web:openlinksinternally=false` sends web links to an external browser. [Key bindings](https://docs.waveterm.dev/keybindings) · [Custom widgets](https://docs.waveterm.dev/customwidgets) · [Configuration](https://docs.waveterm.dev/config)
|
||||
|
||||
## Remote `127.0.0.1`: not established as automatic
|
||||
|
||||
- **Confirmed:** Wave's SSH connection picker opens a remote terminal. With `wsh` installed, the remote can also back file-preview and system-info widgets, and a remote shell can control Wave widgets. The [Connections](https://docs.waveterm.dev/connections) page does **not** list the web widget as a connection-backed widget. The [wsh overview](https://docs.waveterm.dev/wsh) puts `wsh web open http://localhost:3000` under *Development Workflow* and lists SSH under a separate *Remote Development* example; it does not say the first command retargets localhost to an SSH host.
|
||||
- **Best reading, still an inference:** entering `http://127.0.0.1:<port>` in Wave's web widget addresses the machine running the desktop app unless the user has separately made the remote port reachable. Do **not** describe SSH remote loopback as auto-forwarded from these docs. A [May 2026 feature request](https://github.com/wavetermdev/waveterm/issues/3276) explicitly asks for a port-forwarding widget to avoid typing `ssh -L 3000:localhost:3000 user@server`; it was still open with no linked closing PR when checked on 2026-09-29. That request supports the gap but is not, by itself, proof of current behavior.
|
||||
- **Candidate manual workflow, inferred from that request:** create a local SSH tunnel such as `ssh -L 3000:localhost:3000 user@server`, then open `http://127.0.0.1:3000` in Wave's web block. This requires a working tunnel and an available local port; the reviewed docs do not present it as an integrated Wave flow. [Port-forwarding request](https://github.com/wavetermdev/waveterm/issues/3276) · [wsh web command](https://docs.waveterm.dev/wsh-reference)
|
||||
- **Different forwarding path:** the [Connections troubleshooting guide](https://docs.waveterm.dev/connections) says SSH server TCP forwarding settings can affect `wsh`; a [GitHub discussion](https://github.com/wavetermdev/waveterm/discussions/2757) reports `wsh` failing where stream-local forwarding is denied. Neither source establishes automatic forwarding of arbitrary HTTP ports to the browser.
|
||||
|
||||
## Source-code check at the pinned commit
|
||||
|
||||
- [`wsh web open`](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/cmd/wsh/cmd/wshcmd-web.go#L100-L139) creates a web block containing the supplied URL. It does not resolve the current SSH host or request a forward in this command path.
|
||||
- The [web block](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L1106-L1121) uses Electron's `<webview>` to load its URL; its [navigation path](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L393-L470) normalizes the scheme and passes the resulting URL directly to the webview. **Inference from these paths:** `localhost` resolves on the computer running Wave, even when `wsh web open` is invoked in an SSH terminal. This is not a live acceptance test.
|
||||
- The web block implements [address, back/forward, reload, external-open](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L127-L213) and [load-error feedback](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L1011-L1050). Popups are intercepted by the [host tab](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/emain/emain-tabview.ts#L315-L324) and converted into Wave's link-opening flow.
|
||||
- Wave uses an Electron `<webview>` tag. [Electron's current guidance](https://www.electronjs.org/docs/latest/api/webview-tag) advises considering alternatives such as `WebContentsView`; Netcatty should copy the workspace interaction, not assume the exact embedding mechanism is the best current choice.
|
||||
|
||||
## Security and limits of the evidence
|
||||
|
||||
- The website says Wave stores user data locally and needs no account; that is a general product statement, **not** a browser-specific guarantee about cookies, site isolation, permissions, or proxying. The browser docs found here do not specify those protections or a remote-URL trust policy. [Website](https://www.waveterm.dev/) · [Configuration](https://docs.waveterm.dev/config)
|
||||
- Web links can be opened externally, but an [older GitHub report](https://github.com/wavetermdev/waveterm/issues/1733) said the external-browser button picked Chrome instead of the system default on Ubuntu; it is version-specific and should not be generalized. A [v0.14.5 macOS report](https://github.com/wavetermdev/waveterm/issues/3428) describes unresponsiveness when switching between two web blocks; it is a report, not a confirmed universal limitation.
|
||||
|
||||
**Practical conclusion:** the documented product is a browser block beside terminal blocks. The reviewed first-party material does not confirm automatic SSH forwarding for a service bound to remote `127.0.0.1`; treat that behavior as unverified until tested in the app or confirmed by a direct WaveTerm statement.
|
||||
|
||||
For Netcatty issue [#2972](https://github.com/binaricat/Netcatty/issues/2972), the reusable product idea is a resizable web pane beside the terminal, with address, navigation, expand, and clear load errors. The distinct requirement is automatically connecting that pane to the selected SSH host's private web service, showing which host `127.0.0.1` refers to, and releasing the temporary access path when the pane closes. These are design recommendations, not claims about Wave's behavior.
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface BrowserBookmark {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
scope: 'host' | 'all-hosts';
|
||||
hostId: string | null;
|
||||
icon: string;
|
||||
iconMode?: 'auto' | 'custom';
|
||||
iconDataUrl?: string | null;
|
||||
color: string;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -50,7 +50,9 @@ export const CONVERGENT_ENTITY_COLLECTIONS = [
|
||||
'snippets',
|
||||
'notes',
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
] as const satisfies readonly CloudSyncPayloadEntityKey[];
|
||||
|
||||
export const CONVERGENT_STRING_COLLECTIONS = [
|
||||
@@ -224,12 +226,14 @@ 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 ?? [],
|
||||
notes: typedCollection<import('../models').VaultNote>(materialized.collections, 'notes'),
|
||||
noteGroups: materialized.stringCollections.noteGroups ?? [],
|
||||
portForwardingRules: typedCollection<import('../models').PortForwardingRule>(materialized.collections, 'portForwardingRules'),
|
||||
browserBookmarks: typedCollection<import('../browserBookmark').BrowserBookmark>(materialized.collections, 'browserBookmarks'),
|
||||
groupConfigs,
|
||||
settings,
|
||||
syncedAt: options.syncedAt,
|
||||
|
||||
@@ -444,6 +444,22 @@ test('trusted legacy diff treats own undefined optional fields as omitted', () =
|
||||
assert.equal(materialized.settings?.theme, 'dark');
|
||||
});
|
||||
|
||||
test('legacy browser clients cannot erase host browser bookmarks they do not know about', () => {
|
||||
const baseline = payload();
|
||||
baseline.browserBookmarks = [{
|
||||
id: 'bookmark-1', title: 'Dashboard', url: 'https://dashboard.example/',
|
||||
scope: 'host', hostId: 'host-1', icon: 'D', color: '#247969',
|
||||
order: 0, createdAt: NOW, updatedAt: NOW,
|
||||
}];
|
||||
const legacy = { ...baseline };
|
||||
delete legacy.browserBookmarks;
|
||||
|
||||
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
|
||||
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const next = applyLegacySyncPayload(state, baseline, legacy, 'legacy-device', NOW + 1);
|
||||
assert.deepEqual(materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 }).browserBookmarks, baseline.browserBookmarks);
|
||||
});
|
||||
|
||||
test('payload conversion still rejects entities that JSON cannot serialize', () => {
|
||||
const invalid = payload();
|
||||
const circular: Record<string, unknown> = {};
|
||||
|
||||
+2
-48
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -152,55 +154,7 @@ 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
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface TerminalSettings {
|
||||
middleClickPaste: boolean; // Legacy mirror for older settings payloads
|
||||
wordSeparators: string; // Characters for word selection
|
||||
linkModifier: LinkModifier; // Modifier key to click links
|
||||
linkBrowser?: 'netcatty' | 'system';
|
||||
autoCloseOnExit: boolean; // Automatically close terminal UI after eligible session exits
|
||||
disconnectedNoticeMode: DisconnectedNoticeMode; // Non-blocking terminal line or legacy dialog after disconnect
|
||||
/** Action when double-clicking a terminal session tab. */
|
||||
@@ -428,6 +429,7 @@ export const normalizeTerminalSettings = (
|
||||
middleClickBehavior,
|
||||
middleClickPaste: middleClickBehavior === 'paste',
|
||||
wordSeparators,
|
||||
linkBrowser: settings?.linkBrowser === 'system' ? 'system' as const : 'netcatty' as const,
|
||||
shiftEnterNewlineText,
|
||||
cursorBarWidth: normalizeCursorBarWidth(settings?.cursorBarWidth),
|
||||
dynamicTabTitleMode: isDynamicTabTitleMode(settings?.dynamicTabTitleMode)
|
||||
@@ -537,6 +539,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
middleClickPaste: true,
|
||||
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
|
||||
linkModifier: 'none',
|
||||
linkBrowser: 'netcatty',
|
||||
// Off by default: remote graceful channel closes can be reported as a clean
|
||||
// exit (code 0), which would auto-close tabs on network drops. Users who
|
||||
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "./monitorChart";
|
||||
import type { MonitorPoint } from "./monitorRuntime";
|
||||
|
||||
const points: MonitorPoint[] = [
|
||||
{ at: 1000, cpu: 12.5, memory: 40.123, disk: null, rx: null, tx: null },
|
||||
{ at: 2000, cpu: null, memory: 50, disk: null, rx: null, tx: null },
|
||||
{ at: 3000, cpu: 80, memory: null, disk: null, rx: null, tx: null },
|
||||
];
|
||||
test("chart hit testing returns the exact sampled value and timestamp", () => {
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", 2900, 300), points[2]);
|
||||
assert.equal(findMonitorChartPoint(points, "memory", 1100, 300), points[0]);
|
||||
});
|
||||
test("chart hit testing does not fabricate readings in missing or distant history", () => {
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", 2000, 300), null);
|
||||
assert.equal(findMonitorChartPoint(points, "cpu", -5000, 300), null);
|
||||
assert.equal(findMonitorChartPoint([], "cpu", 1000, 300), null);
|
||||
});
|
||||
|
||||
test("full histories fit the chart budget while extrema and raw hover samples survive", () => {
|
||||
const end = 1800000;
|
||||
const samples = Array.from({ length: 1800 }, (_, i) => ({ ...points[0], at: (i + 1) * 1000, cpu: 50 }));
|
||||
samples[700].cpu = 100;
|
||||
samples[701].cpu = 0;
|
||||
const geometry = buildMonitorTrendGeometry(samples, "cpu", 30, 1, end);
|
||||
assert.equal(geometry.segments.length, 1);
|
||||
const path = geometry.segments[0].path;
|
||||
assert.ok(path.split(/ [ML] /).length <= 302);
|
||||
assert.ok(path.includes(" 4.00"));
|
||||
assert.ok(path.includes(" 58.00"));
|
||||
assert.equal(geometry.visible.length, 1800);
|
||||
assert.equal(findMonitorChartPoint(geometry.visible, "cpu", samples[699].at, 0), samples[699]);
|
||||
assert.equal(samples.length, 1800);
|
||||
});
|
||||
|
||||
test("chart reduction preserves missing samples and cadence gaps", () => {
|
||||
const geometry = buildMonitorTrendGeometry([
|
||||
...points,
|
||||
{ ...points[0], at: 20000 },
|
||||
{ ...points[0], at: 21000, cpu: Infinity },
|
||||
{ ...points[0], at: 22000 },
|
||||
], "cpu", 1, 1, 22000);
|
||||
assert.equal(geometry.segments.length, 4);
|
||||
assert.ok(geometry.segments.every((segment) => !segment.path.includes(" L ")));
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { MonitorPoint } from "./monitorRuntime";
|
||||
|
||||
export type MonitorTrendField = "cpu" | "memory" | "rx" | "tx";
|
||||
|
||||
/** Keep extrema within each two-pixel column; hit testing still uses raw samples. */
|
||||
export function buildMonitorTrendGeometry(
|
||||
points: MonitorPoint[], field: MonitorTrendField, range: number, interval: number, end: number,
|
||||
) {
|
||||
const start = end - range * 60000;
|
||||
const visible = points.filter((p) => p.at >= start && p.at <= end);
|
||||
const max = field === "cpu" || field === "memory" ? 100
|
||||
: Math.max(1, ...visible.map((p) => Number.isFinite(p[field]) ? p[field]! : 0));
|
||||
const runs: MonitorPoint[][] = [];
|
||||
let run: MonitorPoint[] = [];
|
||||
for (const p of visible) {
|
||||
if (p[field] == null || !Number.isFinite(p[field])) {
|
||||
if (run.length) runs.push(run);
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
if (run.length && p.at - run[run.length - 1].at > interval * 2500) {
|
||||
runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
run.push(p);
|
||||
}
|
||||
if (run.length) runs.push(run);
|
||||
const segments = runs.map((samples) => {
|
||||
const selected = new Set([samples[0], samples[samples.length - 1]]);
|
||||
const columns = new Map<number, { min: MonitorPoint; max: MonitorPoint }>();
|
||||
for (const p of samples) {
|
||||
const column = Math.min(149, Math.floor(((p.at - start) / (end - start)) * 150));
|
||||
const extremes = columns.get(column);
|
||||
if (!extremes) columns.set(column, { min: p, max: p });
|
||||
else {
|
||||
if (p[field]! < extremes.min[field]!) extremes.min = p;
|
||||
if (p[field]! > extremes.max[field]!) extremes.max = p;
|
||||
}
|
||||
}
|
||||
for (const extremes of columns.values()) {
|
||||
selected.add(extremes.min);
|
||||
selected.add(extremes.max);
|
||||
}
|
||||
const coordinates = samples.filter((p) => selected.has(p)).map((p) => ({
|
||||
x: Math.max(0, Math.min(300, ((p.at - start) / (end - start)) * 300)),
|
||||
y: 58 - (Math.min(max, Math.max(0, p[field]!)) / max) * 54,
|
||||
}));
|
||||
const last = coordinates[coordinates.length - 1];
|
||||
return {
|
||||
path: coordinates.map((p, i) => `${i ? "L" : "M"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" "),
|
||||
firstX: coordinates[0].x, x: last.x, y: last.y,
|
||||
};
|
||||
});
|
||||
return { start, end, visible, max, segments };
|
||||
}
|
||||
|
||||
/** Hit actual samples within cursor tolerance, never an interpolated reading. */
|
||||
export function findMonitorChartPoint(
|
||||
points: MonitorPoint[],
|
||||
field: MonitorTrendField,
|
||||
at: number,
|
||||
tolerance: number,
|
||||
): MonitorPoint | null {
|
||||
let closest: MonitorPoint | null = null;
|
||||
let distance = tolerance;
|
||||
for (const point of points) {
|
||||
const value = point[field];
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
const delta = Math.abs(point.at - at);
|
||||
if (delta <= distance) {
|
||||
closest = point;
|
||||
distance = delta;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mergeMonitorSnapshotHistory, monitorStateSince, type MonitorHostState, type MonitorSnapshot } from "./monitorRuntime";
|
||||
|
||||
const state: MonitorHostState = {
|
||||
historyId: "endpoint-a",
|
||||
status: "live", at: 2000, stats: {
|
||||
cpu: 50, cpuCores: 4, cpuPerCore: [], memTotal: 100, memUsed: 25,
|
||||
memFree: 75, memBuffers: 0, memCached: 0, swapTotal: 0, swapUsed: 0,
|
||||
topProcesses: [], diskPercent: 30, diskUsed: 30, diskTotal: 100, disks: [],
|
||||
netRxSpeed: 10, netTxSpeed: 20, latencyMs: 1, netInterfaces: [],
|
||||
},
|
||||
points: [1000, 2000].map((at) => ({ at, cpu: 50, memory: 25, disk: 30, rx: 10, tx: 20 })),
|
||||
};
|
||||
test("starting or changing cadence clears all old readings and chart fields", () => {
|
||||
const cleared = monitorStateSince(state, 2000)!;
|
||||
assert.deepEqual(cleared.points, []);
|
||||
assert.equal(cleared.stats, null);
|
||||
assert.equal(cleared.at, 0);
|
||||
assert.equal(cleared.status, "queued");
|
||||
assert.equal(monitorStateSince(state, undefined), undefined);
|
||||
});
|
||||
test("only current-run readings are visible without mutating shared history", () => {
|
||||
const fresh = monitorStateSince(state, 1500)!;
|
||||
assert.deepEqual(fresh.points.map((p) => p.at), [2000]);
|
||||
assert.equal(fresh.stats, state.stats);
|
||||
assert.equal(fresh.status, "live");
|
||||
assert.equal(monitorStateSince({ ...state, status: "unsupported" }, 2000)?.status, "unsupported");
|
||||
assert.equal(monitorStateSince(state, 0)?.points.length, 2);
|
||||
assert.equal(state.points.length, 2);
|
||||
assert.equal(monitorStateSince(state, 1500)?.points, fresh.points);
|
||||
assert.equal(monitorStateSince({ ...state }, 1500)?.points, fresh.points);
|
||||
assert.notEqual(monitorStateSince(state, 1000)?.points, fresh.points);
|
||||
});
|
||||
|
||||
const snapshot = (host: MonitorHostState): MonitorSnapshot => ({
|
||||
historyMode: "replace", runtimeId: "worker", epoch: 0, takenAt: 3000,
|
||||
running: ["p"], startedAt: { p: 0 }, count: 1, hostCount: 1, activeSessions: [], hosts: { h: host },
|
||||
});
|
||||
test("incremental snapshots retain history, deduplicate races and replace on worker reset", () => {
|
||||
const original = snapshot(state);
|
||||
const point = { ...state.points[1], at: 3000 };
|
||||
const delta: MonitorSnapshot = { ...snapshot({ ...state, points: [state.points[1], point] }), historyMode: "append", takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(original, delta);
|
||||
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [1000, 2000, 3000]);
|
||||
assert.deepEqual(mergeMonitorSnapshotHistory(merged, delta).hosts.h.points, merged.hosts.h.points);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...original, takenAt: 2000 }), merged);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, epoch: 1 }).hosts.h.points.length, 2);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, historyMode: "replace" }).hosts.h.points.length, 2);
|
||||
assert.equal(original.hosts.h.points.length, 2);
|
||||
});
|
||||
test("incremental history expires after thirty minutes and remains bounded", () => {
|
||||
const points = Array.from({ length: 1800 }, (_, i) => ({ ...state.points[0], at: 10000 + i * 1000 }));
|
||||
const previous = snapshot({ ...state, points });
|
||||
const next = { ...snapshot({ ...state, points: [{ ...points.at(-1)!, at: 1810000 }] }), historyMode: "append" as const, takenAt: 1810000 };
|
||||
const merged = mergeMonitorSnapshotHistory(previous, next);
|
||||
assert.equal(merged.hosts.h.points.length, 1800);
|
||||
assert.equal(merged.hosts.h.points[0].at, 11000);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...next, takenAt: 4000000 }).hosts.h.points.length, 0);
|
||||
});
|
||||
|
||||
test("delayed replies cannot restore a stopped epoch or retired worker", () => {
|
||||
const stopped: MonitorSnapshot = { ...snapshot(state), epoch: 1, takenAt: 4000, running: [], hosts: {}, startedAt: {} };
|
||||
const old = { ...snapshot(state), historyMode: "append" as const };
|
||||
assert.equal(mergeMonitorSnapshotHistory(stopped, old), stopped);
|
||||
assert.equal(mergeMonitorSnapshotHistory(stopped, { ...old, takenAt: 5000 }), stopped);
|
||||
const restarted = { ...stopped, runtimeId: "new-worker", epoch: 0 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(restarted, old), restarted);
|
||||
const fresh = { ...restarted, runtimeId: "next-worker", takenAt: 5000 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(restarted, fresh), fresh);
|
||||
});
|
||||
|
||||
test("a new endpoint replaces retained points even when its first delta races a configuration response", () => {
|
||||
const original = snapshot(state);
|
||||
const changed: MonitorSnapshot = { ...snapshot({ ...state, historyId: "endpoint-b", points: [{ ...state.points[0], at: 2500 }] }), historyMode: "append", takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(original, changed);
|
||||
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [2500]);
|
||||
assert.equal(merged.hosts.h.historyId, "endpoint-b");
|
||||
});
|
||||
|
||||
test("unchanged stats are retained by reference while fresh, cleared and new-endpoint stats replace them", () => {
|
||||
const previous = snapshot(state);
|
||||
const delta = { ...snapshot({ ...state, stats: undefined, points: [] }), historyMode: "append" as const, takenAt: 4000 };
|
||||
const merged = mergeMonitorSnapshotHistory(previous, delta);
|
||||
assert.equal(merged.hosts.h.stats, state.stats);
|
||||
assert.equal(merged.hosts.h.points, state.points);
|
||||
assert.equal(monitorStateSince(merged.hosts.h, 0), merged.hosts.h);
|
||||
assert.deepEqual(merged.hosts.h.points, state.points);
|
||||
const fresh = { ...state.stats!, cpu: 67 };
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: fresh, points: [] } } }).hosts.h.stats, fresh);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: null, points: [] } } }).hosts.h.stats, null);
|
||||
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, historyId: "endpoint-b", stats: null, points: [] } } }).hosts.h.stats, null);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
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,5 +1,6 @@
|
||||
export type SidePanelTool =
|
||||
| 'sftp'
|
||||
| 'browser'
|
||||
| 'scripts'
|
||||
| 'history'
|
||||
| 'theme'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function detectVendorFromSshVersion(softwareVersion?: string): '' | import('./host').NetworkDeviceVendor;
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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,6 +282,8 @@ export interface SyncPayload {
|
||||
notes?: import('./models').VaultNote[];
|
||||
noteGroups?: string[];
|
||||
|
||||
monitorPanels?: import('./monitorPanels').MonitorPanel[];
|
||||
|
||||
// Group configs (connection defaults per host group)
|
||||
groupConfigs?: import('./models').GroupConfig[];
|
||||
|
||||
@@ -296,6 +298,7 @@ export interface SyncPayload {
|
||||
* are stripped — only their metadata travels.
|
||||
*/
|
||||
aiSessions?: import('../infrastructure/ai/types').AISession[];
|
||||
browserBookmarks?: import('./browserBookmark').BrowserBookmark[];
|
||||
|
||||
// Known hosts
|
||||
knownHosts?: import('./models').KnownHost[];
|
||||
@@ -479,8 +482,10 @@ export const SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'notes',
|
||||
'noteGroups',
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'knownHosts',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
] as const;
|
||||
|
||||
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
@@ -494,7 +499,9 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'notes',
|
||||
'noteGroups',
|
||||
'portForwardingRules',
|
||||
'browserBookmarks',
|
||||
'groupConfigs',
|
||||
'monitorPanels',
|
||||
'aiSessions',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -5,6 +5,31 @@ import { mergeSyncPayloads } from "./syncMerge.ts";
|
||||
import { withSyncReliabilityMeta } from "./syncReliability.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
const bookmark = (id: string, title = id) => ({
|
||||
id, title, url: `https://${id}.example.com/`, scope: 'host' as const,
|
||||
hostId: 'host-1', icon: 'A', color: '#247969', order: 0,
|
||||
createdAt: 1, updatedAt: 1,
|
||||
});
|
||||
|
||||
test('browser bookmark additions from two devices merge by ID', () => {
|
||||
const result = mergeSyncPayloads(
|
||||
payload({ browserBookmarks: [] }),
|
||||
payload({ browserBookmarks: [bookmark('local')] }),
|
||||
payload({ browserBookmarks: [bookmark('remote')] }),
|
||||
);
|
||||
assert.deepEqual(result.payload.browserBookmarks?.map((item) => item.id).sort(), ['local', 'remote']);
|
||||
});
|
||||
|
||||
test('browser bookmark deletion survives an unchanged remote and legacy omission', () => {
|
||||
const base = payload({ browserBookmarks: [bookmark('app')] });
|
||||
const local = payload({ browserBookmarks: [] });
|
||||
const unchanged = payload({ browserBookmarks: [bookmark('app')] });
|
||||
assert.deepEqual(mergeSyncPayloads(base, local, unchanged).payload.browserBookmarks, []);
|
||||
const legacy = payload();
|
||||
delete legacy.browserBookmarks;
|
||||
assert.deepEqual(mergeSyncPayloads(base, unchanged, legacy).payload.browserBookmarks, [bookmark('app')]);
|
||||
});
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user