Compare commits

..
Author SHA1 Message Date
bincxz 31700d6847 style(browser): encode glyphs in ASCII-safe source 2026-10-17 02:04:40 +08:00
bincxz 7d3cd14e0b fix(browser): skip unsupported sessions in open requests 2026-10-17 01:55:33 +08:00
bincxz a1d0397ab7 fix(browser): enforce WebRTC routing on page contents 2026-10-17 01:35:02 +08:00
bincxz 1252ec3835 fix(browser): release contexts after workspace detach 2026-10-17 01:26:03 +08:00
bincxz 5fbfdc749f fix(browser): keep vault browser requests on visible sessions 2026-10-17 01:18:13 +08:00
bincxz b0e6cdbeb9 fix(browser): share download controls across host contexts 2026-10-17 01:16:27 +08:00
bincxz ed94b4173c fix(browser): align vault actions with supported hosts 2026-10-17 01:10:20 +08:00
bincxz 7e50cc3218 fix(browser): retain open requests before the panel mounts 2026-10-17 01:04:43 +08:00
bincxz ca37599be7 fix(browser): expose actions for supported terminal types 2026-10-17 00:56:17 +08:00
bincxz 294a9aca69 fix(browser): preserve empty-vault recovery with bookmarks 2026-10-17 00:48:54 +08:00
bincxz 252d2026e0 fix(browser): mount browser content on terminal auto-open 2026-10-17 00:43:18 +08:00
bincxz e8b7a032e9 fix(browser): ignore late downloads after panel closure 2026-10-17 00:37:51 +08:00
bincxz eca4577dbd style(browser): remove the downloads panel shadow 2026-10-17 00:30:07 +08:00
bincxz 259c1970f0 fix(browser): release contexts when their split pane closes 2026-10-17 00:27:26 +08:00
bincxz 660d8483f5 fix(browser): retain the first terminal link during panel mount replay 2026-10-17 00:15:00 +08:00
bincxz ee46f36088 feat(browser): open terminal links internally with a system browser preference 2026-10-16 23:56:50 +08:00
bincxz 249211d893 style(browser): blend compact browser chrome into terminal panels 2026-10-16 23:43:58 +08:00
bincxz aedf99794c feat(browser): embed host browsing in terminal side panels 2026-10-16 23:36:43 +08:00
bincxz 059586c840 fix(browser): close malformed proxy connections without a reply 2026-10-16 23:09:12 +08:00
bincxzandCodex 05cffda7be Codex worktree snapshot: startup-cleanup
Co-authored-by: Codex
2026-10-16 23:03:35 +08:00
168 changed files with 1091 additions and 15172 deletions

No files matched your search

-41
View File
@@ -74,47 +74,6 @@ jobs:
- name: Build
run: npm run build
windows-fido:
name: windows-fido
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci --ignore-scripts
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
- name: Install user-mode OpenSSH and software authenticator toolchain
uses: msys2/setup-msys2@v2
with:
msystem: MSYS
update: true
install: gcc openssl-devel openssh
- name: Build disposable software authenticator
shell: msys2 {0}
run: |
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
cygpath -w /usr/bin >> "$GITHUB_PATH"
ssh -V
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
env:
NETCATTY_FIDO_WINDOWS: "1"
run: node --test scripts/fido-windows.live.test.cjs electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
windows-broadcast-ssh:
name: windows-broadcast-ssh
runs-on: windows-latest
-4
View File
@@ -1,5 +1,3 @@
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
import React, { useEffect } from 'react';
import { initializeFonts } from './application/state/fontStore';
@@ -49,9 +47,7 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
function App() {
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
<AppSideEffects />
<MonitorHostKeyDialog />
<AppShell />
</AppLocalStateProvider>
);
-14
View File
@@ -500,20 +500,6 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
}
}
export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, response, false);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, '', true);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
+1 -52
View File
@@ -86,7 +86,6 @@ import { toast } from '../../components/ui/toast';
import { VaultSection } from '../../components/VaultView';
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
import { classifyLocalShellType } from '../../lib/localShell';
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
@@ -95,7 +94,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -140,8 +139,6 @@ export function AppSideEffects() {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
@@ -839,48 +836,6 @@ export function AppSideEffects() {
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []);
// FIDO2 PIN / touch prompts from main-process askpass / sk-helper
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptRequest) return;
const unsubscribe = bridge.onFidoPromptRequest((request) => {
console.log('[App] FIDO prompt request:', request);
setFidoPromptQueue((prev) => [...prev, {
...request,
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
}]);
});
return () => { unsubscribe?.(); };
}, []);
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
}, [t]);
const handleFidoPromptCancel = useCallback((requestId: string) => {
return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptTimeout) return;
const unsubscribe = bridge.onFidoPromptTimeout((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
toast.error(t('fido.prompt.timeout'));
});
return () => { unsubscribe?.(); };
}, [t]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptCancelled) return;
const unsubscribe = bridge.onFidoPromptCancelled((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
});
return () => { unsubscribe?.(); };
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -2013,8 +1968,6 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
handleRequestCloseEditorTabRef,
resolveEmptyVaultConflict,
@@ -2038,7 +1991,6 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
@@ -2087,8 +2039,6 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
resolveEmptyVaultConflict,
handleCancelDeleteHost,
@@ -2104,7 +2054,6 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
+2 -10
View File
@@ -13,7 +13,6 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { FidoPromptModal } from '../../components/FidoPromptModal';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
@@ -269,11 +268,11 @@ function AppViewInner({ domains }: AppViewProps) {
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -1038,13 +1037,6 @@ function AppViewInner({ domains }: AppViewProps) {
onSkip={handlePassphraseSkip}
/>
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
<FidoPromptModal
request={fidoPromptQueue?.[0] || null}
onSubmit={handleFidoPromptSubmit}
onCancel={handleFidoPromptCancel}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
-7
View File
@@ -1,7 +0,0 @@
import { useMonitorRuntime } from '../state/useMonitorRuntime';
/** Keep monitoring state and subscriptions outside the app coordinator. */
export function MonitorRuntimeHost() {
useMonitorRuntime();
return null;
}
-4
View File
@@ -4,7 +4,6 @@ import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
type Listener = () => void;
@@ -32,7 +31,6 @@ export type AppLocalUiSnapshot = {
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
fidoPromptQueue: readonly FidoPromptRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
@@ -51,7 +49,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
@@ -84,7 +81,6 @@ class AppLocalUiStore {
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.fidoPromptQueue === next.fidoPromptQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
-4
View File
@@ -58,15 +58,12 @@ export function DialogsHost() {
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleFidoPromptCancel: handlers.handleFidoPromptCancel,
handleFidoPromptSubmit: handlers.handleFidoPromptSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
@@ -86,7 +83,6 @@ export function DialogsHost() {
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
@@ -1,58 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useState } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
assert.ok(declaration, `${name} must exist`);
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
});
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
}
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let updateMonitor: (() => void) | undefined;
let runtimeRenders = 0;
let sideEffectRenders = 0;
let shellRenders = 0;
const useMonitorRuntime = () => {
const [, setVersion] = useState(0);
updateMonitor = () => setVersion((version) => version + 1);
runtimeRenders += 1;
};
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', {
useMonitorRuntime,
MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
AppSideEffects: () => { sideEffectRenders += 1; return null; },
MonitorHostKeyDialog: () => null,
AppShell: React.memo(() => { shellRenders += 1; return null; }),
});
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(App)); });
const initialRuntimeRenders = runtimeRenders;
const initialSideEffectRenders = sideEffectRenders;
const initialShellRenders = shellRenders;
for (let i = 0; i < 10; i += 1) {
await act(async () => updateMonitor!());
}
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
} finally {
await act(async () => renderer?.unmount());
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
+1 -1
View File
@@ -542,7 +542,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}
return;
}
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
@@ -1,12 +1,9 @@
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
installBrowserLockFixture();
const NOW = 1_700_000_000_000;
const localStorageValues = new Map<string, string>();
-2
View File
@@ -1,4 +1,3 @@
import { enMonitorMessages } from './en/monitor';
import type { Messages } from './types';
import { enCoreMessages } from './en/core';
import { enVaultMessages } from './en/vault';
@@ -10,7 +9,6 @@ import { enScriptsMessages } from './en/scripts';
export type { Messages } from './types';
const en: Messages = {
...enMonitorMessages,
...enCoreMessages,
...enVaultMessages,
...enTerminalMessages,
-95
View File
@@ -1,95 +0,0 @@
export const enMonitorMessages = {
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
"monitor.password": "Password (this run only)",
"monitor.status.limit":
"Monitoring is limited to 256 unique hosts at a time.",
"sync.entityType.monitorPanels": "monitor panels",
"monitor.title": "Monitoring",
"monitor.create": "New panel",
"monitor.createFirst": "Create your first panel",
"monitor.empty": "Your hosts, at a glance",
"monitor.emptyHint":
"Choose saved hosts from any group. Start monitoring when you are ready.",
"monitor.choosePanel": "Choose a panel",
"monitor.edit": "Edit panel",
"monitor.copy": "Duplicate panel",
"monitor.copyName": "{name} copy",
"monitor.delete": "Delete panel",
"monitor.deleteHint":
"Delete “{name}”? Its monitoring will stop. Saved hosts and other panels are kept.",
"monitor.start": "Start monitoring",
"monitor.stop": "Stop",
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search name, address or group",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
"monitor.minutes": "Last {count} min",
"monitor.sort": "Sort hosts",
"monitor.sort.manual": "Saved order",
"monitor.sort.name": "Name",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.noMatches": "No matching hosts",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
"monitor.status": "Status",
"monitor.cpu": "CPU",
"monitor.cores": "Cores: {count}",
"monitor.memory": "Memory",
"monitor.diskCapacity": "{used} used / {total} total",
"monitor.diskCapacityUnknown": "Capacity unavailable",
"monitor.rootDisk": "Root partition",
"monitor.network": "Network",
"monitor.download": "Download",
"monitor.upload": "Upload",
"monitor.updated": "Updated",
"monitor.updatedAt": "Updated {time}",
"monitor.noSamples": "Waiting for the first reading",
"monitor.cpuTrend": "CPU trend",
"monitor.memoryTrend": "Memory trend",
"monitor.downloadTrend": "Download trend",
"monitor.uploadTrend": "Upload trend",
"monitor.memoryOnlyHint":
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
"monitor.name": "Panel name",
"monitor.description": "Description (optional)",
"monitor.chooseHosts": "Choose hosts",
"monitor.selectionHint":
"Choose across groups. Unsupported connections are shown with a clear status when started.",
"monitor.defaultView": "Default view",
"monitor.saveHint":
"Saving keeps configuration only and does not connect. Copies start stopped too.",
"monitor.nameRequired": "Enter a panel name.",
"monitor.hostRequired": "Select at least one host.",
"monitor.duplicateName": "A panel already uses this name.",
"monitor.saveFailed": "Could not save the panel. Please try again.",
"monitor.deleted":
"This panel was deleted in another window. Close the editor and create a new panel.",
"monitor.moveUp": "Move host up",
"monitor.removeHost": "Remove from panel",
"monitor.missingHost": "Deleted host",
"monitor.observed": "Observed for {count} min this run",
"monitor.disks": "Partitions",
"monitor.system": "System information",
"monitor.loginHint":
"Check the host credentials or connection settings, then retry. Identity and sign-in prompts are handled one host at a time.",
"monitor.retry": "Retry connection",
"monitor.unsupportedHint":
"Monitoring needs Linux or macOS over SSH with support for additional command channels.",
"monitor.openTerminal": "Open terminal",
"monitor.confirmHost": "Confirm host identity",
"monitor.status.stopped": "Not started",
"monitor.status.queued": "Queued",
"monitor.status.connecting": "Connecting",
"monitor.status.live": "Up to date",
"monitor.status.login": "Sign-in needed",
"monitor.status.error": "Collection failed",
"monitor.status.unsupported": "Unavailable",
"monitor.status.disconnected": "Disconnected",
"monitor.status.stale": "Out of date",
"monitor.status.missing": "Host deleted",
};
+2
View File
@@ -36,6 +36,8 @@ export const enScriptsMessages = {
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.sidePanel.viewList': 'List view',
'scripts.sidePanel.viewStacked': 'Stacked view',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
-43
View File
@@ -439,12 +439,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
'cloudSync.localBackups.clear': 'Clear all backups',
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
@@ -637,43 +631,6 @@ export const enTerminalMessages: Messages = {
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.standardKeys': 'Standard keys',
'keychain.generate.securityKeys': 'Security keys · FIDO2',
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
'keychain.generate.generating': 'Generating…',
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
'fido.prompt.details': 'Request details',
'fido.prompt.showPin': 'Show PIN',
'fido.prompt.hidePin': 'Hide PIN',
'fido.prompt.verifying': 'Verifying…',
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
'fido.prompt.pinTitle': 'Security key PIN',
'fido.prompt.touchTitle': 'Touch your security key',
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Waiting for your key…',
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
"fido.recovery.device-unavailable.title": "Connect the matching security key",
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
"fido.recovery.timeout.title": "Security key confirmation timed out",
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
"fido.recovery.failed.title": "Security key verification did not complete",
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
"fido.recovery.retry": "Retry verification",
"fido.recovery.usePassword": "Use configured password",
"fido.recovery.passwordFallback": "Trying the password configured for this host",
"fido.prompt.cancelled": "Security key authentication cancelled.",
'fido.prompt.touchDone': 'I touched it',
'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.',
'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.',
'keychain.generate.resident': 'Store credentials on the key',
'keychain.generate.verifyRequired': 'Verify PIN each time',
'keychain.action.loadResident': 'Load resident keys from security key',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
-2
View File
@@ -1,4 +1,3 @@
import { esMonitorMessages } from './es/monitor';
import type { Messages } from './types';
import { esCoreMessages } from './es/core';
import { esVaultMessages } from './es/vault';
@@ -10,7 +9,6 @@ import { esScriptsMessages } from './es/scripts';
export type { Messages } from './types';
const es: Messages = {
...esMonitorMessages,
...esCoreMessages,
...esVaultMessages,
...esTerminalMessages,
-95
View File
@@ -1,95 +0,0 @@
export const esMonitorMessages = {
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
"monitor.password": "Contraseña (solo esta ejecución)",
"monitor.status.limit":
"Se pueden monitorizar hasta 256 equipos únicos a la vez.",
"sync.entityType.monitorPanels": "paneles de monitorización",
"monitor.title": "Monitorización",
"monitor.create": "Nuevo panel",
"monitor.createFirst": "Crear el primer panel",
"monitor.empty": "Tus equipos de un vistazo",
"monitor.emptyHint":
"Elige equipos guardados de cualquier grupo e inicia cuando quieras.",
"monitor.choosePanel": "Elegir panel",
"monitor.edit": "Editar panel",
"monitor.copy": "Duplicar panel",
"monitor.copyName": "Copia de {name}",
"monitor.delete": "Eliminar panel",
"monitor.deleteHint":
"¿Eliminar «{name}»? Se detendrá su monitorización. Se conservarán los equipos y otros paneles.",
"monitor.start": "Iniciar monitorización",
"monitor.stop": "Detener",
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar nombre, dirección o grupo",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
"monitor.minutes": "Últimos {count} min",
"monitor.sort": "Ordenar equipos",
"monitor.sort.manual": "Orden guardado",
"monitor.sort.name": "Nombre",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
"monitor.status": "Estado",
"monitor.cpu": "CPU",
"monitor.cores": "Núcleos: {count}",
"monitor.memory": "Memoria",
"monitor.diskCapacity": "{used} usados / {total} en total",
"monitor.diskCapacityUnknown": "Capacidad no disponible",
"monitor.rootDisk": "Partición raíz",
"monitor.network": "Red",
"monitor.download": "Descarga",
"monitor.upload": "Subida",
"monitor.updated": "Actualizado",
"monitor.updatedAt": "Actualizado: {time}",
"monitor.noSamples": "Esperando la primera lectura",
"monitor.cpuTrend": "Tendencia de CPU",
"monitor.memoryTrend": "Tendencia de memoria",
"monitor.downloadTrend": "Tendencia de descarga",
"monitor.uploadTrend": "Tendencia de subida",
"monitor.memoryOnlyHint":
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
"monitor.name": "Nombre del panel",
"monitor.description": "Descripción (opcional)",
"monitor.chooseHosts": "Elegir equipos",
"monitor.selectionHint":
"Selecciona entre grupos. Las conexiones no compatibles se indicarán al iniciar.",
"monitor.defaultView": "Vista predeterminada",
"monitor.saveHint":
"Guardar conserva la configuración sin conectar. Las copias también empiezan detenidas.",
"monitor.nameRequired": "Escribe un nombre para el panel.",
"monitor.hostRequired": "Selecciona al menos un equipo.",
"monitor.duplicateName": "Ya existe un panel con ese nombre.",
"monitor.saveFailed": "No se pudo guardar el panel. Inténtalo de nuevo.",
"monitor.deleted":
"El panel se eliminó en otra ventana. Cierra el editor y crea uno nuevo.",
"monitor.moveUp": "Subir equipo",
"monitor.removeHost": "Quitar del panel",
"monitor.missingHost": "Equipo eliminado",
"monitor.observed": "Observado durante {count} min",
"monitor.disks": "Particiones",
"monitor.system": "Información del sistema",
"monitor.loginHint":
"Comprueba las credenciales o la conexión y reintenta. Las solicitudes se procesan equipo por equipo.",
"monitor.retry": "Reintentar conexión",
"monitor.unsupportedHint":
"Se necesita Linux o macOS por SSH con canales de comandos adicionales.",
"monitor.openTerminal": "Abrir terminal",
"monitor.confirmHost": "Confirmar identidad",
"monitor.status.stopped": "Sin iniciar",
"monitor.status.queued": "En cola",
"monitor.status.connecting": "Conectando",
"monitor.status.live": "Actualizado",
"monitor.status.login": "Requiere acceso",
"monitor.status.error": "Error de lectura",
"monitor.status.unsupported": "No disponible",
"monitor.status.disconnected": "Desconectado",
"monitor.status.stale": "Sin actualizar",
"monitor.status.missing": "Equipo eliminado",
};
+2
View File
@@ -36,6 +36,8 @@ export const esScriptsMessages = {
'scripts.sidePanel.library': 'Biblioteca',
'scripts.sidePanel.running': 'En ejecución',
'scripts.sidePanel.newScript': 'Nuevo script',
'scripts.sidePanel.viewList': 'Vista de lista',
'scripts.sidePanel.viewStacked': 'Vista apilada',
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
'scripts.running.unnamed': 'Script sin título',
'scripts.running.status.running': 'En ejecución',
-6
View File
@@ -438,12 +438,6 @@ export const esTerminalMessages: Messages = {
'cloudSync.history.download': 'Descarga',
'cloudSync.history.resolved': 'Resuelto',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
@@ -1,40 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { enMonitorMessages } from "./en/monitor";
import { esMonitorMessages } from "./es/monitor";
import { ruMonitorMessages } from "./ru/monitor";
import { zhCNMonitorMessages } from "./zh-CN/monitor";
import { zhTWMonitorMessages } from "./zh-TW/monitor";
test("monitoring UI literal message keys exist, including editor-only controls", () => {
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
for (const key of keys)
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
}
});
for (const [locale, messages] of Object.entries({
es: esMonitorMessages,
ru: ruMonitorMessages,
"zh-CN": zhCNMonitorMessages,
"zh-TW": zhTWMonitorMessages,
})) {
test(`monitoring has translated actions, states and matching placeholders in ${locale}`, () => {
assert.deepEqual(
Object.keys(messages).sort(),
Object.keys(enMonitorMessages).sort(),
);
for (const key of Object.keys(enMonitorMessages)) {
const english = enMonitorMessages[key as keyof typeof enMonitorMessages];
const translated = messages[key as keyof typeof messages];
assert.ok(translated.trim(), key);
assert.deepEqual(
(translated.match(/\{\w+\}/g) ?? []).sort(),
(english.match(/\{\w+\}/g) ?? []).sort(),
key,
);
}
});
}
-2
View File
@@ -1,4 +1,3 @@
import { ruMonitorMessages } from './ru/monitor';
import type { Messages } from './types';
import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
@@ -10,7 +9,6 @@ import { ruScriptsMessages } from './ru/scripts';
export type { Messages } from './types';
const ru: Messages = {
...ruMonitorMessages,
...ruCoreMessages,
...ruVaultMessages,
...ruTerminalMessages,
-94
View File
@@ -1,94 +0,0 @@
export const ruMonitorMessages = {
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
"monitor.password": "Пароль (только для этого запуска)",
"monitor.status.limit": "Одновременно можно наблюдать не более 256 серверов.",
"sync.entityType.monitorPanels": "панели мониторинга",
"monitor.title": "Мониторинг",
"monitor.create": "Новая панель",
"monitor.createFirst": "Создать первую панель",
"monitor.empty": "Все серверы перед глазами",
"monitor.emptyHint":
"Выберите сохранённые серверы из любых групп и запустите мониторинг.",
"monitor.choosePanel": "Выбрать панель",
"monitor.edit": "Изменить панель",
"monitor.copy": "Дублировать панель",
"monitor.copyName": "Копия {name}",
"monitor.delete": "Удалить панель",
"monitor.deleteHint":
"Удалить «{name}»? Мониторинг этой панели остановится. Серверы и другие панели сохранятся.",
"monitor.start": "Начать мониторинг",
"monitor.stop": "Остановить",
"monitor.stopAll": "Остановить всё",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Имя, адрес или группа",
"monitor.interval": "Интервал обновления",
"monitor.range": "Период графика",
"monitor.seconds": "Каждые {count} с",
"monitor.minutes": "Последние {count} мин",
"monitor.sort": "Сортировка серверов",
"monitor.sort.manual": "Сохранённый порядок",
"monitor.sort.name": "Имя",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
"monitor.status": "Состояние",
"monitor.cpu": "ЦП",
"monitor.cores": "Ядер: {count}",
"monitor.memory": "Память",
"monitor.diskCapacity": "Использовано {used} / всего {total}",
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
"monitor.rootDisk": "Корневой раздел",
"monitor.network": "Сеть",
"monitor.download": "Загрузка",
"monitor.upload": "Отправка",
"monitor.updated": "Обновлено",
"monitor.updatedAt": "Обновлено: {time}",
"monitor.noSamples": "Ожидание первого измерения",
"monitor.cpuTrend": "График ЦП",
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.memoryOnlyHint":
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
"monitor.selectionHint":
"Можно выбирать из разных групп. Неподдерживаемые подключения будут отмечены при запуске.",
"monitor.defaultView": "Вид по умолчанию",
"monitor.saveHint":
"Сохранение не подключает серверы. Копии также создаются остановленными.",
"monitor.nameRequired": "Введите название панели.",
"monitor.hostRequired": "Выберите хотя бы один сервер.",
"monitor.duplicateName": "Панель с таким названием уже существует.",
"monitor.saveFailed": "Не удалось сохранить панель. Повторите попытку.",
"monitor.deleted":
"Панель удалена в другом окне. Закройте редактор и создайте новую.",
"monitor.moveUp": "Переместить выше",
"monitor.removeHost": "Убрать из панели",
"monitor.missingHost": "Удалённый сервер",
"monitor.observed": "Наблюдение: {count} мин",
"monitor.disks": "Разделы дисков",
"monitor.system": "Информация о системе",
"monitor.loginHint":
"Проверьте данные входа и настройки подключения. Подтверждения обрабатываются по одному серверу.",
"monitor.retry": "Повторить подключение",
"monitor.unsupportedHint":
"Для мониторинга нужен Linux или macOS по SSH с дополнительными каналами команд.",
"monitor.openTerminal": "Открыть терминал",
"monitor.confirmHost": "Подтвердить сервер",
"monitor.status.stopped": "Не запущено",
"monitor.status.queued": "В очереди",
"monitor.status.connecting": "Подключение",
"monitor.status.live": "Актуально",
"monitor.status.login": "Нужен вход",
"monitor.status.error": "Ошибка сбора",
"monitor.status.unsupported": "Недоступно",
"monitor.status.disconnected": "Отключено",
"monitor.status.stale": "Устарело",
"monitor.status.missing": "Сервер удалён",
};
+2
View File
@@ -36,6 +36,8 @@ export const ruScriptsMessages = {
'scripts.sidePanel.library': 'Библиотека',
'scripts.sidePanel.running': 'Выполняются',
'scripts.sidePanel.newScript': 'Новый скрипт',
'scripts.sidePanel.viewList': 'Список',
'scripts.sidePanel.viewStacked': 'Плитка',
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
'scripts.running.unnamed': 'Скрипт без названия',
'scripts.running.status.running': 'Выполняется',
-43
View File
@@ -457,12 +457,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.history.download': 'Скачивание',
'cloudSync.history.resolved': 'Разрешено',
'cloudSync.history.error': 'Ошибка',
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
'cloudSync.localBackups.clear': 'Удалить все копии',
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
@@ -652,43 +646,6 @@ export const ruTerminalMessages: Messages = {
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.standardKeys': 'Обычные ключи',
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
'keychain.generate.generating': 'Создание…',
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
'fido.prompt.details': 'Подробности запроса',
'fido.prompt.showPin': 'Показать PIN',
'fido.prompt.hidePin': 'Скрыть PIN',
'fido.prompt.verifying': 'Проверка…',
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
'fido.prompt.pinTitle': 'PIN ключа безопасности',
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
"fido.recovery.failed.title": "Проверка ключа не завершена",
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
"fido.recovery.retry": "Повторить проверку",
"fido.recovery.usePassword": "Использовать сохранённый пароль",
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
'fido.prompt.touchDone': 'Я коснулся',
'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.',
'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.',
'keychain.generate.resident': 'Хранить учётные данные на ключе',
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
test('scripts side panel view-toggle copy exists in every locale', () => {
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
}
});
-2
View File
@@ -1,4 +1,3 @@
import { zhCNMonitorMessages } from './zh-CN/monitor';
import type { Messages } from './types';
import { zhCNCoreMessages } from './zh-CN/core';
import { zhCNVaultMessages } from './zh-CN/vault';
@@ -10,7 +9,6 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
export type { Messages } from './types';
const zhCN: Messages = {
...zhCNMonitorMessages,
...zhCNCoreMessages,
...zhCNVaultMessages,
...zhCNTerminalMessages,
-90
View File
@@ -1,90 +0,0 @@
export const zhCNMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
"monitor.password": "密码(仅本次使用)",
"monitor.status.limit": "同时最多监控 256 台不同主机。",
"sync.entityType.monitorPanels": "监控面板",
"monitor.title": "监控",
"monitor.create": "新建面板",
"monitor.createFirst": "创建第一个监控面板",
"monitor.empty": "一眼查看多台主机",
"monitor.emptyHint": "跨分组选择已有主机,准备好后再开始监控。",
"monitor.choosePanel": "选择面板",
"monitor.edit": "编辑面板",
"monitor.copy": "复制面板",
"monitor.copyName": "{name} 副本",
"monitor.delete": "删除面板",
"monitor.deleteHint":
"删除“{name}”?该面板将停止监控,已有主机和其他面板会保留。",
"monitor.start": "开始监控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索名称、地址或分组",
"monitor.interval": "更新频率",
"monitor.range": "趋势范围",
"monitor.seconds": "每 {count} 秒",
"monitor.minutes": "最近 {count} 分钟",
"monitor.sort": "主机排序",
"monitor.sort.manual": "保存的顺序",
"monitor.sort.name": "名称",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.noMatches": "没有符合条件的主机",
"monitor.host": "主机",
"monitor.hosts": "主机",
"monitor.status": "状态",
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核",
"monitor.memory": "内存",
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
"monitor.diskCapacityUnknown": "暂无容量读数",
"monitor.rootDisk": "根分区",
"monitor.network": "网络",
"monitor.download": "下载",
"monitor.upload": "上传",
"monitor.updated": "更新时间",
"monitor.updatedAt": "更新于 {time}",
"monitor.noSamples": "等待第一次读数",
"monitor.cpuTrend": "CPU 趋势",
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.memoryOnlyHint":
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
"monitor.selectionHint": "可跨分组选择。开始后会明确标出不支持的连接。",
"monitor.defaultView": "默认展示",
"monitor.saveHint": "保存仅保留配置,不会连接。复制的面板也不会自动开始。",
"monitor.nameRequired": "请输入面板名称。",
"monitor.hostRequired": "请至少选择一台主机。",
"monitor.duplicateName": "已有同名面板,请换个名称。",
"monitor.saveFailed": "面板未能保存,请重试。",
"monitor.deleted": "该面板已在其他窗口删除。请关闭编辑后新建面板。",
"monitor.moveUp": "主机上移",
"monitor.removeHost": "从面板移除",
"monitor.missingHost": "已删除的主机",
"monitor.observed": "本次已观察 {count} 分钟",
"monitor.disks": "磁盘分区",
"monitor.system": "系统信息",
"monitor.loginHint":
"请检查主机的登录信息或连接设置后重试。身份确认和登录提示会逐台处理。",
"monitor.retry": "重试连接",
"monitor.unsupportedHint":
"监控需要支持额外命令通道的 Linux 或 macOS SSH 连接。",
"monitor.openTerminal": "打开终端",
"monitor.confirmHost": "确认主机身份",
"monitor.status.stopped": "未开始",
"monitor.status.queued": "排队中",
"monitor.status.connecting": "连接中",
"monitor.status.live": "已更新",
"monitor.status.login": "需要登录",
"monitor.status.error": "采集失败",
"monitor.status.unsupported": "不支持",
"monitor.status.disconnected": "已断开",
"monitor.status.stale": "读数过期",
"monitor.status.missing": "主机已删除",
};
@@ -36,6 +36,8 @@ export const zhCNScriptsMessages = {
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.sidePanel.viewList': '列表视图',
'scripts.sidePanel.viewStacked': '堆叠视图',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
+2 -39
View File
@@ -716,50 +716,13 @@ export const zhCNTerminalMessages: Messages = {
'keychain.export.scriptRequired': '脚本 *',
'keychain.export.exportToHost': '导出到主机',
'keychain.export.failedGeneric': '导出失败:{message}',
'keychain.field.label': '名称',
'keychain.field.labelRequired': '名称 *',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '密钥 Label',
'keychain.field.privateKeyRequired': '私钥 *',
'keychain.field.publicKey': '公钥',
'keychain.field.certificatePlaceholder': '证书内容(可选)',
'keychain.generate.keyType': '密钥类型',
'keychain.generate.standardKeys': '标准密钥',
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
'keychain.generate.generating': '正在生成…',
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
'fido.prompt.details': '查看请求详情',
'fido.prompt.showPin': '显示 PIN',
'fido.prompt.hidePin': '隐藏 PIN',
'fido.prompt.verifying': '正在验证…',
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
'fido.prompt.pinTitle': '安全密钥 PIN',
'fido.prompt.touchTitle': '请触摸安全密钥',
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全密钥响应…',
'fido.prompt.touchAutomatic': '触摸后将自动继续',
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
"fido.recovery.timeout.title": "等待安全密钥确认超时",
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
"fido.recovery.failed.title": "安全密钥验证未完成",
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
"fido.recovery.retry": "重试验证",
"fido.recovery.usePassword": "改用已配置的密码",
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
"fido.prompt.cancelled": "已取消安全密钥验证。",
'fido.prompt.touchDone': '已触摸',
'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。',
'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。',
'keychain.generate.resident': '在设备上保存凭据',
'keychain.generate.verifyRequired': '每次验证 PIN',
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
'keychain.generate.keySize': '密钥长度',
'keychain.generate.labelPlaceholder': '密钥 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
-6
View File
@@ -842,12 +842,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
'cloudSync.localBackups.clear': '清空全部备份',
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
-2
View File
@@ -1,4 +1,3 @@
import { zhTWMonitorMessages } from './zh-TW/monitor';
import type { Messages } from './types';
import { zhTWCoreMessages } from './zh-TW/core';
import { zhTWVaultMessages } from './zh-TW/vault';
@@ -10,7 +9,6 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
export type { Messages } from './types';
const zhTW: Messages = {
...zhTWMonitorMessages,
...zhTWCoreMessages,
...zhTWVaultMessages,
...zhTWTerminalMessages,
-90
View File
@@ -1,90 +0,0 @@
export const zhTWMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
"monitor.password": "密碼(僅本次使用)",
"monitor.status.limit": "同時最多監控 256 台不同主機。",
"sync.entityType.monitorPanels": "監控面板",
"monitor.title": "監控",
"monitor.create": "新增面板",
"monitor.createFirst": "建立第一個監控面板",
"monitor.empty": "一眼查看多台主機",
"monitor.emptyHint": "跨群組選擇現有主機,準備好後再開始監控。",
"monitor.choosePanel": "選擇面板",
"monitor.edit": "編輯面板",
"monitor.copy": "複製面板",
"monitor.copyName": "{name} 副本",
"monitor.delete": "刪除面板",
"monitor.deleteHint":
"刪除「{name}」?此面板將停止監控,現有主機和其他面板會保留。",
"monitor.start": "開始監控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋名稱、位址或群組",
"monitor.interval": "更新頻率",
"monitor.range": "趨勢範圍",
"monitor.seconds": "每 {count} 秒",
"monitor.minutes": "最近 {count} 分鐘",
"monitor.sort": "主機排序",
"monitor.sort.manual": "儲存的順序",
"monitor.sort.name": "名稱",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.host": "主機",
"monitor.hosts": "主機",
"monitor.status": "狀態",
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核心",
"monitor.memory": "記憶體",
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
"monitor.diskCapacityUnknown": "暫無容量讀數",
"monitor.rootDisk": "根分割區",
"monitor.network": "網路",
"monitor.download": "下載",
"monitor.upload": "上傳",
"monitor.updated": "更新時間",
"monitor.updatedAt": "更新於 {time}",
"monitor.noSamples": "等待第一次讀數",
"monitor.cpuTrend": "CPU 趨勢",
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.memoryOnlyHint":
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
"monitor.selectionHint": "可跨群組選擇。開始後會明確標示不支援的連線。",
"monitor.defaultView": "預設檢視",
"monitor.saveHint": "儲存僅保留設定,不會連線。複製的面板也不會自動開始。",
"monitor.nameRequired": "請輸入面板名稱。",
"monitor.hostRequired": "請至少選擇一台主機。",
"monitor.duplicateName": "已有同名面板,請換個名稱。",
"monitor.saveFailed": "面板無法儲存,請重試。",
"monitor.deleted": "此面板已在其他視窗刪除。請關閉編輯後新增面板。",
"monitor.moveUp": "主機上移",
"monitor.removeHost": "從面板移除",
"monitor.missingHost": "已刪除的主機",
"monitor.observed": "本次已觀察 {count} 分鐘",
"monitor.disks": "磁碟分割區",
"monitor.system": "系統資訊",
"monitor.loginHint":
"請檢查主機的登入資訊或連線設定後重試。身分確認與登入提示會逐台處理。",
"monitor.retry": "重試連線",
"monitor.unsupportedHint":
"監控需要支援額外命令通道的 Linux 或 macOS SSH 連線。",
"monitor.openTerminal": "開啟終端機",
"monitor.confirmHost": "確認主機身分",
"monitor.status.stopped": "未開始",
"monitor.status.queued": "排程中",
"monitor.status.connecting": "連線中",
"monitor.status.live": "已更新",
"monitor.status.login": "需要登入",
"monitor.status.error": "採集失敗",
"monitor.status.unsupported": "不支援",
"monitor.status.disconnected": "已中斷",
"monitor.status.stale": "讀數過期",
"monitor.status.missing": "主機已刪除",
};
@@ -36,6 +36,8 @@ export const zhTWScriptsMessages = {
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新增指令碼',
'scripts.sidePanel.viewList': '列表檢視',
'scripts.sidePanel.viewStacked': '堆疊檢視',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
+2 -39
View File
@@ -716,50 +716,13 @@ export const zhTWTerminalMessages: Messages = {
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': '名稱',
'keychain.field.labelRequired': '名稱 *',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.standardKeys': '標準金鑰',
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
'keychain.generate.generating': '正在產生…',
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
'fido.prompt.details': '檢視請求詳情',
'fido.prompt.showPin': '顯示 PIN',
'fido.prompt.hidePin': '隱藏 PIN',
'fido.prompt.verifying': '正在驗證…',
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
'fido.prompt.pinTitle': '安全金鑰 PIN',
'fido.prompt.touchTitle': '請觸摸安全金鑰',
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
"fido.recovery.failed.title": "安全金鑰驗證未完成",
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
"fido.recovery.retry": "重試驗證",
"fido.recovery.usePassword": "改用已設定的密碼",
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
'fido.prompt.touchDone': '已觸摸',
'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。',
'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。',
'keychain.generate.resident': '在裝置上儲存憑證',
'keychain.generate.verifyRequired': '每次驗證 PIN',
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
-6
View File
@@ -842,12 +842,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.history.download': '下載',
'cloudSync.history.resolved': '已解決',
'cloudSync.history.error': '錯誤',
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
'cloudSync.localBackups.clear': '清空全部備份',
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
+1 -87
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import test from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
@@ -132,89 +132,3 @@ for (const localRestore of [false, true]) {
});
}
}
function installBackupLocks(t: TestContext) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
let readers = 0;
let writer: Promise<void> | null = null;
const locks = {
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
if (options.mode === 'shared') {
if (writer) await writer;
readers += 1;
try { return await callback({}); } finally { readers -= 1; }
}
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
if (readers || writer) return callback(null);
let release!: () => void;
writer = new Promise<void>((resolve) => { release = resolve; });
try { return await callback({}); } finally { writer = null; release(); }
},
};
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
else Reflect.deleteProperty(globalThis, 'navigator');
});
}
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
}
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
await active;
try {
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
} finally {
finish();
await restoring;
}
await clearLocalVaultBackups();
assert.equal(clears, 1);
});
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
installClearBridge(t, async () => { entered(); await gate; });
const clearing = clearLocalVaultBackups();
await active;
let applied = false;
const restoring = withRestoreBarrier(async () => { applied = true; });
await Promise.resolve();
assert.equal(applied, false);
finish();
await Promise.all([clearing, restoring]);
assert.equal(applied, true);
});
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
});
-28
View File
@@ -10,15 +10,6 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
export class LocalVaultBackupBusyError extends Error {
constructor() {
super('Finish restoring or recovering the vault before clearing backups.');
this.name = 'LocalVaultBackupBusyError';
}
}
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
@@ -95,16 +86,6 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function clearLocalVaultBackups(): Promise<void> {
const bridge = netcattyBridge.get();
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
await bridge.clearVaultBackups!();
});
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
@@ -284,15 +265,6 @@ export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
// Shared locks allow existing nested restore helpers. Cleanup never queues
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
return locks
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
: runWithRestoreBarrier(task, holdMs);
}
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
-155
View File
@@ -1,155 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import type { MonitorPanel } from "../domain/monitorPanels";
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
const {
buildCloudSyncPayload,
buildLocalVaultPayload,
applySyncPayload,
applyLocalVaultPayload,
} = await import("./syncPayload");
const {
writeMonitorPanels,
readMonitorPanels,
saveMonitorPanel,
deleteMonitorPanel,
} = await import("./state/monitorPanelsStorage");
const panel: MonitorPanel = {
id: "p",
name: "Fleet",
description: "",
hostIds: ["h1", "h2"],
view: "charts",
interval: 10,
range: 15,
sort: "manual",
};
const vault = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [],
};
const opts = { collectPluginSidecars: () => undefined };
const importers = { importVaultData: () => {} };
test("real builders and importers round-trip panel configuration through cloud and backups without runtime state", async () => {
store.clear();
writeMonitorPanels([
{
...panel,
stats: { cpu: 42 },
running: true,
points: [1],
error: "never-save",
},
]);
const cloud = await buildCloudSyncPayload(vault, [], opts);
const backup = buildLocalVaultPayload(vault);
assert.deepEqual(cloud.monitorPanels, [panel]);
assert.deepEqual(backup.monitorPanels, [panel]);
assert.ok(!JSON.stringify(cloud).includes("never-save"));
writeMonitorPanels([]);
await applySyncPayload(cloud, importers);
assert.deepEqual(readMonitorPanels(), [panel]);
delete backup.aiSessions;
writeMonitorPanels([]);
await applyLocalVaultPayload(backup, importers);
assert.deepEqual(readMonitorPanels(), [panel]);
const renamed = {
...panel,
name: "Changed",
hostIds: ["h2"],
view: "list" as const,
};
await applySyncPayload({ ...cloud, monitorPanels: [renamed] }, importers);
assert.deepEqual(readMonitorPanels(), [renamed]);
const legacy = { ...cloud };
delete legacy.monitorPanels;
await applySyncPayload(legacy, importers);
assert.deepEqual(readMonitorPanels(), [renamed]);
await applySyncPayload({ ...cloud, monitorPanels: [] }, importers);
assert.deepEqual(readMonitorPanels(), []);
});
test("serialized window mutations rebase stale fields and do not resurrect a deletion", async () => {
store.clear();
writeMonitorPanels([panel]);
await Promise.all([
saveMonitorPanel(panel, { ...panel, name: "Rename" }),
saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" }),
]);
assert.deepEqual(readMonitorPanels(), [
{ ...panel, name: "Rename", hostIds: ["h3"], view: "list" },
]);
await deleteMonitorPanel("p");
await assert.rejects(
saveMonitorPanel(panel, { ...panel, name: "Stale" }),
/deleted/,
);
assert.deepEqual(readMonitorPanels(), []);
});
test("cloud apply preserves panel edits committed after preparation", async () => {
store.clear();
writeMonitorPanels([panel]);
const { prepareSyncPayloadApply } = await import("./syncPayload");
const prepared = await prepareSyncPayloadApply(
{
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 1,
monitorPanels: [{ ...panel, name: "From cloud" }],
},
importers,
);
await saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" });
await prepared();
assert.deepEqual(readMonitorPanels(), [
{ ...panel, name: "From cloud", hostIds: ["h3"], view: "list" },
]);
});
test("cloud and backup imports preserve both independently created same-name panels", async () => {
store.clear();
const original = { ...panel, id: "a", name: "Fleet" };
const other = { ...panel, id: "b", name: "FLEET" };
writeMonitorPanels([original]);
await applySyncPayload(
{
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 1,
monitorPanels: [other, original],
},
importers,
);
assert.deepEqual(
readMonitorPanels().map((p) => [p.id, p.name]),
[
["a", "Fleet"],
["b", "FLEET (2)"],
],
);
const backup = buildLocalVaultPayload(vault);
delete backup.aiSessions;
writeMonitorPanels([]);
await applyLocalVaultPayload(backup, importers);
assert.equal(readMonitorPanels().length, 2);
assert.equal(
new Set(readMonitorPanels().map((p) => p.name.toLowerCase())).size,
2,
);
});
-98
View File
@@ -1,98 +0,0 @@
import { mergeSyncPayloads } from "../../domain/syncMerge";
import { useSyncExternalStore } from "react";
import {
normalizeMonitorPanels,
rebaseMonitorPanel,
type MonitorPanel,
} from "../../domain/monitorPanels";
import { STORAGE_KEY_MONITOR_PANELS } from "../../infrastructure/config/storageKeys";
import {
localStorageAdapter,
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
} from "../../infrastructure/persistence/localStorageAdapter";
import { withVaultImportLock } from "./vaultManagedImportLock";
export function readMonitorPanels(): MonitorPanel[] {
return normalizeMonitorPanels(
localStorageAdapter.read(STORAGE_KEY_MONITOR_PANELS),
);
}
export function writeMonitorPanels(value: unknown): void {
if (
!localStorageAdapter.write(
STORAGE_KEY_MONITOR_PANELS,
normalizeMonitorPanels(value),
)
)
throw new Error("monitor.saveFailed");
}
export async function saveMonitorPanel(
base: MonitorPanel | null,
next: MonitorPanel,
): Promise<void> {
return withVaultImportLock("vault", async () =>
writeMonitorPanels(rebaseMonitorPanel(readMonitorPanels(), base, next)),
);
}
export async function deleteMonitorPanel(id: string): Promise<void> {
return withVaultImportLock("vault", async () =>
writeMonitorPanels(readMonitorPanels().filter((p) => p.id !== id)),
);
}
/** Rebase a prepared cloud snapshot against edits committed while it was applying. */
export async function applyMonitorPanels(
value: unknown,
base: MonitorPanel[],
replace = false,
): Promise<void> {
return withVaultImportLock("vault", async () => {
const remote = normalizeMonitorPanels(value);
if (replace) {
writeMonitorPanels(remote);
return;
}
const payload = (panels: MonitorPanel[]) => ({
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 0,
monitorPanels: panels,
});
writeMonitorPanels(
mergeSyncPayloads(
payload(base),
payload(readMonitorPanels()),
payload(remote),
).payload.monitorPanels ?? [],
);
});
}
let cachedRaw: string | null | undefined;
let cached: MonitorPanel[] = [];
function getSnapshot() {
const raw = localStorageAdapter.readString(STORAGE_KEY_MONITOR_PANELS);
if (raw !== cachedRaw) {
cachedRaw = raw;
cached = readMonitorPanels();
}
return cached;
}
function subscribe(listener: () => void) {
const handler = (event: Event) => {
const key =
event instanceof StorageEvent
? event.key
: (event as CustomEvent<{ key: string }>).detail?.key;
if (!key || key === STORAGE_KEY_MONITOR_PANELS) listener();
};
window.addEventListener("storage", handler);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
};
}
export function useMonitorPanels(): MonitorPanel[] {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
-56
View File
@@ -1,56 +0,0 @@
import { useSyncExternalStore } from "react";
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
const empty: MonitorSnapshot = {
historyMode: "replace",
running: [],
count: 0,
hostCount: 0,
epoch: 0,
runtimeId: "",
takenAt: 0,
activeSessions: [],
startedAt: {},
hosts: {},
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function publishMonitorSnapshot(next: MonitorSnapshot) {
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
for (const l of listeners) l();
}
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
return {
runtimeId: snapshot.runtimeId,
epoch: snapshot.epoch,
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
publishMonitorSnapshot({ ...empty, unavailable: true });
}
export function useMonitorSnapshot() {
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
() => snapshot,
() => empty,
);
}
export const monitorActions: {
start: (id: string) => void;
stop: (id: string) => void;
stopAll: () => void;
retry: (
id: string,
credentials?: { username: string; password: string },
) => void;
} = {
start: () => {},
stop: () => {},
stopAll: () => {},
retry: () => {},
};
@@ -7,55 +7,6 @@ import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
for (const direction of ["download", "upload"] as const) {
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
let streamStarts = 0;
const restore = installGlobals({
statSftp: async () => ({ type: "file", size: 3626, lastModified: 1758000001000 }),
statLocal: async () => null,
startStreamTransfer: async () => { streamStarts += 1; return {}; },
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = `failed-${direction}-connection-3698`;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(direction === "download" ? side : side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => { throw new Error("SFTP channel open timed out after 10000ms"); },
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const results = await ops!.startTransfer(
[{ name: "aops_dns_view_demo.sh", isDirectory: false }], "left", "right",
);
assert.equal(results[0]?.status, "failed");
});
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.status, "failed");
assert.equal(task?.error, "SFTP channel open timed out after 10000ms");
assert.ok(task?.endTime);
assert.equal(streamStarts, 0);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
type StartOptions = {
transferId: string;
sourcePath: string;
@@ -721,6 +721,7 @@ export const useSftpTransfers = ({
}
};
try {
const t0 = performance.now();
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
@@ -1136,6 +1137,7 @@ export const useSftpTransfers = ({
}
}
return "failed";
}
} finally {
// Walk registry cleanup is owned by transferRuntime.runWalk.
// Drop ephemeral pool holds opened when browse was soft-closed.
@@ -1,197 +0,0 @@
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { MonitorPanel } from "../../domain/monitorPanels";
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
installBrowserLockFixture();
const store = new Map<string, string>();
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
const events = new EventTarget();
for (const key of [
"addEventListener",
"removeEventListener",
"dispatchEvent",
] as const) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: events[key].bind(events),
});
}
const windowFixture = Object.assign(events, {
localStorage: storage,
CustomEvent,
setInterval,
clearInterval,
});
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: windowFixture,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const { useAutoSync } = await import("./useAutoSync");
const { getCloudSyncManager } =
await import("../../infrastructure/services/CloudSyncManager");
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
await import("./monitorPanelsStorage");
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
const manager = getCloudSyncManager();
const snapshot = {
...manager.getState(),
securityState: "UNLOCKED" as const,
syncState: "IDLE" as const,
autoSyncEnabled: true,
providers: {
...manager.getState().providers,
webdav: { provider: "webdav" as const, status: "connected" as const },
},
};
const uploads: SyncPayload[] = [];
const originals = {
getState: manager.getState,
syncAllProviders: manager.syncAllProviders,
loadSyncBase: manager.loadSyncBase,
inspectProviderRemote: manager.inspectProviderRemote,
setPendingLocalSync: manager.setPendingLocalSync,
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
};
manager.getState = () => snapshot;
manager.loadSyncBase = async () => null;
manager.inspectProviderRemote = async () => ({
remoteFile: null,
payload: null,
remoteChanged: false,
});
manager.syncAllProviders = async (payload) => {
uploads.push(structuredClone(payload));
return new Map<CloudProvider, SyncResult>([
["webdav", { provider: "webdav", success: true, action: "upload" }],
]);
};
manager.setPendingLocalSync = () => {};
manager.setAvailablePluginSyncProviders = () => {};
const panel: MonitorPanel = {
id: "p",
name: "Fleet",
description: "",
hostIds: ["h1", "h2"],
view: "charts",
interval: 10,
range: 15,
sort: "manual",
};
let api: ReturnType<typeof useAutoSync> | undefined;
let renderer: ReactTestRenderer | undefined;
const apply = async () => {};
const Probe = () => {
api = useAutoSync({
hosts: [],
keys: [],
snippets: [],
customGroups: ["fixture"],
startupReady: true,
onApplyPayload: apply,
onApplyConvergentPayload: apply,
});
return null;
};
const waitForUpload = async (count: number) => {
// Flush the adapter's batched event, then observe the actual debounced
// upload. Slow CI must not be mistaken for an incomplete collection.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
if (uploads.length === count) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3500));
});
} else {
const deadline = Date.now() + 10000;
while (uploads.length < count && Date.now() < deadline)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
}
assert.equal(
uploads.length,
count,
"a panel-only change must alter the auto-sync hash and upload",
);
};
try {
writeMonitorPanels([
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
]);
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
await act(async () => {
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
});
assert.deepEqual(uploads[0].monitorPanels, [panel]);
const renamed = { ...panel, name: "Renamed" };
await act(async () => {
await saveMonitorPanel(panel, renamed);
});
await waitForUpload(2);
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
const edited = {
...renamed,
hostIds: ["h2"],
view: "list" as const,
interval: 30 as const,
};
await act(async () => {
await saveMonitorPanel(renamed, edited);
});
await waitForUpload(3);
assert.deepEqual(uploads[2].monitorPanels, [edited]);
await act(async () => {
await deleteMonitorPanel(panel.id);
});
await waitForUpload(4);
assert.deepEqual(uploads[3].monitorPanels, []);
await act(async () => {
await saveMonitorPanel(null, panel);
});
await waitForUpload(5);
assert.deepEqual(uploads[4].monitorPanels, [panel]);
assert.ok(
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
);
await act(async () => {
await saveMonitorPanel(panel, panel);
});
await waitForUpload(5);
} finally {
await act(async () => {
renderer?.unmount();
});
Object.assign(manager, originals);
}
});
+1 -5
View File
@@ -1,5 +1,3 @@
import { readMonitorPanels } from './monitorPanelsStorage';
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
/**
* useAutoSync - Auto-sync Hook for Cloud Sync
*
@@ -153,7 +151,6 @@ const isRestoreInProgress = (): boolean => {
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
return JSON.stringify({
monitorPanels: payload.monitorPanels,
hosts: sanitizeHostsForSync(payload.hosts),
keys: payload.keys,
identities: payload.identities,
@@ -264,7 +261,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && !chatChanged) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
@@ -286,7 +283,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const getSyncSnapshot = useCallback(() => {
return {
monitorPanels: readMonitorPanels(),
hosts: sanitizeHostsForSync(config.hosts),
keys: config.keys,
identities: config.identities,
+3 -15
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
type LocalVaultBackupPreview,
clearLocalVaultBackups,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
@@ -21,15 +20,13 @@ export function useLocalVaultBackups() {
// a destructive action that might later be disabled.
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
const refreshSequence = useRef(0);
const refreshBackups = useCallback(async () => {
const sequence = ++refreshSequence.current;
setIsLoading(true);
try {
const next = await listLocalVaultBackups();
if (sequence === refreshSequence.current) setBackups(next);
setBackups(next);
} finally {
if (sequence === refreshSequence.current) setIsLoading(false);
setIsLoading(false);
}
}, []);
@@ -79,14 +76,6 @@ export function useLocalVaultBackups() {
return sanitized;
}, [refreshBackups]);
const clearBackups = useCallback(async () => {
try {
await clearLocalVaultBackups();
} finally {
await refreshBackups();
}
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
@@ -97,7 +86,6 @@ export function useLocalVaultBackups() {
maxBackups,
encryptionAvailable,
refreshBackups,
clearBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
@@ -1,61 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useAppVaultRuntime } from "./appRuntimeBridge";
import { useMonitorSnapshot } from "./monitorRuntimeStore";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import {
createKnownHostFromHostKeyInfo,
upsertKnownHost,
} from "../../domain/knownHosts";
import { toHostKeyInfo } from "../../domain/hostKey";
type Request = Parameters<
Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]
>[0];
export function useMonitorHostKeyVerification() {
const { knownHosts, updateKnownHosts } = useAppVaultRuntime();
const [request, setRequest] = useState<Request | null>(null);
const snapshot = useMonitorSnapshot();
const receivedAt = useRef(0);
useEffect(
() =>
netcattyBridge.get()?.onHostKeyVerification?.((r) => {
if (r.sessionId?.startsWith("monitor-")) {
receivedAt.current = Date.now();
setRequest(r);
}
}),
[],
);
const respond = (accept: boolean, save = false) => {
if (!request) return;
if (accept && save)
updateKnownHosts(
upsertKnownHost(
knownHosts,
createKnownHostFromHostKeyInfo(toHostKeyInfo(request)),
),
);
void netcattyBridge
.get()
?.respondHostKeyVerification?.(request.requestId, accept, save);
setRequest(null);
};
useEffect(() => {
if (
request &&
snapshot.takenAt >= receivedAt.current &&
(!snapshot.running.length ||
!snapshot.activeSessions.includes(request.sessionId ?? ""))
) {
void netcattyBridge
.get()
?.respondHostKeyVerification?.(request.requestId, false, false);
setRequest(null);
}
}, [
request,
snapshot.running.length,
snapshot.takenAt,
snapshot.activeSessions,
]);
return { request, respond };
}
-198
View File
@@ -1,198 +0,0 @@
import { hostRestrictsExtraSshChannels } from "../../domain/host";
import { useEffect, useRef, useState } from "react";
import { buildHostConnectionTestPlan } from "../../domain/hostConnectionTest";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import { getEffectiveKnownHosts } from "../../infrastructure/syncHelpers";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import {
useAppLockChrome,
useAppSettingsRuntime,
useAppVaultRuntime,
} from "./appRuntimeBridge";
import { useMonitorPanels } from "./monitorPanelsStorage";
import {
monitorActions,
getMonitorHistoryCursor,
publishMonitorSnapshot,
publishMonitorUnavailable,
} from "./monitorRuntimeStore";
import type { MonitorPanelDemand } from "../../domain/monitorRuntime";
/** Root lifetime, independent of Vault navigation. No runtime state is persisted. */
export function useMonitorRuntime() {
const panels = useMonitorPanels();
const {
hosts,
keys,
identities,
groupConfigs,
proxyProfiles,
knownHosts,
isInitialized,
} = useAppVaultRuntime();
const { terminalSettings } = useAppSettingsRuntime();
const { locked } = useAppLockChrome();
const [running, setRunning] = useState(() => new Map<string, string>());
const [retryVersion, setRetryVersion] = useState(0);
const epoch = useRef<number | null>(null);
const runtimeId = useRef<string | null>(null);
const credentials = useRef(
new Map<string, { username: string; password: string }>(),
);
useEffect(() => {
monitorActions.start = (id) =>
setRunning((prev) => new Map(prev).set(id, crypto.randomUUID()));
monitorActions.stop = (id) =>
setRunning((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
monitorActions.stopAll = () => {
credentials.current.clear();
setRunning(new Map());
void netcattyBridge.get()?.monitorStopAll?.();
};
monitorActions.retry = (id, override) => {
if (override) credentials.current.set(id, override);
void netcattyBridge
.get()
?.monitorRetry?.(id)
.then(() => setRetryVersion((v) => v + 1));
};
const offline = () => monitorActions.stopAll();
window.addEventListener("offline", offline);
return () => {
window.removeEventListener("offline", offline);
void netcattyBridge.get()?.monitorUpdate?.([]);
};
}, []);
useEffect(() => {
if (locked || !isInitialized) {
credentials.current.clear();
setRunning(new Map());
}
}, [locked, isInitialized]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.monitorUpdate) {
publishMonitorUnavailable();
return;
}
const demand: MonitorPanelDemand[] =
locked || !isInitialized
? []
: panels
.filter((p) => running.has(p.id))
.map((panel) => ({
id: panel.id,
runId: running.get(panel.id)!,
interval: panel.interval,
hosts: panel.hostIds.map((id) => {
const host = hosts.find((h) => h.id === id);
if (
!host ||
(host.protocol && host.protocol !== "ssh") ||
hostRestrictsExtraSshChannels(host)
)
return { id };
const effective = resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
});
const plan = buildHostConnectionTestPlan({
host,
hosts,
keys,
identities,
groupConfigs,
proxyProfiles,
knownHosts: getEffectiveKnownHosts(knownHosts) ?? [],
terminalSettings,
sessionId: `monitor-${id}`,
bootEpoch: 0,
authOverride: credentials.current.has(id)
? { ...credentials.current.get(id), authMethod: "password" }
: null,
});
if (!plan.ok) return { id, status: "error" as const };
if (plan.needsCredentialReentry)
return { id, status: "login" as const };
return {
id,
options: {
...plan.options,
singleChannelSsh: hostRestrictsExtraSshChannels(effective),
sshAuthReadyTimeoutMs: 120000,
agentForwarding: effective.agentForwarding,
},
};
}),
}));
const activeHostIds = new Set(
demand.flatMap((p) => p.hosts.map((h) => h.id)),
);
for (const id of credentials.current.keys())
if (!activeHostIds.has(id)) credentials.current.delete(id);
void bridge
.monitorUpdate(demand)
.then(publishMonitorSnapshot)
.catch(() => {
credentials.current.clear();
setRunning((prev) => (prev.size ? new Map() : prev));
publishMonitorUnavailable();
});
}, [
panels,
running,
hosts,
keys,
identities,
groupConfigs,
proxyProfiles,
knownHosts,
terminalSettings,
locked,
isInitialized,
retryVersion,
]);
useEffect(() => {
const bridge = netcattyBridge.get();
let disposed = false;
let pending = false;
const poll = async () => {
if (pending || !bridge?.monitorSnapshot) return;
pending = true;
try {
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
if (disposed) return;
if (
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
(runtimeId.current !== null &&
runtimeId.current !== snapshot.runtimeId)
) {
credentials.current.clear();
setRunning(new Map());
}
epoch.current = snapshot.epoch;
runtimeId.current = snapshot.runtimeId;
publishMonitorSnapshot(snapshot);
} catch {
credentials.current.clear();
setRunning((prev) => (prev.size ? new Map() : prev));
publishMonitorUnavailable();
} finally {
pending = false;
}
};
void poll();
const timer = setInterval(() => {
void poll();
}, 1000);
return () => {
disposed = true;
clearInterval(timer);
};
}, []);
}
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
parseScriptsViewMode,
SCRIPTS_VIEW_MODE_STORAGE_KEY,
} from "./useScriptsViewMode.ts";
test("scripts view mode parser only accepts 'stacked'", () => {
assert.equal(parseScriptsViewMode("stacked"), "stacked");
assert.equal(parseScriptsViewMode("list"), "list");
assert.equal(parseScriptsViewMode("grid"), "list");
assert.equal(parseScriptsViewMode(null), "list");
assert.equal(parseScriptsViewMode(""), "list");
});
test("scripts view mode hook persists the chosen mode", () => {
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
assert.match(source, /useStoredString/);
// The shared hook owns persistence and same-/cross-window subscriptions.
assert.match(
source,
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
);
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
});
+19
View File
@@ -0,0 +1,19 @@
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
import { useStoredString } from "./useStoredString";
/** View modes for the scripts side panel library:
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
* wrap-around chips). */
export type ScriptsViewMode = "list" | "stacked";
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
/** Pure resolver so callers and tests share one validation rule. */
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
value === "stacked" ? "stacked" : "list";
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
value === "list" || value === "stacked";
export const useScriptsViewMode = () =>
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
test("script view changes follow native storage events from another window", async (t) => {
const windowA = new EventTarget();
const windowB = new EventTarget();
let activeWindow = windowA;
const data = new Map<string, string>();
const values = {
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
let a!: ReturnType<typeof useScriptsViewMode>;
let b!: ReturnType<typeof useScriptsViewMode>;
function FirstWindow() { a = useScriptsViewMode(); return null; }
function SecondWindow() { b = useScriptsViewMode(); return null; }
let first!: ReactTestRenderer;
let second!: ReactTestRenderer;
t.after(async () => {
activeWindow = windowA; await act(async () => first?.unmount());
activeWindow = windowB; await act(async () => second?.unmount());
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
assert.equal(a[0], "list"); assert.equal(b[0], "list");
// BrowserWindow A writes shared storage. Electron emits the native event
// in B, not an adapter CustomEvent from A's independent JS environment.
activeWindow = windowA; await act(async () => a[1]("stacked"));
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
assert.equal(b[0], "list");
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
const event = new Event("storage");
Object.defineProperty(event, "key", { value: key });
await act(async () => { target.dispatchEvent(event); });
};
activeWindow = windowB; await notify(windowB);
assert.equal(b[0], "stacked");
await act(async () => b[1]("list"));
assert.equal(a[0], "stacked");
activeWindow = windowA; await notify(windowA);
assert.equal(a[0], "list");
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
await notify(windowA, "unrelated");
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
});
-6
View File
@@ -1,5 +1,3 @@
import { readMonitorPanels, writeMonitorPanels } from './monitorPanelsStorage';
import type { MonitorPanel } from '../../domain/monitorPanels';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
hostsEqualForIdentityReuse,
@@ -126,7 +124,6 @@ import {
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
type ExportableVaultData = {
monitorPanels?: MonitorPanel[];
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
@@ -1191,7 +1188,6 @@ export const useVaultState = () => {
}, []);
const clearVaultData = useCallback(() => {
void withVaultImportLock('vault', async () => writeMonitorPanels([]));
updateHosts([]);
updateKeys([]);
updateIdentities([]);
@@ -1976,7 +1972,6 @@ export const useVaultState = () => {
const exportData = useCallback(
(): ExportableVaultData => ({
monitorPanels: readMonitorPanels(),
hosts,
keys,
identities,
@@ -1995,7 +1990,6 @@ export const useVaultState = () => {
const importData = useCallback(
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.monitorPanels !== undefined) encryptedWrites.push(withVaultImportLock('vault', async () => writeMonitorPanels(payload.monitorPanels)));
if (payload.hosts) {
// Cloud payloads no longer carry `lastConnectedAt` (#2629), but any
// remote reconciliation replaces the whole local host array. Re-attach
@@ -1,4 +1,3 @@
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AISession } from '../infrastructure/ai/types';
@@ -9,8 +8,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
installBrowserLockFixture();
const storage = new Map<string, string>();
const events = new EventTarget();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
-3
View File
@@ -1,4 +1,3 @@
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
import test from "node:test";
import assert from "node:assert/strict";
@@ -39,8 +38,6 @@ function installLocalStorage(): LocalStorageMock {
return localStorage;
}
installBrowserLockFixture();
const localStorage = installLocalStorage();
const {
applyLocalVaultPayload,
-6
View File
@@ -1,5 +1,3 @@
import { readMonitorPanels, applyMonitorPanels } from './state/monitorPanelsStorage';
import { normalizeMonitorPanels } from '../domain/monitorPanels';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
@@ -1092,7 +1090,6 @@ function buildVaultSyncPayload(
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
monitorPanels: readMonitorPanels(),
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
settings: collectSyncableSettings(),
@@ -1181,7 +1178,6 @@ export async function buildCloudSyncPayload(
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
monitorPanels: readMonitorPanels(),
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
...(aiSessions !== undefined ? { aiSessions } : {}),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
@@ -1363,7 +1359,6 @@ async function preparePayloadApply(
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const monitorPanelsAtPrepare = readMonitorPanels();
// Prepare credentials before any vault or settings mutation. Encryption can
// fail when local credential storage is unavailable; reuse the result below.
const providers = sanitizedPayload.settings?.ai?.providers;
@@ -1405,7 +1400,6 @@ async function preparePayloadApply(
}
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
if (sanitizedPayload.monitorPanels !== undefined) await applyMonitorPanels(normalizeMonitorPanels(sanitizedPayload.monitorPanels), monitorPanelsAtPrepare, options.includeLocalOnlyData);
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
@@ -1,52 +0,0 @@
/** Web Locks fixture for tests with a simulated browser window. */
export function installBrowserLockFixture(): void {
const states = new Map<string, {
readers: number;
writer: boolean;
pending: Array<{ mode: LockMode; grant: () => void }>;
}>();
const locks = {
async request<T>(
name: string,
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
): Promise<T> {
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
const mode = options.mode ?? "exclusive";
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
states.set(name, state);
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
return task(null);
}
const drain = () => {
while (!state.writer && state.pending.length) {
const next = state.pending[0];
if (next.mode === "exclusive" && state.readers) return;
state.pending.shift();
if (next.mode === "exclusive") state.writer = true;
else state.readers += 1;
next.grant();
}
};
return new Promise<T>((resolve, reject) => {
state.pending.push({ mode, grant: () => {
void (async () => {
try {
return await task({ name, mode });
} finally {
if (mode === "exclusive") state.writer = false;
else state.readers -= 1;
drain();
}
})().then(resolve, reject);
} });
drain();
});
},
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { locks },
});
}
@@ -1,104 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
};
const previous = new Map(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 });
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
} });
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [];
const success = toast.success;
const error = toast.error;
toast.success = (message) => { notices.push(`success:${message}`); };
toast.error = (message) => { notices.push(`error:${message}`); };
let calls = 0;
let fail = true;
let hasBackup = true;
let finish: (() => void) | undefined;
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
// Cleanup must remain available even when encrypted backups cannot be read.
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
listVaultBackups: async () => hasBackup ? [{
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
}] : [],
clearVaultBackups: async () => {
calls += 1;
if (fail) throw new Error('access denied');
await new Promise<void>((resolve) => { finish = resolve; });
hasBackup = false;
return { deletedCount: 1 };
},
} });
const click = async (node: Element) => {
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
};
const button = (text: string, scope: ParentNode = dom.document) => {
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
assert.ok(node, `button ${text}`);
return node as HTMLButtonElement;
};
try {
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await click(button('Clear all backups'));
let dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
await click(button('Cancel', dialog));
assert.equal(calls, 0);
assert.equal(hasBackup, true);
await click(button('Clear all backups'));
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
await click(button('Clear all backups', dialog));
assert.equal(calls, 1);
assert.match(notices[0], /^error:/);
assert.equal(hasBackup, true);
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
fail = false;
await click(button('Clear all backups', dialog));
assert.equal(calls, 2);
assert.equal(button('Clear all backups', dialog).disabled, true);
assert.equal(button('Cancel', dialog).disabled, true);
assert.equal(notices.length, 1);
await runWithAct(async () => { finish?.(); });
await flushEffects();
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
assert.match(notices[1], /^success:/);
} finally {
toast.success = success;
toast.error = error;
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
-36
View File
@@ -1,36 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
test("FidoPromptModal ships PIN and touch UI wiring", () => {
const source = readFileSync(join(import.meta.dirname, "FidoPromptModal.tsx"), "utf8");
assert.match(source, /kind === "touch"/);
assert.match(source, /fido-pin-input/);
assert.match(source, /fido\.prompt\.pinTitle/);
assert.match(source, /fido\.prompt\.touchTitle/);
assert.match(source, /onSubmit\(request\.requestId/);
assert.match(source, /onCancel\(request\.requestId/);
});
test("AppView mounts FidoPromptModal", () => {
const source = readFileSync(
join(import.meta.dirname, "../application/app/AppView.tsx"),
"utf8",
);
assert.match(source, /FidoPromptModal/);
assert.match(source, /fidoPromptQueue/);
assert.match(source, /handleFidoPromptSubmit/);
});
test("GenerateStandardPanel exposes FIDO options", () => {
const source = readFileSync(
join(import.meta.dirname, "keychain/GenerateStandardPanel.tsx"),
"utf8",
);
assert.match(source, /ED25519-SK/);
assert.match(source, /ECDSA-SK/);
assert.match(source, /resident/);
assert.match(source, /verifyRequired/);
assert.match(source, /fidoHint/);
});
-155
View File
@@ -1,155 +0,0 @@
/** PIN entry and user-presence prompts from OpenSSH. */
import { Clock3, Eye, EyeOff, Hand, KeyRound, Loader2, ShieldAlert, Usb } from "lucide-react";
import React, { useCallback, useEffect, useId, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export type FidoPromptKind = "pin" | "touch" | "confirm" | "recovery";
export interface FidoPromptRequest {
requestId: string;
kind: FidoPromptKind;
message?: string;
title?: string;
keyName?: string;
notificationOnly?: boolean;
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
allowPasswordFallback?: boolean;
}
interface FidoPromptModalProps {
request: FidoPromptRequest | null;
onSubmit: (requestId: string, response: string) => void;
onCancel: (requestId: string) => void;
}
export const FidoPromptModal: React.FC<FidoPromptModalProps> = ({ request, onSubmit, onCancel }) => {
const { t } = useI18n();
// Retain the content during Radix's closing animation, but clear the PIN.
const [lastRequest, setLastRequest] = useState(request);
const [pin, setPin] = useState("");
const [showPin, setShowPin] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const descriptionId = useId();
useEffect(() => { if (request) setLastRequest(request); }, [request]);
useEffect(() => {
setPin("");
setShowPin(false);
setIsSubmitting(false);
}, [request?.requestId]);
const visibleRequest = request || lastRequest;
const isTouch = visibleRequest?.kind === "touch" || visibleRequest?.kind === "confirm";
const isRecovery = visibleRequest?.kind === "recovery";
const recoveryReason = visibleRequest?.reason || "failed";
const RecoveryIcon = recoveryReason === "device-unavailable" ? Usb : recoveryReason === "timeout" ? Clock3 : ShieldAlert;
const handleSubmit = useCallback(() => {
if (!request || request.notificationOnly || isSubmitting || (!isTouch && !isRecovery && !pin)) return;
setIsSubmitting(true);
onSubmit(request.requestId, isRecovery ? "retry" : isTouch ? "" : pin);
}, [request, isSubmitting, isTouch, isRecovery, pin, onSubmit]);
const handleCancel = useCallback(() => {
if (request) onCancel(request.requestId);
}, [request, onCancel]);
const message = visibleRequest?.message?.trim();
const hasPinAndTouch = !isTouch && /user presence|touch|tap/i.test(message || "");
const isErrorPrompt = /incorrect|invalid|wrong|failed|retries|attempts|错误|錯誤|剩余|剩餘/i.test(message || "");
return (
<Dialog open={!!request} onOpenChange={open => !open && handleCancel()}>
{visibleRequest && (
<DialogContent
hideCloseButton
aria-describedby={descriptionId}
overlayClassName="fido-prompt-overlay bg-black/20"
className="fido-prompt-dialog z-[51] w-[calc(100%_-_2rem)] max-w-[400px] gap-0 overflow-hidden rounded-xl border-border/70 p-6 sm:rounded-xl"
style={{ boxShadow: '0 20px 60px -20px rgb(0 0 0 / 0.28)' }}
>
<div key={visibleRequest.requestId} className="fido-prompt-stage">
{isRecovery ? (
<div className="pb-2 pt-2 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<RecoveryIcon size={29} strokeWidth={1.5} />
</div>
<DialogTitle className="text-base">{t(`fido.recovery.${recoveryReason}.title`)}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t(`fido.recovery.${recoveryReason}.desc`)}
</DialogDescription>
</div>
) : isTouch ? (
<div className="pb-5 pt-2 text-center">
<div className="relative mx-auto mb-6 flex h-24 w-24 items-center justify-center" aria-hidden="true">
<span className="fido-touch-ring absolute inset-0 rounded-full bg-primary/5" />
<span className="fido-touch-ring fido-touch-ring-delayed absolute inset-0 rounded-full bg-primary/5" />
<div className="fido-touch-symbol relative flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary">
<Hand size={30} strokeWidth={1.5} />
</div>
</div>
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.touchTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t("fido.prompt.touchDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
<div role="status" className="mt-5 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="fido-status-dot h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
{t(visibleRequest.notificationOnly ? "fido.prompt.touchAutomatic" : "fido.prompt.touchWaiting")}
</div>
</div>
) : (
<>
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<KeyRound size={21} strokeWidth={1.7} />
</div>
<div className="min-w-0">
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.pinTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-1 truncate text-xs font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-1 text-xs leading-relaxed">
{t(hasPinAndTouch ? "fido.prompt.pinTouchDesc" : "fido.prompt.pinDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="fido-pin-input" className="text-xs">{t("fido.prompt.pinLabel")}</Label>
<div className="relative">
<Input id="fido-pin-input" type={showPin ? "text" : "password"}
value={pin} onChange={e => setPin(e.target.value)} disabled={isSubmitting}
onKeyDown={e => {
if (e.key === "Enter") { e.preventDefault(); handleSubmit(); }
}} autoFocus className="h-11 pr-11" autoComplete="off" spellCheck={false} />
<Button type="button" variant="ghost" size="icon" disabled={isSubmitting}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
aria-label={t(showPin ? "fido.prompt.hidePin" : "fido.prompt.showPin")}
aria-pressed={showPin} onClick={() => setShowPin(value => !value)}>
{showPin ? <EyeOff size={15} /> : <Eye size={15} />}
</Button>
</div>
</div>
</>
)}
{message && (isErrorPrompt ? (
<p role="alert" className="mt-3 break-words text-xs leading-relaxed text-destructive">{message}</p>
) : (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="w-fit cursor-pointer rounded-sm text-[11px] outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">{t("fido.prompt.details")}</summary>
<p className="mt-2 break-words leading-relaxed">{message}</p>
</details>
))}
</div>
<div className="-mx-6 -mb-6 mt-5 flex items-center justify-end gap-2 border-t border-border/50 bg-muted/15 px-6 py-3.5">
<Button type="button" variant="ghost" className="h-9" onClick={handleCancel} disabled={isSubmitting}>{t(visibleRequest.notificationOnly ? "common.close" : "common.cancel")}</Button>
{isRecovery && visibleRequest.allowPasswordFallback && <Button type="button" variant="outline" className="h-9" onClick={() => request && onSubmit(request.requestId, "fallback")}>{t("fido.recovery.usePassword")}</Button>}
{!visibleRequest.notificationOnly && <Button type="button" className="h-9 min-w-20 gap-2" onClick={handleSubmit} disabled={isSubmitting || (!isTouch && !isRecovery && !pin)}>
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t(isSubmitting ? "fido.prompt.verifying" : isRecovery ? "fido.recovery.retry" : "common.continue")}
</Button>}
</div>
</DialogContent>
)}
</Dialog>
);
};
export default FidoPromptModal;
+22 -37
View File
@@ -4,7 +4,6 @@ import {
EyeOff,
FileText,
FolderPlus,
PlugZap,
Plus,
Settings2,
Tag,
@@ -62,7 +61,6 @@ 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,
@@ -1017,39 +1015,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
actions={
<>
{!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>
</>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!canSaveHost}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
</Button>
}
>
<AsidePanelContent>
@@ -1371,9 +1346,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<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>
)}
<Button
className="w-full h-10"
className="flex-1 h-10"
onClick={handleSubmit}
disabled={!canSaveHost}
>
+4 -12
View File
@@ -379,12 +379,6 @@ echo $3 >> "$FILE"`);
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
resident: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
? !!(draftKey as { resident?: boolean }).resident
: undefined,
verifyRequired: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
? !!(draftKey as { verifyRequired?: boolean }).verifyRequired
: undefined,
});
if (!result) {
throw new Error(
@@ -395,17 +389,15 @@ echo $3 >> "$FILE"`);
throw new Error(result.error || t("keychain.error.generateKeyPairFailed"));
}
const resolvedType = (result.keyType as KeyType | undefined) || keyType;
const isFidoSk = resolvedType === "ED25519-SK" || resolvedType === "ECDSA-SK";
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: resolvedType,
keySize: resolvedType === "ED25519" || isFidoSk ? undefined : keySize,
type: keyType,
keySize: keyType !== "ED25519" ? keySize : undefined,
privateKey: result.privateKey,
publicKey: result.publicKey,
passphrase: isFidoSk ? undefined : draftKey.passphrase,
savePassphrase: isFidoSk ? undefined : draftKey.savePassphrase,
passphrase: draftKey.passphrase,
savePassphrase: draftKey.savePassphrase,
source: "generated",
category: "key",
created: Date.now(),
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
import type { Snippet } from "../types";
test("stacked library continues loading after running a script and returning", async (t) => {
const env = installDomEnvironment();
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
class Observer {
target: Element | null = null;
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
observe(target: Element) { this.target = target; }
disconnect() { this.target = null; }
intersect = () => {
if (this.target?.isConnected) {
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
};
}
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
env.cleanup();
});
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
}));
const executed: string[] = [];
await renderer.render(
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
);
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
const chipCount = () => library()?.querySelectorAll("button").length;
const button = (label: string) => {
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
assert.ok(result, `missing button ${label}`);
return result;
};
assert.equal(chipCount(), 120);
const firstSentinel = observers.at(-1)?.target;
assert.ok(firstSentinel?.isConnected);
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(executed, ["script-0"]);
assert.equal(library(), null, "execution opens Running");
assert.equal(firstSentinel.isConnected, false);
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 120);
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
assert.notEqual(observers.at(-1)?.target, firstSentinel);
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 240);
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 240, "returning preserves the loaded window");
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 300);
assert.ok(button("Script 299"));
});
+28
View File
@@ -174,3 +174,31 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
assert.match(source, /isNonPrimaryPointer/);
assert.match(source, /primaryOnlyDragHandlers/);
});
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
assert.match(source, /useScriptsViewMode/);
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
assert.ok(toggle, "view toggle button should swap list / stacked icons");
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
// The chosen mode persists via the shared hook so it survives app restarts.
assert.match(source, /useScriptsViewMode\(\)/);
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
});
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
assert.ok(stacked, "stacked branch should exist in the content area");
assert.match(stacked[0], /flex flex-wrap/);
// Chips reuse the same click handler (multi-select / run / execute) as rows.
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
// Packages stay visible as section headers with their counts.
assert.match(stacked[0], /item\.row\.name/);
assert.match(stacked[0], /item\.countLabel/);
assert.match(source, /const SnippetChip = memo/);
// The stacked chip keeps the same context menu contract (edit / delete / run).
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
assert.ok(chip, "SnippetChip component should exist");
assert.match(chip[0], /ContextMenuContent/);
assert.match(chip[0], /onRunParallel/);
assert.match(chip[0], /onEdit/);
});
+305 -1
View File
@@ -15,6 +15,8 @@ import {
Expand,
FolderPlus,
Layers,
LayoutGrid,
List,
Minimize2,
Package,
Play,
@@ -27,6 +29,7 @@ import {
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
collectSnippetPackageTreePaths,
@@ -61,6 +64,12 @@ const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
// so cap the initial render and grow lazily as the user scrolls to keep a huge
// library (or a broad search) from mounting every chip at once.
const STACKED_INITIAL_RENDER_COUNT = 120;
const STACKED_RENDER_BATCH = 120;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
return body.length > 0 && !body.includes('/');
@@ -170,10 +179,14 @@ export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
snippetsBeforeChildren = false,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
/** Emit each package's own snippets before its sub-packages so flat views
* (stacked chips) keep every package's chips attached to its section. */
snippetsBeforeChildren?: boolean;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
@@ -250,6 +263,13 @@ export function buildScriptsSidePanelRows({
});
if (!isExpanded) return;
if (snippetsBeforeChildren) {
// Flat stacked view: a package's chips must follow its own section
// header, not trail after its nested children's chips.
emitSnippets(localSnippets, pkg, depth);
children.forEach((child) => walk(child, depth + 1));
return;
}
children.forEach((child) => walk(child, depth + 1));
emitSnippets(localSnippets, pkg, depth);
};
@@ -283,6 +303,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [viewMode, setViewMode] = useScriptsViewMode();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
@@ -293,6 +314,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [renamingPackagePath, setRenamingPackagePath] = useState('');
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
const packageDialogRef = useRef<HTMLDivElement>(null);
const packageNameInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -487,8 +509,11 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
expandedPaths,
// Stacked chips wrap flat, so each package's chips must stay attached
// to its own section header.
snippetsBeforeChildren: viewMode === 'stacked',
});
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
@@ -521,6 +546,48 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [rows, searchMatches, t, isVisible]);
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
// Restart the lazy window whenever the underlying result set changes
// (new search, package list changes, snippet edits, view-mode switch) — but
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
// same result set; resetting there would drop the user's lazy-loaded window
// and remount the package they just toggled, forcing them to re-scroll.
// Keyed by data identity, not by listItems reference, for that reason.
// Reset during render — not in an effect — so the first render of a new
// result set never slices with the previous, potentially very large window.
const stackedResultSetKey = useMemo(() => {
if (searchMatches !== null) {
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
}
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
.map((snippet) => snippet.id)
.join('\u0000')}`;
}, [searchMatches, viewMode, packages, snippets]);
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
if (prevResultSetKey !== stackedResultSetKey) {
setPrevResultSetKey(stackedResultSetKey);
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
}
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
useEffect(() => {
if (subView !== 'library' || !hasMoreStackedItems) return;
const sentinel = stackedSentinelRef.current;
if (!sentinel) return;
// Re-created per batch: observing always emits an initial intersection
// callback, so short result sets that never scroll keep loading.
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
@@ -979,6 +1046,30 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
)}
disabled={!hasAnyContent}
aria-label={viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
aria-pressed={viewMode === 'stacked'}
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
>
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1126,6 +1217,107 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : viewMode === 'stacked' ? (
<div
className="h-full overflow-auto px-2 py-1.5"
data-scripts-view="stacked"
>
<div className="flex flex-wrap items-center content-start gap-1.5">
{listItems.slice(0, stackedRenderLimit).map((item) => {
if (item.kind === 'package') {
// Mirror the list view's PackageRow context menu so stacked
// mode keeps rename/delete reachable without switching views.
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
const header = (
<button
key={item.key}
type="button"
onClick={() => togglePackage(item.row.path)}
aria-expanded={item.row.isExpanded}
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
>
<ChevronRight
size={11}
className={cn(
'shrink-0 text-muted-foreground transition-transform',
item.row.isExpanded && 'rotate-90',
!item.row.hasChildren && 'opacity-0',
)}
/>
<Package size={11} className="shrink-0 text-primary/80" />
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
{item.row.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{item.countLabel}
</span>
</button>
);
if (!canMutatePackages) {
return header;
}
return (
<ContextMenu key={item.key}>
<ContextMenuTrigger asChild>
{header}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => openRenamePackageDialog(item.row.path)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => requestDeletePackage(item.row.path)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
const isScript = isScriptSnippet(snippet);
// Search results drop the package headers, so surface each
// snippet's package on the chip itself to keep results
// distinguishable (labels are not unique across packages).
const chipSubtitle = item.kind === 'search'
? (snippet.package || t('terminal.toolbar.library'))
: undefined;
return (
<SnippetChip
key={item.key}
snippet={snippet}
subtitle={chipSubtitle}
selected={selectedSnippetIds.has(snippet.id)}
multiSelect={isMultiSelectMode}
onClick={() => handleSnippetClick(snippet)}
onEdit={() => handleEditSnippet(snippet)}
onDelete={() => handleDeleteSnippet(snippet.id)}
onCopyCommand={() => handleCopySnippetCommand(snippet)}
copyCommandLabel={t('scripts.actions.copyCommand')}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'parallel')
: undefined}
onRunSequential={isScript && onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'sequential')
: undefined}
runParallelLabel={isScript
? t('scripts.actions.runParallel')
: t('scripts.actions.runOnAllTabs')}
runSequentialLabel={t('scripts.actions.runSequential')}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
})}
</div>
{hasMoreStackedItems ? (
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
) : null}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
@@ -1650,5 +1842,117 @@ const SnippetRow = memo<SnippetRowProps>(({
));
SnippetRow.displayName = 'SnippetRow';
interface SnippetChipProps {
snippet: Snippet;
/** Optional package context shown on the chip (used by stacked search
* results, where the package headers are dropped). */
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
onRunParallel?: () => void;
onRunSequential?: () => void;
onCopyCommand?: () => void;
copyCommandLabel?: string;
runParallelLabel?: string;
runSequentialLabel?: string;
editLabel: string;
deleteLabel: string;
}
/**
* Compact chip for the stacked (wrap) view. Shares the same click → run /
* execute and right-click context menu as the list rows; only the layout is
* denser. Drag reordering stays list-view-only.
*/
const SnippetChip = memo<SnippetChipProps>(({
snippet,
subtitle,
selected = false,
multiSelect = false,
onClick,
onEdit,
onDelete,
onRunParallel,
onRunSequential,
onCopyCommand,
copyCommandLabel,
runParallelLabel,
runSequentialLabel,
editLabel,
deleteLabel,
}) => (
<ContextMenu>
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
<ContextMenuTrigger asChild>
<div className="min-w-0 max-w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
)}
>
{multiSelect ? (
<CheckSquare
size={11}
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
/>
) : isScriptSnippet(snippet) ? (
<Play size={11} className="shrink-0 text-primary" />
) : (
<Zap size={11} className="shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate">{snippet.label}</span>
{subtitle ? (
<>
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
{subtitle}
</span>
</>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" align="start">
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onRunParallel ? (
<ContextMenuItem onClick={onRunParallel}>
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
</ContextMenuItem>
) : null}
{onRunSequential ? (
<ContextMenuItem onClick={onRunSequential}>
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
{onCopyCommand ? (
<ContextMenuItem onClick={onCopyCommand}>
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
));
SnippetChip.displayName = 'SnippetChip';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
+1 -6
View File
@@ -14,7 +14,6 @@ interface SelectHostPanelProps {
onSelect: (host: Host) => void;
onSelectionChange?: (selectedHostIds: string[]) => void;
onBack: () => void;
onClose?: () => void;
onContinue?: () => void;
onNewHost?: () => void;
availableKeys?: SSHKey[];
@@ -26,7 +25,6 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
headerClassName?: string;
width?: string;
layout?: AsidePanelLayout;
resizable?: boolean;
@@ -44,7 +42,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onSelect,
onSelectionChange,
onBack,
onClose,
onContinue,
onNewHost,
availableKeys = [],
@@ -56,7 +53,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
headerClassName,
width,
layout = "overlay",
resizable = false,
@@ -82,9 +78,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<AsidePanel
open={true}
onClose={onClose ?? onBack}
onClose={onBack}
title={panelTitle}
headerClassName={headerClassName}
subtitle={subtitle}
showBackButton={true}
onBack={onBack}
+4 -2
View File
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
data-active={isActive ? 'true' : undefined}
className={cn(
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
@@ -487,7 +487,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
aria-label={t('selectHost.toggleHost', { name: host.label })}
className={cn(
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
// Keep keyboard cursor visible even when the host is already selected.
isActive && 'ring-1 ring-primary/40',
)}
onClick={() => {
if (navIndex >= 0) setActiveNavIndex(navIndex);
-2
View File
@@ -170,7 +170,6 @@ const SnippetsManager = lazy(() => import("./SnippetsManager"));
export type VaultSection =
| "hosts"
| "monitor"
| "keys"
| "proxies"
| "snippets"
@@ -1616,7 +1615,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
NotebookText,
NotesManager,
onCommitPluginImporterData,
onConnect,
onConnectSerial,
onCreateLocalTerminal,
onDeleteHost,
@@ -577,7 +577,6 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
browserBookmarks: 'cloudSync.conflict.entity.browserBookmarks',
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
monitorPanels: 'monitor.title',
aiSessions: 'cloudSync.aiSessions.title',
settings: 'cloudSync.conflict.entity.settings',
};
@@ -14,11 +14,9 @@ import {
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
import {
LocalVaultBackupBusyError,
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
@@ -31,7 +29,6 @@ import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { ConfirmDialog } from '../ui/confirm-dialog';
import { toast } from '../ui/toast';
// ============================================================================
@@ -57,13 +54,10 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
maxBackups,
encryptionAvailable,
refreshBackups,
clearBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [showClearDialog, setShowClearDialog] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
@@ -181,34 +175,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
}
};
const performClear = async () => {
if (isClearing) return;
setIsClearing(true);
try {
await clearBackups();
setShowClearDialog(false);
toast.success(t('cloudSync.localBackups.clearSuccess'));
} catch (error) {
toast.error(t(error instanceof LocalVaultBackupBusyError
? 'cloudSync.localBackups.clearBusy'
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
} finally {
setIsClearing(false);
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
return (
<div className="space-y-4">
{encryptionAvailable === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
{t('cloudSync.localBackups.unavailableTitle')}
</div>
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
</div>
)}
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
@@ -230,7 +224,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
@@ -241,7 +235,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</div>
</div>
{restoreDisabledReason !== null && (
{!restoreAllowed && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
@@ -254,19 +248,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading || isClearing}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
@@ -281,16 +275,6 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 size={14} />
{t('cloudSync.localBackups.clear')}
</Button>
</div>
</div>
@@ -355,7 +339,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null || isClearing}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
@@ -372,17 +356,6 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
)}
</div>
<ConfirmDialog
open={showClearDialog}
title={t('cloudSync.localBackups.clearConfirmTitle')}
message={t('cloudSync.localBackups.clearConfirmDesc')}
confirmLabel={t('cloudSync.localBackups.clear')}
busy={isClearing}
destructive
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
onConfirm={() => void performClear()}
/>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
@@ -443,7 +416,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null || isClearing}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
@@ -455,7 +428,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null || isClearing}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
+7 -3
View File
@@ -34,9 +34,13 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
assert.match(source, /navigableIndexByKey/);
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
assert.doesNotMatch(source, /navigable\.findIndex/);
// The keyboard cursor stays visible on selected hosts through background alone.
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
// Selected hosts must still show the keyboard active ring.
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
assert.doesNotMatch(
source,
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
);
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
// One listbox tab stop — not per-host tabIndex under virtualization.
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
+105 -99
View File
@@ -1,17 +1,19 @@
/** Standard and hardware-backed SSH key generation. */
import { Eye, EyeOff, Loader2, Usb } from 'lucide-react';
/**
* Generate Key Panel - Standard SSH Key generation form
*/
import { Eye, EyeOff } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { KeyType, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
import { Switch } from '../ui/switch';
interface GenerateStandardPanelProps {
draftKey: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean };
setDraftKey: (key: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean }) => void;
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
isGenerating: boolean;
@@ -19,115 +21,119 @@ interface GenerateStandardPanelProps {
}
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
draftKey, setDraftKey, showPassphrase, setShowPassphrase, isGenerating, onGenerate,
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
isGenerating,
onGenerate,
}) => {
const { t } = useI18n();
const isFido = draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK';
const selectType = (value: string) => {
const type = value as KeyType;
setDraftKey({ ...draftKey, type, keySize: type === 'RSA' ? 4096 : type === 'ECDSA' ? 256 : undefined });
};
return (
<div className="space-y-5" data-section="keychain-generate-form">
<>
<div className="space-y-2">
<Label htmlFor="generate-key-label">{t('keychain.field.label')}</Label>
<Input id="generate-key-label" value={draftKey.label || ''}
<Label>{t('keychain.field.label')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.generate.labelPlaceholder')} disabled={isGenerating} />
placeholder={t('keychain.generate.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="generate-key-type">{t('keychain.generate.keyType')}</Label>
<Select value={draftKey.type || 'ED25519'} onValueChange={selectType} disabled={isGenerating}>
<SelectTrigger id="generate-key-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('keychain.generate.standardKeys')}</SelectLabel>
{['ED25519', 'ECDSA', 'RSA'].map(type => <SelectItem key={type} value={type}>{type}</SelectItem>)}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>{t('keychain.generate.securityKeys')}</SelectLabel>
<SelectItem value="ED25519-SK">ED25519-SK</SelectItem>
<SelectItem value="ECDSA-SK">ECDSA-SK</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Label>{t('keychain.generate.keyType')}</Label>
<div className="flex gap-2">
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
<Button
key={t}
variant={draftKey.type === t ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.type === t && "bg-primary/15 text-primary"
)}
onClick={() => {
// Set default keySize based on type
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
}}
>
{t}
</Button>
))}
</div>
</div>
{/* Key Size selector - only for RSA and ECDSA */}
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
<div className="space-y-2 fido-field-stage" key="key-size">
<Label htmlFor="generate-key-size">{t('keychain.generate.keySize')}</Label>
<Select value={String(draftKey.keySize || (draftKey.type === 'RSA' ? 4096 : 256))}
onValueChange={value => setDraftKey({ ...draftKey, keySize: Number(value) })} disabled={isGenerating}>
<SelectTrigger id="generate-key-size"><SelectValue /></SelectTrigger>
<SelectContent>
{(draftKey.type === 'RSA' ? [4096, 2048, 1024] : [256, 384, 521]).map(size => (
<SelectItem key={size} value={String(size)}>{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isFido ? (
<div key="hardware" className="fido-field-stage fido-key-options space-y-4">
<div className="flex gap-2.5 rounded-lg bg-muted/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
<Usb size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
<p>{t('keychain.generate.fidoHint')}</p>
</div>
<div className="divide-y divide-border/50 border-y border-border/50">
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-resident-label" className="text-sm font-medium">{t('keychain.generate.resident')}</Label>
<p id="fido-resident-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.residentDesc')}</p>
</div>
<Switch id="fido-resident" checked={!!draftKey.resident} disabled={isGenerating}
aria-labelledby="fido-resident-label" aria-describedby="fido-resident-description"
onCheckedChange={resident => setDraftKey({ ...draftKey, resident })} />
</div>
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-verify-label" className="text-sm font-medium">{t('keychain.generate.verifyRequired')}</Label>
<p id="fido-verify-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.verifyRequiredDesc')}</p>
</div>
<Switch id="fido-verify-required" checked={!!draftKey.verifyRequired} disabled={isGenerating}
aria-labelledby="fido-verify-label" aria-describedby="fido-verify-description"
onCheckedChange={verifyRequired => setDraftKey({ ...draftKey, verifyRequired })} />
</div>
</div>
</div>
) : (
<div key="software" className="fido-field-stage space-y-4">
<div className="space-y-2">
<Label htmlFor="generate-key-passphrase">{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input id="generate-key-passphrase" type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''} disabled={isGenerating}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')} className="pr-10" />
<Button type="button" variant="ghost" size="icon" disabled={isGenerating}
aria-label={t(showPassphrase ? 'hostDetails.password.hide' : 'hostDetails.password.show')}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassphrase(!showPassphrase)}>
{showPassphrase ? <EyeOff size={15} /> : <Eye size={15} />}
<div className="space-y-2">
<Label>{t('keychain.generate.keySize')}</Label>
<div className="flex gap-2">
{(draftKey.type === 'RSA'
? [4096, 2048, 1024]
: [256, 384, 521]
).map((size) => (
<Button
key={size}
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.keySize === size && "bg-primary/15 text-primary"
)}
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
>
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<Label id="generate-save-passphrase">{t('keychain.generate.savePassphrase')}</Label>
<Switch checked={!!draftKey.savePassphrase} disabled={isGenerating}
aria-labelledby="generate-save-passphrase"
onCheckedChange={savePassphrase => setDraftKey({ ...draftKey, savePassphrase })} />
))}
</div>
</div>
)}
<Button className="h-10 w-full gap-2" onClick={onGenerate} disabled={isGenerating || !draftKey.label?.trim()}>
{isGenerating && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{t(isGenerating ? 'keychain.generate.generating' : 'keychain.generate.generateSave')}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphrase"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<Button
className="w-full h-11"
onClick={onGenerate}
disabled={isGenerating || !draftKey.label?.trim()}
>
{isGenerating ? (
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
) : (
t('keychain.generate.generateSave')
)}
</Button>
</div>
</>
);
};
-61
View File
@@ -1,61 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SK_ECDSA_NISTP256, SK_SSH_ED25519 } from "../../domain/fidoSsh.ts";
import { detectKeyType, resolveImportedKeyType } from "./utils.ts";
/** Real OpenSSH shape: algorithm only after base64 decode. */
function makeSkPrivatePem(algo: string): string {
const body = Buffer.from(
`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`,
).toString("base64");
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("resolveImportedKeyType prefers FIDO detection over seeded ED25519 draft", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
// Simulate openImport seed + paste import (handleImport path).
const seededDraftType = "ED25519" as const;
const detected = resolveImportedKeyType({ privateKey: pem });
assert.equal(detected, "ED25519-SK");
// Product rule: material wins; never keep seeded ED25519 when SK is detected.
const savedType = detected; // same as fixed handleImport
assert.notEqual(savedType, seededDraftType);
assert.equal(savedType, "ED25519-SK");
});
test("resolveImportedKeyType distinguishes ECDSA-SK from base64-only PEM", () => {
assert.equal(
resolveImportedKeyType({ privateKey: makeSkPrivatePem(SK_ECDSA_NISTP256) }),
"ECDSA-SK",
);
});
test("detectKeyType uses the same material path for file drop", () => {
assert.equal(detectKeyType(makeSkPrivatePem(SK_SSH_ED25519)), "ED25519-SK");
assert.equal(detectKeyType("-----BEGIN RSA PRIVATE KEY-----\nMII\n-----END RSA PRIVATE KEY-----"), "RSA");
});
test("resolveImportedKeyType keeps soft keys", () => {
assert.equal(
resolveImportedKeyType({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
}),
"ED25519",
);
});
test("KeychainManager handleImport uses material type not seeded draft type", () => {
const source = readFileSync(join(import.meta.dirname, "../KeychainManager.tsx"), "utf8");
// Must resolve from material; must not keep draftKey.type first (openImport seeds ED25519).
assert.match(source, /detectSshKeyType/);
assert.doesNotMatch(
source,
/type:\s*\(draftKey\.type as KeyType\)\s*\|\|\s*detectedType/,
);
assert.match(source, /type:\s*detected.type/);
});
+3 -17
View File
@@ -4,7 +4,6 @@
import { BadgeCheck, Key } from 'lucide-react';
import React from 'react';
import { detectFidoSshKeyType } from '../../domain/fidoSsh';
import { logger } from '../../lib/logger';
import { detectSshKeyType } from '../../domain/keyTypeDetect';
import { KeyType, SSHKey } from '../../types';
@@ -26,25 +25,12 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
};
/**
* Resolve vault KeyType from imported key material.
*
* Always prefer material detection over the import form's seeded default
* (`openImport` seeds `type: "ED25519"`). Real OpenSSH sk private PEMs only
* expose sk-* algorithms after base64 decode — handled by detectFidoSshKeyType.
* Detect key type from private key content
*/
export const resolveImportedKeyType = (args: {
privateKey: string;
publicKey?: string;
}): KeyType => {
return detectFidoSshKeyType(args) || detectSshKeyType(args.privateKey, args.publicKey).type;
export const detectKeyType = (privateKey: string): KeyType => {
return detectSshKeyType(privateKey).type;
};
/**
* Detect key type from private key content (file drop / paste helpers).
*/
export const detectKeyType = (privateKey: string): KeyType =>
resolveImportedKeyType({ privateKey });
/**
* Copy text to clipboard
*/
@@ -37,7 +37,6 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
import {
hostRestrictsExtraSshChannels,
resolveHostKeepalive,
@@ -112,32 +111,6 @@ const createPluginConnectionRequestId = (): string => {
return `plugin-connection-${randomId}`.slice(0, 128);
};
/**
* Private key material for the bridge.
* Soft keys: PEM/OpenSSH private key for ssh2.
* FIDO2 SK: still send the OpenSSH key *handle* so the main process can
* ssh-add it into the system agent (signing stays on the hardware).
* Reference keys: path-only (no inline material).
*/
const resolveBridgePrivateKeyMaterial = (
key: SSHKey | undefined | null,
options?: { forceAgent?: boolean },
): string | undefined => {
if (!key || key.source === "reference") return undefined;
// Soft-key agent mode still omits inline privateKey when the agent will sign.
if (
options?.forceAgent
&& !requiresFidoSshAgentAuth({
type: key.type,
publicKey: key.publicKey,
privateKey: key.privateKey,
})
) {
return undefined;
}
return sanitizeCredentialValue(key.privateKey) || undefined;
};
const formatPluginDiagnosticLines = (
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
): string[] => (diagnostics ?? [])
@@ -478,9 +451,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
@@ -708,11 +679,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
privateKey: resolveBridgePrivateKeyMaterial(attempt.key),
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keyLabel: attempt.key?.label,
keySource: attempt.key?.source,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
@@ -885,10 +855,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
const rawMessage = err instanceof Error ? err.message : String(err);
const message = rawMessage.includes("Security key authentication cancelled")
? tr("fido.prompt.cancelled", "Security key authentication cancelled.")
: rawMessage;
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
if (isJumpHostAuthError(err, message)) {
@@ -1302,9 +1269,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod,
requiresMfa: !!ctx.host.requiresMfa,
password: effectivePassword,
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
@@ -1598,9 +1563,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: jumpAuth.username || "root",
authMethod: jumpAuth.authMethod,
password: jumpPassword,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
keyId: jumpAuth.keyId,
@@ -1639,9 +1602,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
hostId: ctx.host.id,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
+4 -11
View File
@@ -83,7 +83,6 @@ interface AsidePanelProps extends AsidePanelResizeProps {
onBack?: () => void;
children: ReactNode;
className?: string;
headerClassName?: string;
width?: string;
layout?: AsidePanelLayout;
/**
@@ -100,7 +99,6 @@ interface AsidePanelHeaderProps {
onBack?: () => void;
onClose: () => void;
showBackButton?: boolean;
className?: string;
}
// Header Component
@@ -111,10 +109,9 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
onBack,
onClose,
showBackButton = false,
className,
}) => {
return (
<div className={cn("px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0", className)}>
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
<div className="flex items-center gap-2 min-w-0">
{showBackButton && onBack && (
<button
@@ -173,18 +170,16 @@ export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: strin
// Action Menu Component (for the ... button)
interface AsideActionMenuProps {
children: ReactNode;
triggerClassName?: string;
ariaLabel?: string;
}
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children, triggerClassName, ariaLabel }) => {
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button aria-label={ariaLabel} className={cn("p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer", triggerClassName)}>
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
<MoreVertical size={18} />
</button>
</PopoverTrigger>
@@ -350,7 +345,6 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
onBack,
children,
className,
headerClassName,
width = 'w-[380px]',
layout = 'overlay',
resizable = false,
@@ -420,7 +414,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && !usesPixelWidth && width,
isResizing && 'transition-none',
className,
className
)}
style={panelStyle}
data-section={dataSection}>
@@ -441,7 +435,6 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
{title && (
<AsidePanelHeader
title={title}
className={headerClassName}
subtitle={subtitle}
actions={actions}
onClose={onClose}
-56
View File
@@ -1,56 +0,0 @@
import React from "react";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { ResourceBar } from "../systemManager/ResourceBar";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const diskCapacity = (value: number) => value >= 1024
? `${(value / 1024).toFixed(2)} TiB`
: value > 0 && value < 1
? `${(value * 1024).toFixed(1)} MiB`
: `${value.toFixed(1)} GiB`;
/** Cards show the largest capacities; the list keeps every reported mount. */
export function MonitorDiskUsage({
stats,
limit,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
limit?: number;
}) {
const { t } = useI18n();
const reported = stats?.disks.length
? stats.disks
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
const disks = limit == null
? reported
: [...reported].sort((a, b) =>
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
).slice(0, limit);
return (
<TooltipProvider delayDuration={150}>
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
{disks.map((disk, index) => (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
{disk.mountPoint}
</span>
<ResourceBar label="" value={disk.percent} className="flex-1" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
<div className="mb-1 break-all font-medium">{disk.mountPoint}</div>
<div className="tabular-nums text-muted-foreground">
{typeof disk.used === "number" && Number.isFinite(disk.used) && disk.used >= 0 && typeof disk.total === "number" && Number.isFinite(disk.total) && disk.total > 0
? t("monitor.diskCapacity", { used: diskCapacity(disk.used), total: diskCapacity(disk.total) })
: t("monitor.diskCapacityUnknown")}
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
);
}
-29
View File
@@ -1,29 +0,0 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useMonitorHostKeyVerification } from "../../application/state/useMonitorHostKeyVerification";
import { toHostKeyInfo } from "../../domain/hostKey";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
export function MonitorHostKeyDialog() {
const { t } = useI18n();
const { request, respond } = useMonitorHostKeyVerification();
return (
<Dialog open={!!request} onOpenChange={(open) => !open && respond(false)}>
<DialogContent hideCloseButton>
<DialogTitle className="sr-only">
{t("monitor.confirmHost")}
</DialogTitle>
{request && (
<TerminalHostKeyVerification
hostKeyInfo={toHostKeyInfo(request)}
showLogs={false}
progressLogs={[]}
onClose={() => respond(false)}
onContinue={() => respond(true)}
onAddAndContinue={() => respond(true, true)}
/>
)}
</DialogContent>
</Dialog>
);
}
-38
View File
@@ -1,38 +0,0 @@
import React from "react";
import { Gauge } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { cn } from "../../lib/utils";
import { RippleButton } from "../ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function MonitorNavButton({
active,
collapsed,
onClick,
}: {
active: boolean;
collapsed: boolean;
onClick: () => void;
}) {
const { t } = useI18n();
return (
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={active ? "secondary" : "ghost"}
onClick={onClick}
aria-label={t("monitor.title")}
className={cn(
"w-full h-10",
collapsed ? "justify-center p-0" : "justify-start gap-3",
active &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
>
<Gauge size={16} className="shrink-0" />
{!collapsed && <span>{t("monitor.title")}</span>}
</RippleButton>
</TooltipTrigger>
<TooltipContent side="right">{t("monitor.title")}</TooltipContent>
</Tooltip>
);
}
-168
View File
@@ -1,168 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { JSDOM } from "jsdom";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
const now = 1_800_000_000_000;
const point = (ago: number, cpu: number | null): MonitorPoint => ({
at: now - ago,
cpu,
memory: null,
disk: null,
rx: null,
tx: null,
});
const trend = (points: MonitorPoint[]) => new JSDOM(renderToStaticMarkup(
<MonitorTrend clock={0} points={points} field="cpu" range={5} interval={10} label="CPU trend" />,
)).window.document;
test("unchanged series reuse geometry but age out when the viewport clock advances", async (t) => {
const dom = new JSDOM("<div id='root'></div>");
const globals = { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
let current = now;
t.mock.method(Date, "now", () => current);
const root = createRoot(dom.window.document.getElementById("root")!);
const samples = [point(1000, 42)];
try {
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU" />));
const path = dom.window.document.querySelector("svg g path")!.getAttribute("d");
current += 1000;
await act(async () => root.render(<MonitorTrend clock={0} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
assert.equal(dom.window.document.querySelector("svg g path")!.getAttribute("d"), path);
current += 6 * 60000;
await act(async () => root.render(<MonitorTrend clock={1} points={samples} field="cpu" range={5} interval={60} label="CPU updated" />));
assert.equal(dom.window.document.querySelectorAll("svg g path, svg circle").length, 0);
} finally {
await act(async () => root.unmount());
for (const key of Object.keys(globals)) {
const descriptor = originals[key];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
}
});
test("empty trends do not draw a reading", (t) => {
t.mock.method(Date, "now", () => now);
const document = trend([]);
assert.equal(document.querySelectorAll("svg g path, svg circle").length, 0);
});
test("a first reading is visible at its actual time without invented history", (t) => {
t.mock.method(Date, "now", () => now);
const document = trend([point(1000, 40)]);
const dot = document.querySelector("circle")!;
assert.equal(Number(dot.getAttribute("cx")), 299);
assert.equal(document.querySelector('g path[fill="none"]')!.getAttribute("d"), "M 299.00 36.40");
const area = document.querySelector("g path")!.getAttribute("d")!;
assert.equal(area, "M 299.00 36.40 L 299.00 58 L 299.00 58 Z");
});
test("trend lines and shaded areas break at missing readings and collection gaps", (t) => {
t.mock.method(Date, "now", () => now);
for (const points of [
[point(40000, 20), point(30000, null), point(20000, 30), point(10000, 40)],
[point(50000, 20), point(10000, 30), point(0, 40)],
]) {
const document = trend(points);
const segments = [...document.querySelectorAll("svg g")];
assert.equal(segments.length, 2);
assert.equal(segments[0].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), false);
assert.equal(segments[1].querySelector('path[fill="none"]')!.getAttribute("d")!.includes(" L "), true);
// Each area starts at its own segment, rather than extending to the chart origin.
for (const segment of segments) {
const firstX = segment.querySelector("path")!.getAttribute("d")!.split(" ")[1];
assert.ok(Number(firstX) > 0);
assert.ok(segment.querySelector("path")!.getAttribute("d")!.endsWith(`L ${firstX} 58 Z`));
}
}
});
test("multiple host charts use independent gradient references", () => {
const document = new JSDOM(renderToStaticMarkup(<>
<MonitorTrend clock={0} points={[]} field="cpu" range={5} interval={10} label="CPU" />
<MonitorTrend clock={0} points={[]} field="memory" range={5} interval={10} label="Memory" />
</>)).window.document;
const ids = [...document.querySelectorAll("linearGradient")].map((g) => g.id);
assert.equal(new Set(ids).size, 2);
});
const hosts: Host[] = [
{ id: "custom", label: "Cloud host", hostname: "cloud.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, iconMode: "custom", iconId: "cloud", iconColorMode: "manual", iconColorCustom: "#9333EA" },
{ id: "brand", label: "Ubuntu host", hostname: "ubuntu.example.com", username: "test", port: 22, os: "linux", tags: [], createdAt: 1, distro: "ubuntu" },
];
test("disk usage shows every reported partition, including healthy and empty disks", () => {
const disks = [
{ mountPoint: "/", used: 25, total: 100, percent: 25 },
{ mountPoint: "/data", used: 95, total: 100, percent: 95 },
{ mountPoint: "/backup", used: 0, total: 100, percent: 0 },
];
const document = new JSDOM(renderToStaticMarkup(
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
)).window.document;
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
for (const value of ["25.0%", "95.0%", "0.0%"])
assert.ok(document.body.textContent!.includes(value));
});
test("disk usage retains a root-only reading and leaves missing data unknown", () => {
const known = renderToStaticMarkup(<MonitorDiskUsage stats={{ disks: [], diskPercent: 42 }} />);
const unknown = renderToStaticMarkup(<MonitorDiskUsage />);
assert.ok(known.includes("42.0%"));
assert.ok(unknown.includes("--"));
assert.ok(!unknown.includes("0.0%"));
});
test("cards show the two largest capacities without changing the source disk order", () => {
const disks = [
{ mountPoint: "/small", used: 49, total: 50, percent: 98 },
{ mountPoint: "/data", used: 100, total: 1000, percent: 10 },
{ mountPoint: "/backup", used: 0, total: 500, percent: 0 },
];
const document = new JSDOM(renderToStaticMarkup(
<MonitorDiskUsage stats={{ disks, diskPercent: 98 }} limit={2} />,
)).window.document;
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/data", "/backup"]);
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
});
for (const view of ["list", "charts"] as const) {
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
const storage = new Map<string, string>();
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
} });
t.after(() => {
if (original) Object.defineProperty(globalThis, "localStorage", original);
else Reflect.deleteProperty(globalThis, "localStorage");
});
writeMonitorPanels([{ id: "panel", name: "Panel", description: "", hostIds: hosts.map((h) => h.id), view, interval: 10, range: 15, sort: "manual" }]);
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorPanels hosts={hosts} customGroups={[]} onConnect={() => {}} /></I18nProvider>,
)).window.document;
const icon = document.querySelector(".lucide-cloud")!;
assert.ok(icon);
assert.equal((icon.parentElement as HTMLElement).style.backgroundColor, "rgb(147, 51, 234)");
assert.ok(document.querySelector('img[src="/distro/ubuntu.svg"]'));
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const network = document.querySelector("tbody tr")!.children[5];
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
}
File diff suppressed because it is too large. Load diff
-5
View File
@@ -1,5 +1,3 @@
import { MonitorPanels } from './MonitorPanels';
import { MonitorNavButton } from './MonitorNavButton';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Combobox } from "../ui/combobox";
import React, { useCallback } from "react";
@@ -251,7 +249,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Network,
newFolderName,
newHostGroupPath,
onConnect,
onConnectSerial,
onCreateLocalTerminal,
onDeleteHost,
@@ -624,7 +621,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</TooltipContent>
)}
</Tooltip>
<MonitorNavButton active={currentSection === "monitor"} collapsed={sidebarCollapsed} onClick={() => setCurrentSection("monitor")} />
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
@@ -1411,7 +1407,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</Suspense>
</LazyLoadBoundary>
)}
{currentSection === "monitor" && <MonitorPanels hosts={hosts} customGroups={customGroups} onConnect={onConnect} />}
{currentSection === "port" && (
<LazyLoadBoundary
name="Port forwarding"
-172
View File
@@ -1,172 +0,0 @@
# FIDO2 SSH validation without hardware
Run the real OpenSSH integration simulation on Linux:
```sh
npm run test:fido-sim
```
Prerequisites: Node.js 22+, a C compiler, OpenSSL development headers, and
OpenSSH `ssh-keygen`, `/usr/bin/ssh-agent`, `ssh-add`, `ssh-sk-helper`, and
`/usr/sbin/sshd`. The test runs an unprivileged server bound to a random
localhost port and uses Netcatty's managed temporary directory. It does not
modify system SSH configuration or the user's login agent.
The opt-in test compiles `scripts/fixtures/fido/provider.c` as an OpenSSH SK
provider and exercises the production key generation, askpass and agent helpers.
It covers both ED25519-SK and ECDSA-SK, PIN-required enrollment and signatures,
tokenized IdentityFile references, inline handles, SSH command execution, SFTP,
TCP forwarding, CA certificates under IdentitiesOnly, and simultaneous callers
from separate windows. The independent OpenSSH server verifies the signatures.
The provider is strictly a test fixture: its opaque handles contain disposable
software private keys, and its PIN is always `123456`. It must never be selected
for a real account. Its resident flag support only simulates enrollment; it does
not enumerate stored resident credentials.
The ordinary test suite also exercises PIN/touch helper subprocesses, modal
cancellation, helper-exit cleanup, platform selection, resource release and SK
signature framing. `npm test` skips the opt-in integration simulation.
This verifies the OpenSSH integration on Linux with a software authenticator.
Real USB/CTAP devices and Windows/macOS installations still need device testing.
The Linux simulation also removes the optional `.pub` file before strict agent
selection, runs two real native OpenSSH clients concurrently against separate
caller-bound agent sockets, and checks native SK certificate authentication.
It verifies that releasing a second certificate preserves the bare key and
the first certificate while removing only the released certificate. Native
Mosh/ET authentication uses these sockets so its signatures follow the same
window attribution and recovery wrapper as ssh2.
Identity preparation and removal are serialized per underlying agent socket.
Bare-key removal uses `ssh-add -d -k` and certificate removal has its own hold;
staging directories remain until all associated removals have completed.
The tests also cover partial credential-write failures, socket paths containing
spaces, and unknown agent-extension requests followed by normal requests on
the same connection.
## Native Windows integration check
The `windows-fido` job in `.github/workflows/test.yml` runs native Windows Node
with real MSYS2 OpenSSH and an MSYS-built version of the disposable provider.
It uses a managed temporary path containing spaces and exercises Windows
named-pipe askpass IPC, enrollment and actual signatures
for both SK algorithms, PIN plus a separately gated touch, incorrect PIN,
missing-device cancellation with a saved password, and explicit password
fallback. A local SSH protocol server verifies the signatures and executes a
test command. The independent Docker/OpenSSH server check above runs on Linux.
This checks a specific **user-mode MSYS2 OpenSSH** configuration. It does not
establish support for every Windows OpenSSH distribution, the Windows system
agent service, Git for Windows, native WebAuthn dialogs, or physical USB devices.
Windows currently has no `ssh-sk-helper` error observer: recovery must retain
the generic verification-failed reason rather than guessing which device error
occurred. The Unix helper's 30-second cancellation deadline also does not apply
to this Windows path.
For a physical-device check of this configuration, launch Netcatty with
`NETCATTY_SSH_AGENT_PATH` and `NETCATTY_SSH_KEYGEN_PATH` pointing to the matching
MSYS2 executables and put that installation's `usr/bin` on `PATH`. Use its real
built-in FIDO provider; **never** set `SSH_SK_PROVIDER` to the CI fixture. Check
the complete application flow against a separate SSH server: select the saved
SK identity on a host, connect, fulfill the actual PIN/touch requests, execute a
command, and repeat with the device absent, removed, and with PIN cancellation.
Record the Windows/OpenSSH/device versions and which dialogs are owned by the
application or OS. A successful software-provider CI run does not replace this
hardware check.
## Docker server and gated user presence
```sh
npm run test:fido-docker
```
Requires Linux, Docker, a C compiler, OpenSSL headers and the OpenSSH client
tools above. Host `sshd` is not required. The command builds a disposable Debian
OpenSSH server, publishes a random port on `127.0.0.1`, and removes its container
and temporary files on completion. The server accepts only public-key login as
the test user `netcatty`. Host keys are generated when the container starts.
Only disposable public authorized keys are mounted into the server; the client
and software provider run on the host through Netcatty's production helpers.
For each of ED25519-SK and ECDSA-SK, both touch-only and PIN-plus-touch modes
are exercised. The fixture's optional `NETCATTY_FIDO_SIM_CONTROL` file gates the
actual signature: the test first asserts that login has not completed, then
simulates physical touch independently of the renderer. No renderer response
is sent for OpenSSH's touch notifications. The test verifies automatic prompt
dismissal, command execution and SFTP, as well as rejection of missing touch,
device removal, wrong/cancelled PIN, and signed responses missing the UP or UV
flag. The latter assertions also check the independent server's rejection log.
A fresh login after each group of failures checks that the agent remains usable.
The fixture accelerates the simulated touch timeout to three seconds; it does
not reproduce device retry counters, PIN lockout or CTAP/USB transport.
## Expected security-key flow
This feature implements **FIDO2 OpenSSH security keys**, not the YubiKey PIV or
OpenPGP applications. The public key must first be installed on the SSH server.
During login, OpenSSH asks the device to sign; normal keys require touch, while
`verify-required` additionally requests user verification (typically the device
PIN). The private key remains on the real hardware; Netcatty stores its handle.
Resident credentials support recovery of handles on another machine, and do not
remove the presence/verification requirement.
OpenSSH's `notify_start()` invokes askpass with `SSH_ASKPASS_PROMPT=none` and
terminates the notification helper when the hardware operation finishes. That
mode is forwarded to the UI as `notificationOnly`: there is no Continue button,
and the dialog closes automatically when the helper exits. Closing this hint
only dismisses the notification; it does not authorize or cancel the hardware
operation. PIN prompts still have explicit Continue and Cancel actions. OpenSSH
can combine PIN and touch instructions into one prompt, so the UI also explains
that the user should touch the device after entering the PIN. The exact prompt
order depends on the OpenSSH/device implementation; the UI follows its requests.
For the agent's combined PIN/presence prompt, the UI keeps a touch-only waiting
hint after PIN submission, tied to the real signing operation's completion.
The feature is vendor-neutral: compatible security keys from other vendors can
be used. Check the device's algorithm support: ECDSA-SK and ED25519-SK are not
universally interchangeable. PIN/resident/biometric support also varies. A
touch indicates physical presence; it does not imply fingerprint recognition.
### Recovery and alternative authentication
On supported Unix OpenSSH installations, Netcatty wraps `ssh-sk-helper` to
observe its framed error reply before `ssh-agent` collapses it into generic
agent failure. The wrapper forwards the request/reply bytes unchanged and never
logs request contents, PINs or signatures. It pairs with the selected OpenSSH
helper, honors an existing `SSH_SK_HELPER`, and lives in the managed temp dir.
Unrecognized helper reply versions retain generic recovery while their bytes
are forwarded unchanged.
If no compatible wrapper is available (including native Windows agents), use
the generic verification-failed message instead of inventing a missing-device
diagnosis. Real Windows/macOS device behavior still requires hardware testing.
| Situation | Behavior |
| --- | --- |
| Matching device unavailable | Show the selected key's label and ask to connect its device; Retry continues the pending login. |
| Waiting for device confirmation | Show a touch/verification hint; the device completes it automatically. Closing the hint only hides it. |
| Device operation exceeds 30 seconds | Stop that helper operation, discard late signatures and offer Retry. PIN entry time is excluded. |
| Device removed while waiting | The helper's device-unavailable result returns to the connection/retry prompt. |
| PIN rejected | Explain that the device PIN was not accepted; require an explicit retry, without guessing remaining attempts. |
| Other helper failure | Offer generic recovery; do not claim the device is missing. |
| Saved password available | Offer an explicit "Use configured password" action after a failed device operation. Cancel does not select the password. |
| Device removed after SSH login | The established SSH session continues; the device signs authentication, not each terminal command. |
The configured SSH authentication deadline still bounds the pending connection.
Device recovery also closes when its owning session/window is torn down. A
lost prompt window or explicit cancellation fails closed; late results cannot
authorize the cancelled attempt.
The Docker suite accelerates the helper deadline to 1.5 seconds and verifies
both key algorithms, device insertion/retry, removal, timeout, incorrect PIN,
unknown/unsupported failures, explicit password fallback and cancellation with
a valid saved password. UI recordings shorten the deadline to 4.5 seconds and
use the complete host -> key selection -> production SSH bridge -> Docker shell
path; only the Electron transport and physical authenticator are simulated.
References:
- [Yubico: Securing SSH with FIDO2](https://developers.yubico.com/SSH/Securing_SSH_with_FIDO2.html)
- [OpenSSH notify_start/notify_complete](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/readpass.c)
- [OpenSSH agent security-key signing](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/ssh-agent.c)
@@ -1,228 +0,0 @@
# 自定义监控面板:研究与页面雏形
日期:2026-10-17。对应 [#3621](https://github.com/binaricat/Netcatty/issues/3621)。本稿取代上一版“在主机分组里开启监控”的提案,以用户本轮要求为准。
状态:用户已认可雏形并授权正式实现,补充的五项要求如下,优先于原型中仅为演示而作的简化:
- 成品使用整个 App 的设计系统、主题色、字体、间距与现有组件;不把独立雏形的样式直接搬入产品。
- 新建、编辑、选择主机与详情的表单交互沿用当前 Vault 的成熟模式,包括返回、取消、保存、验证与滚动行为。
- 所有新增用户可见文字、状态、错误与操作完整接入现有 i18n,覆盖项目已有语言,不把雏形中的中文硬编码带入成品。
- 面板配置中的主机引用、顺序、列表/图表布局和显示偏好等纳入现有云同步,并覆盖相应导入导出、备份及兼容路径;读数与趋势仍绝不持久化或同步。
- 主机系统识别、能力判断、信息探测与状态采集复用或扩充现有逻辑,禁止为监控平行重写一套探测器。
## 1. 已确认的产品要求
1. Vault 左侧菜单新增独立“监控”栏目。
2. 用户可创建、命名和切换多个监控面板。
3. 每个面板自由选择已有主机,可跨分组;面板不等于主机分组。
4. 同一个面板可在列表和丰富图表两种展示之间切换。
5. 使用 SSH 长连接,沿用 Netcatty 现有连接复用逻辑,不另造一套独立监控连接池。
6. 用户已明确:面板名称、主机选择、展示偏好等配置重启后保留;监控读数不在本地持久化。
7. 研究与雏形已完成并获认可;正式实现单独创建 thread 与 worktree,延续此前授权的 PR 审查与合并流程。原型验证不替代真实 App、同步与 SSH 验收。
完成标准:核实多面板管理、主机选择、两种展示和连接复用的相关一手资料;给出一致的运行与保存规则;雏形中的新建、修改、复制、删除、选主机、切换、启停和失败提示能实际操作;打开浏览器验证布局与交互,并区分示例演示与真实连接验证。
## 2. 研究结论
最适合的组合是 **Homarr 的命名面板管理 + Grafana 的对象与展示分离 + ServerBox 的紧凑主机卡片 + Termix 的共享采集**。最终做成 Netcatty 自己的轻量监控页面,保持现有 Vault 导航、品牌、配色、图标和右侧面板习惯。
不需要让用户逐张图配置数据源、查询语言和坐标位置。面板只负责“看哪些主机、怎么显示、多久更新”,一台主机在多个面板出现,不应使远端查询成倍增加。
### 一手资料对照
| 项目 | 已核实的模式 | 本设计采用什么 | 没有照搬什么 |
| --- | --- | --- | --- |
| Homarr | 独立命名 board,创建、复制、打开、删除、搜索切换;服务器选择器补充来源防同名 | 面板管理与切换;主机显示名称、地址和分组 | 分享权限、门户首页、嵌套容器、完整自由布局 |
| Grafana | 集中选择监控对象;同一数据提供者切换图形/表格预览;自动网格 | 主机集合属于整页面板;列表/图表只换展示;自动排布 | 查询编辑器、数据源管理、变量依赖、历史数据库 |
| ServerBox | 多主机卡片/行/网格密度,核心读数摘要与单机详情 | 图表卡片与列表的信息密度 | 远端采集脚本与其断连行为 |
| Termix | 同机多观察者共享采集;列表摘要与详情分开;认证与网络错误分别处理 | 多面板共享一份指标,明确失败和待登录 | 独立于终端的监控连接池 |
本轮没有部署竞品;下列行为来自官方文档与固定提交源码。没有声称某一个竞品完整具备本设计的全部功能。
### Homarr:面板管理和对象选择
固定提交:`e0ff7ab007d915269351ab9f5b227f2ffcc2c115`。
- [官方 Boards](https://homarr.dev/docs/management/boards/) 与 [创建表单](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/packages/forms-collection/src/board-create/board-create-form.tsx#L20-L98)确认 board 是可命名的独立对象;[复制表单](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/apps/nextjs/src/app/%5Blocale%5D/manage/boards/_components/duplicate-board-form.tsx#L22-L86)要求为副本命名。
- [BoardSwitcher](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/apps/nextjs/src/components/board/board-switcher.tsx#L51-L80)支持按名称过滤面板;我们采用轻量下拉,不再增加第二级永久侧栏。
- [系统选择](https://github.com/homarr-labs/homarr/blob/e0ff7ab007d915269351ab9f5b227f2ffcc2c115/packages/widgets/src/beszel-system-stats/selection.ts#L14-L32)用来源 ID 与系统 ID 区分对象,多来源时补来源名称。我们对应保存已有主机 ID,并展示地址和分组,避免只凭名称选错。
- 官方有 [table](https://homarr.dev/docs/widgets/beszel-system-table/)、[grid](https://homarr.dev/docs/widgets/beszel-system-grid/) 和 [stats](https://homarr.dev/docs/widgets/beszel-system-stats/) 三种相关展示。它们是不同 widget,并不能证明 Homarr 有一个按钮让整个页面直接切换三种展示。我们的一键列表/图表是针对本需求做的简化。
### Grafana:同数据换展示与自动排布
固定提交:`3392442829842df6485c9305a23c30943147ebe6`。
- [Variables 文档](https://grafana.com/docs/grafana/latest/visualizations/dashboards/variables/)说明通过页面顶部的统一选择改变监控对象,避免复制很多相同内容。我们的对象选择使用现有主机多选,而不引入变量语言。
- [PanelEditor.onToggleTableView](https://github.com/grafana/grafana/blob/3392442829842df6485c9305a23c30943147ebe6/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx#L422-L448)保留原图表的数据提供者,通过 `DataProviderSharer` 建立表格预览。这是同数据换展示的明确先例,但它是图表编辑器内预览,不是全局 dashboard 一键转表格。
- [自动网格编辑器](https://github.com/grafana/grafana/blob/3392442829842df6485c9305a23c30943147ebe6/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx#L44-L171)提供按最小列宽和最多列数排布的能力。我们的首版只自动适应可用宽度,不做拖拽搭建器。
### ServerBox 与 Termix:多机读数与共享
- [ServerBox 列表密度](https://github.com/lollipopkit/flutter_server_box/blob/278d48013e0e855c889455aeb58e17c9b8db6679/lib/view/page/server/card/density.dart#L6-L95)和[读数构造](https://github.com/lollipopkit/flutter_server_box/blob/278d48013e0e855c889455aeb58e17c9b8db6679/lib/view/page/server/card/metric.dart#L491)提供多机紧凑呈现参考;标签过滤不等于本设计的命名面板。
- [Termix 观察者管理](https://github.com/Termix-SSH/Plugin-Host-Metrics/blob/e3bde42a184e41c2b09c80a82fe2ab1f3306a726/src/backend/poller.ts#L59-L157)提供同机多处查看共享采集的参考;[采集入口](https://github.com/Termix-SSH/Plugin-Host-Metrics/blob/e3bde42a184e41c2b09c80a82fe2ab1f3306a726/src/backend/collect.ts#L67-L185)优先使用插件自己的连接或 stats 池,不能当成已经满足 Netcatty 的终端连接复用要求。
### Xterminal 在新方案中的位置
此前已实际查看需求中的列表截图、官方连接中心和单机监控图片。Xterminal 的“信息”列和右侧详细监控适合参考读数组织;新的多命名面板结构来自本轮用户要求,不能继续受“只在分组里加开关”的旧方案限制。
[Xterminal 连接中心](https://docs.xterminal.cn/connections/overview/);[官方监控说明](https://docs.xterminal.cn/advanced/server-monitor/)明确会写采集脚本,不能将它作为“长连接就没有查询/审计”的证明。
## 3. 页面结构与主要流程
### 独立入口
Vault 左侧在“主机”下方增加“监控”,沿用原有选中态和折叠方式。正在运行时,栏目旁显示运行面板数;应用顶部工具栏保持原样。页面主体依次为:
1. 标题“监控”、“全部停止”和“新建面板”。
2. 当前面板名称下拉、说明、编辑与更多菜单。
3. 列表/图表切换、搜索、趋势范围、更新频率、当前面板开始/停止。
4. 主机列表或图表卡片;右侧按需展开编辑或详情,与页面顶部对齐,覆盖在内容上方并可拖拽调宽,不改变左侧内容宽度。
面板多时下拉支持按名称查找。第一版不再给监控面板引入分组、文件夹或嵌套页面。
### 新建与编辑
首次无面板时显示“创建第一个监控面板”。新建与编辑使用现有右侧面板风格:名称、可选说明、默认列表/图表、已有主机多选。
选择器按名称、地址和分组搜索;分组只负责帮助选择,最终保存的是明确的主机 ID 集合。可跨组选主机,显示“已选 N 台”,支持选择筛选结果和清空。名称不能为空、不能与现有面板重名;至少选一台。
保存只保存配置,不发起连接。新面板进入未开始状态,用户点击“开始监控”才连接。复制只复制配置与主机集合,不复制读数和运行状态;删除只删除面板,并释放它的监控需求,主机和其他面板保留。
编辑运行中面板时,保存后应用差异:新增成员加入队列,移除成员撤销本面板的需求;重复成员与其他面板继续共用。取消编辑不改变正在运行的名单。
主机改名或改组后按 ID 跟随;主机删除后显示失效成员并允许移除,不静默替换为同地址的另一条主机。组选中按当时的具体成员保存,未来新加主机不会悄悄加入面板。
### 列表模式
每台一行,字段为主机图标/名称/地址、状态、CPU、内存、各分区占用、上传/下载、更新时间。仅提供名称与保存的顺序两种排序,列表和卡片均支持拖拽;拖拽基于当前显示顺序保存并切换为保存的顺序,搜索隐藏的主机仍保留。提供搜索与详情入口。没有“需关注”机制。磁盘进度保留百分比,鼠标悬停时显示完整分区名与已用/总容量,缺失容量不伪造为零。
列表用于比较,避免每一行都塞完整图表。缺失值显示 `--`;失败时保留旧值但降灰并说明时间;第一次差值采样尚未完成时显示采样中。CPU 与网速未就绪不伪装成 0。
### 图表模式
每台一张自动排列的卡片:主机图标/名称/地址/状态、CPU 当前值及核数、内存当前值及容量、CPU/内存短趋势(悬停显示指示线、真实采样值和时间)、总容量最大的两个分区的挂载点和占用条、网络上下行当前速率。卡片高度保持一致,内部以留白和彩色读数分区,不嵌套描边容器或状态标签;列表同样无边框,工具栏控件统一高度。面板说明不在主页面重复展示。
点击主机打开右侧详情,显示放大的 CPU/内存趋势、网络上传/下载趋势、系统信息和各盘/各网卡读数。详情、编辑与选主机互斥打开,均使用可拖拽调宽的内联侧栏,打开时收窄左侧内容,与其他 Vault 分区保持一致;侧栏标题栏与主内容标题栏同高,保留返回/关闭和固定底部操作。
两种展示共用主机集合、读数和短趋势。换视图不重新登录、不清曲线、不新建另一份采集。显示偏好按每个面板保存。首版不加入自由拖拽布局和每张图的查询编辑器。
## 4. 已认可的运行规则
建议每个面板独立开始/停止。**明确开始的面板在应用运行期间继续采集,切换面板或进入终端不中断。** 不因打开下拉菜单或保存面板便自动开始全部面板。每次明确开始和每次变更采集频率都会清空当前面板的所有旧曲线,从本次操作之后的真实读数重新绘制;不会清除其他面板或终端的共享读数。
这种持续运行方式随雏形获得用户认可。它与上一稿“离开页面暂停”的规则不同,以本稿为准。研究和雏形阶段没有发起真实主机连接;正式实现须使用可控测试环境验证,不能任意批量登录用户保存的生产主机。
- 侧栏与顶部持续显示运行面板数;监控页同时显示去重后的主机数并提供“全部停止”。
- 停止 A 面板不影响 B 面板;交集主机由仍有效的需求继续使用。
- 停止取消本面板的后续采样和排队,释放不再需要的监控用途;正常终端不被断开。
- 退出软件结束全部监控。重启只还原面板配置,全部为未开始,不自动恢复后台运行。
- Vault 锁定、系统睡眠或网络断开时,不继续用缓存秘密重试登录;展示暂停/断开与缺口,解锁或恢复网络后依既有安全规则处理,不能绕过身份确认。
- 需要口令、验证码或主机身份确认时逐台处理,其他主机继续;不要同时弹出一串登录框。
默认 5 秒更新,菜单提供 3/5/10/30/60 秒,隐藏 1/2 秒选项,内部采样能力不作变更。共享缓存按当前监控的最快需求刷新,不以旧的 4 秒缓存限制 1 秒采集。系统信息、磁盘容量可低频,列表不采完整进程/GPU/每核详情。采集失败有限退避,认证或身份变化等待用户,不循环刷日志。
## 5. SSH 长连接:复用现有机制的真实边界
核对基线:Netcatty main `8aeb6ade6fc2b56436442ebe93852cb5dde5c141`。
### 已有基础
- [共享连接池](../../electron/bridges/sshConnectionPool.cjs)已支持非终端用途的借用/归还、兼容身份匹配、并发建连合并和空闲保留;[转发使用路径](../../electron/bridges/portForwardingBridge.cjs)说明不必先开终端才能借用连接。
- 完整连接身份包含保存主机、地址/端口/账号、路由与代理、认证及信任策略等。不能按 IP 或主机名另做一个简化去重表。
- 最后一个用途归还后,普通空闲连接默认可保留 5 分钟;配置为 0 时可保留到退出/主动清理。故“停止监控”应表示停止查询与归还用途,不应许诺物理连接瞬间消失。
### 需要增加的能力
- 当前池没有正式 monitor 用途;现有 [统计入口](../../electron/bridges/sshBridge/sessionOps.cjs)仍依赖终端 session。正式实现需要新增监控用途,并把采样上下文与 CPU/网速前次计数从终端会话解耦。不能创建隐藏终端冒充监控,否则可能带来终端启动脚本和其他副作用。
- 多面板、详情、终端与窗口对同一兼容身份共用一份采集;只有第一次无可用连接才按既有认证/组继承/跳板/信任检查流程建连。不另造监控专用池,不绕过认证。
- 多个订阅频率不同时,底层基础读数按最短有效需求;1 秒等快速监控始终使用轻量采集,进程/GPU 等完整读数只由终端按其自身频率触发,并有独立的 4 秒缓存;例如终端仍要每 5 秒读数,监控面板选择 30 秒不意味着该机器远端查询变为每 30 秒。各面板可按自身频率呈现,实际共享频率需可解释。
- 长连接指已认证 SSH 传输保持与复用,现有路径仍定时执行查询。它不自动等于持续推流、无远端命令或零堡垒机审计;改为持续采集进程属于另一个明确决策,本方案不默认引入远端落盘脚本或常驻安装。
正式支持范围先以现有 Linux/macOS SSH 统计与已验证跳板方式为准。网络设备、Telnet、单通道/受限方式需明确不可用原因。现有 [SelectHostPanel](../../components/SelectHostPanel.tsx)的默认过滤不足以代表这些能力已经全部处理。
## 6. 保存什么、不保存什么
| 保存的面板配置 | 只在内存中的运行内容 |
| --- | --- |
| 面板 ID、名称、说明 | 实时读数和最后成功快照 |
| 有序主机 ID 集合 | 趋势点与网速/CPU 差值基线 |
| 列表/图表偏好、刷新间隔、趋势范围 | 正在运行的面板集合、连接状态、错误响应 |
| 必要的排序和显示偏好 | SSH 连接对象、临时认证状态 |
配置通过现有 [storageKeys](../../infrastructure/config/storageKeys.ts) 和 [localStorageAdapter](../../infrastructure/persistence/localStorageAdapter.ts) 保存,不复制凭据。按照用户最新要求,必须把面板实体、主机引用、顺序与布局/显示偏好接入现有云同步、导入导出与备份体系。当前这些路径尚无面板字段,正式实现要补齐字段传输、合并、删除传播和旧版本缺字段兼容,不能只增加本地键后宣称同步已完成。多个设备和窗口间的修改不能互相静默覆盖或使已删除面板复活;主机 ID 的解析和失效引用遵循现有主机同步语义。
实时读数、内存曲线、登录连接与“正在运行”状态不进入同步载荷。其他设备收到面板配置后以未开始状态呈现,不因一次云端配置拉取自动批量登录主机。
监控读数不写 localStorage、IndexedDB、文件、缓存、备份、同步或常规日志;不通过“页面状态恢复”间接带出采样。诊断仅记录必要的状态与计数,不记录采样正文。
短趋势建议每个唯一采集身份一份有上限的内存缓冲,保留最近 30 分钟、最多 1800 点,监控最快 1 秒一点。多个面板不各存一份。按时间和数量同时裁剪,失去需求后有限期回收;程序退出全部清空。
因此只提供最近 5/15/30 分钟的观察范围,不提供昨天/过去一周等历史查询。实际采样不足所选范围时显示已采时长,断网或暂停期间保留缺口,不补 0、不虚构连贯趋势。100 台 × 1,800 点 × 六个 8 字节数字约 8.6MB 只是原始数字下限,实际对象和渲染占用仍要实测。
## 7. 雏形与本次验证
独立雏形位于本对话的可视化输出目录 `vault-monitor-panels`,以独立本地预览运行;未把监控功能接入主仓库。使用现有 AppLogo、AppWordmark 和 Lucide 图标,参考实际运行的 Vault 浅色界面。所有主机、状态和曲线都是示例;“打开终端”只说明正式功能的去向,验证码只演示逐台恢复。
已在浏览器检查:
- 初始未开始;开始后图表、计数和异常状态正确;列表/图表来回切换。
- 跨组选两台主机,创建新面板后未自动开始;开始第二面板后按唯一主机数计数。
- 离开监控栏目仍有运行数量提示;回来保留运行状态。
- 编辑改名、复制配置但不复制运行状态、删除面板不删其他配置。
- 删除到零个面板后,空状态仍能创建;空名称和未选主机有提示。
- 重开页面保留配置,运行状态与读数清空。
- 逐台演示登录恢复、过期数据提示、重试和网络详情。
- 桌面、小窗口、窄窗口及深色模式;修复了编辑侧栏底部按钮需要滚到页面底部才能看到的问题。
- 浏览器错误/警告检查与独立雏形构建。
这些证明雏形可用于审阅流程,不证明新的真实 SSH 采集、复用、审计或内存占用已经验收。首次开始后的预填短趋势是用于展示效果的示例数据,正式功能不能回填并不存在的历史。
## 8. 用户认可后,正式实现的交付门槛
1. 另开 thread/worktree,先确认 issue 与 PR 关系,按本稿确认的范围实现,不与暂缓的私人分组锁定混合。
2. 无终端的新监控连接、已有终端/SFTP/转发连接的复用、同身份并发建连都走真实路径;账号、密钥、代理或信任策略不同绝不错误共享。
3. 两面板、两窗口、列表/详情/终端同时查看,同机只采一份;切视图不重连,停一个面板不影响其他用途,退出后没有监控遗留。
4. 点击停止时连接中/排队中/采集中分别验证:不再补发后续查询;连接按池规则保留或关闭,终端继续能输入。
5. 核验读数绝无落盘或同步路径;重启只有配置没有曲线与运行状态;配置跨窗口编辑不会互相覆盖。两端真实同步或代表性端到端路径覆盖创建、改名、增删主机、改布局、删除面板、旧载荷缺字段及导入导出恢复;对既有同步方式的影响逐项核对。
6. 真实主机与至少一个代表堡垒机测查询与审计量;测试慢主机、首次采样、睡眠、断网、认证、主机删除和凭据变更。
7. 对代表性的主机规模测试内存、重绘与连接成本;不以原始数字估算替代运行结果。
8. 实际运行 Netcatty 走完整新建/编辑/切换/启停/详情流程;检查设计系统、Vault 表单交互、亮暗主题和窄窗口,切换语言验证状态、错误、复数和长文本;确认系统探测复用已有真实路径,再按已有 PR 审查与合并标准交付。
## 9. Implementation acceptance checklist
- [x] Named panels: create/edit/copy/delete, ordered cross-group host references, list/cards, search/sort/attention, details and terminal action.
- [x] Existing Vault components/themes/forms; every visible addition translated for all five locales.
- [x] Config only: local storage, concurrent window field edits, cloud merge/deletions/legacy payloads, JSON import/export and protective backups.
- [x] Shared SSH pool, full identity, joined dials, explicit monitor lease, no shell or startup commands; serialized authentication.
- [x] One collection/baseline/history per compatible transport across panels/windows/terminal; cancellation releases only owned demand.
- [x] Memory-only samples/status/errors; bounded sparse trends, honest first samples, stale readings and unsupported targets.
- [x] Lock/sleep/offline/close/quit cleanup; restart restores stopped configuration only.
- [x] Controlled SSH integration, cancellation/reuse and representative scale checks; real Electron complete flows, themes, narrow windows, languages.
- [ ] Focused and required checks, independent current-head reviews, exact-head remote review, CI and review-thread gates; verify after merge.
## 10. Implementation notes (2026-10-17)
- Panel configuration is normalized through an allowlist. It participates in the existing three-way and convergent sync collections, deletion records, auto-sync change notifications, Vault JSON export/import and protective backups. Prepared cloud applies rebase intervening window edits; old payloads without the field preserve local panels.
- Monitoring runs in the terminal worker, using the existing SSH registry's full identity and joined dials, with an explicit `monitor` lease. Authentication reuses the existing SSH preparation, host-key, keyboard-interactive and jump-host paths. No terminal session or shell is created for monitoring.
- Ordinary terminal opens retain their existing fresh-login policy. If a terminal already exists, monitoring borrows its compatible transport. Distinct transports with the exact same normalized identity share the metrics cache and baseline too, so a required fresh terminal login does not duplicate collection.
- Shared collection/history is volatile. Each history keeps at most 1800 points from the last 30 minutes, sampled no faster than every second. At most 256 unique monitoring identities run at once; the metrics identity cache is bounded to 512 entries and periodically prunes expired histories. No readings, errors, running intent or credentials enter the panel storage shape. Renderer polling transfers only new history points and omits unchanged host stats using per-history sample cursors; fresh samples and connection changes include their complete current stats. Trend geometry is memoized across unchanged samples (including after panel resets), reduces dense runs to per-column extrema, and advances idle viewports on a shared five-second clock. Hover still reads the original samples; missing samples and cadence gaps remain separate runs.
- Known network devices are rejected using the existing renderer banner rules, extracted into one shared module. Recognized single-channel peers are rejected before an extra command runs. Monitor-only samples omit process and GPU queries. Terminal readers share a separate four-second complete-sample cache; monitor readers continue collecting lightweight samples. The same existing Linux/macOS parser, disk handling, CPU deltas and network deltas serve terminal and monitoring readers.
- A real macOS shell exercise found an existing cooked-template escape in the disk awk expression. It now uses a literal prefix comparison, with a generated-command regression test; the actual monitor reads root/volume capacity correctly.
Verified so far in an isolated Electron profile and controlled SSH fixture (no saved production host logins): creation, validation, cross-group selection, copy starting stopped, two running panels, navigation retention, direct and jump-host real shell sampling, known-host confirmation, unsupported network devices, sequential verification prompts, failed login details, light/dark and Chinese/narrow rendering. Stopping an in-flight slow query produced no additional queries after 12 seconds; another panel and an ordinary terminal remained usable. Starting monitoring against an existing terminal did not add an authenticated connection. Restart restored two saved panels with zero running panels and zero monitor readings.
Focused configuration, sync/backup round-trip, concurrent edit, collection, pool, SSH authentication and capability checks pass. Build and directory packaging have run; the packaged archive includes the shared vendor rules and monitoring runtime. The repository-wide TypeScript check has 755 pre-existing errors on both this work and an untouched main snapshot, with no new diagnostics after path/line normalization. The Node 26 full suite also exposes a pre-existing transfer FileHandle cleanup failure, reproduced against untouched main; missing nested plugin dependencies were environment setup and were corrected. The complete suite finished with 13,822 tests: 13,791 passed, 30 skipped and the one reproduced baseline FileHandle failure. Follow-up review fixes have focused regression coverage. Current-head review and merge checks are recorded in the PR.
Additional acceptance evidence:
- Actual two-window execution: both owners watched the same two supported identities with two running panels and only two unique monitoring entries. A stale editor in the second window changed description and host order after the first renamed the panel; all three edits survived. Stopping and then closing the second owner left the first live. Independent tests cover owner closure during active and queued authentication, credential-changing and same-identity retry, and stop during post-authentication work.
- Actual UI actions also exercised saved ordering, name sorting, search, attention filtering, duplicate creation starting stopped, confirmed deletion retaining all saved hosts, and JSON restore. Clear All Local Data removes panels. An offline event stops all monitoring; restore recreates five saved panels with zero running panels.
- Each of the five locales was rendered in Electron with no untranslated monitoring keys or document overflow. Light/dark and 760px width were checked; the editor's Save button remained inside the viewport.
- A controlled SSH fixture supplied 100 distinct identities over real connections. All 100 produced their first readings in 876ms; 100 cards rendered (two trend charts each), with a measured renderer heap increase of 6.69MiB. The fixture recorded 100 successful authentications, 100 collection commands plus capability/disk work, and zero shells for this exercise. This tests fixture throughput and rendering, not a production fleet's network latency.
- Accelerated in-memory history exercise through the production sampler retained exactly 18,000 points for 100 identities, with a measured heap increase of 1.92MiB after collection. This measures the real bounded object representation; it does not claim 30 minutes of production observation.
- Last successful readings and their timestamp survive sampling failure; missing measurements remain null. A failed host does not block reuse and collection on an already-authenticated host. Capacity is applied after obsolete demand is removed, including replacing a host at the 256-identity bound.
### Fast-cadence transport validation
Renderer polling sends a per-host timestamp cursor. Worker snapshots send only newer points after the first full snapshot; renderer memory merges, deduplicates and bounds history to 1,800 points and 30 minutes. Endpoint history identities travel with cursors so replacing a host connection cannot merge readings from different endpoints. Worker/runtime changes replace history, and older responses cannot overwrite newer state. A 256-host test with full retained histories transfers only 256 new points per polling round, below one percent of the full-history payload. Full terminal stats keep their own four-second cache; one-second monitor requests remain lightweight.
-2
View File
@@ -52,7 +52,6 @@ export const CONVERGENT_ENTITY_COLLECTIONS = [
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'monitorPanels',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
export const CONVERGENT_STRING_COLLECTIONS = [
@@ -226,7 +225,6 @@ export function materializeSyncPayloadFromConvergentState(
keys: typedCollection<import('../models').SSHKey>(materialized.collections, 'keys'),
identities: typedCollection<import('../models').Identity>(materialized.collections, 'identities'),
proxyProfiles: typedCollection<import('../models').ProxyProfile>(materialized.collections, 'proxyProfiles'),
monitorPanels: typedCollection<import('../monitorPanels').MonitorPanel>(materialized.collections, 'monitorPanels'),
snippets: typedCollection<import('../models').Snippet>(materialized.collections, 'snippets'),
customGroups: materialized.stringCollections.customGroups ?? [],
snippetPackages: materialized.stringCollections.snippetPackages ?? [],
-88
View File
@@ -1,88 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
SK_ECDSA_NISTP256,
SK_SSH_ED25519,
decodeOpenSshPrivateKeyBody,
detectFidoSshKeyType,
extractOpenSshPublicKeyType,
isSkPrivateKey,
isSkPublicKey,
requiresFidoSshAgentAuth,
} from "./fidoSsh.ts";
const skEdPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
const skEcdsaPub =
"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
test("extractOpenSshPublicKeyType recognizes sk algorithms", () => {
assert.equal(extractOpenSshPublicKeyType(skEdPub), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaPub), SK_ECDSA_NISTP256);
assert.equal(extractOpenSshPublicKeyType("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), "ssh-ed25519");
assert.equal(isSkPublicKey(skEdPub), true);
assert.equal(isSkPublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), false);
});
test("extractOpenSshPublicKeyType recognizes OpenSSH sk certificate algorithms", () => {
const skEdCert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user@fido";
const skEcdsaCert =
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAA user@fido";
assert.equal(extractOpenSshPublicKeyType(skEdCert), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaCert), SK_ECDSA_NISTP256);
assert.equal(isSkPublicKey(skEdCert), true);
assert.equal(detectFidoSshKeyType({ publicKey: skEdCert }), "ED25519-SK");
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEcdsaCert }), true);
});
test("detectFidoSshKeyType maps vault and OpenSSH types", () => {
assert.equal(detectFidoSshKeyType({ type: "ED25519-SK" }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEdPub }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEcdsaPub }), "ECDSA-SK");
assert.equal(detectFidoSshKeyType({ type: "ED25519", publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test" }), undefined);
});
/** Real-shaped PEM: sk type only appears after base64 decode (`@` not in b64 alphabet). */
function makeSkPrivatePem(algo: string): string {
const raw = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`);
const body = raw.toString("base64");
// Guard: the PEM text must NOT contain the plain algorithm (skeptic regression).
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("isSkPrivateKey detects OpenSSH sk key handles", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
assert.equal(isSkPrivateKey(pem), true);
assert.equal(isSkPrivateKey("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----"), false);
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEdPub }), true);
assert.equal(requiresFidoSshAgentAuth({ privateKey: pem }), true);
assert.equal(requiresFidoSshAgentAuth({ type: "ED25519", privateKey: "soft-key" }), false);
});
test("detectFidoSshKeyType reads sk algorithm from base64-only private PEM", () => {
const edPem = makeSkPrivatePem(SK_SSH_ED25519);
const ecPem = makeSkPrivatePem(SK_ECDSA_NISTP256);
assert.equal(detectFidoSshKeyType({ privateKey: edPem }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ privateKey: ecPem }), "ECDSA-SK");
});
test("decodeOpenSshPrivateKeyBody works without Node Buffer (renderer)", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const originalBuffer = globalThis.Buffer;
// @ts-expect-error intentional renderer simulation
delete globalThis.Buffer;
try {
assert.equal(typeof globalThis.Buffer, "undefined");
const decoded = decodeOpenSshPrivateKeyBody(pem);
assert.ok(decoded);
assert.equal(decoded.includes(SK_SSH_ED25519), true);
assert.equal(detectFidoSshKeyType({ privateKey: pem }), "ED25519-SK");
} finally {
globalThis.Buffer = originalBuffer;
}
});
-137
View File
@@ -1,137 +0,0 @@
/**
* OpenSSH FIDO2 / U2F security-key (sk-*) helpers.
*
* Netcatty does not drive CTAP itself. SK keys authenticate through the system
* OpenSSH agent + ssh-sk-helper (libfido2). These helpers detect sk material so
* the app can force the agent path instead of software privateKey signing.
*/
export const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
export const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
export type SkPublicKeyType = typeof SK_SSH_ED25519 | typeof SK_ECDSA_NISTP256;
export type FidoSshKeyType = "ED25519-SK" | "ECDSA-SK";
const SK_PUBLIC_KEY_TYPES = new Set<string>([SK_SSH_ED25519, SK_ECDSA_NISTP256]);
const OPENSSH_PRIVATE_KEY_RE =
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
export const isSkPublicKeyType = (type: string | undefined | null): type is SkPublicKeyType =>
typeof type === "string" && SK_PUBLIC_KEY_TYPES.has(type.trim());
export const isFidoSshVaultKeyType = (
type: string | undefined | null,
): type is FidoSshKeyType => type === "ED25519-SK" || type === "ECDSA-SK";
/** Parse the algorithm field from an OpenSSH authorized_keys / .pub line. */
export const extractOpenSshPublicKeyType = (
publicKey: string | undefined | null,
): string | undefined => {
if (typeof publicKey !== "string") return undefined;
const line = publicKey
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry && !entry.startsWith("#"));
if (!line) return undefined;
// OpenSSH certificate algorithms embed `-cert-v01` *before* `@openssh.com`
// (e.g. `sk-ssh-ed25519-cert-v01@openssh.com`), not after the RP id.
const skCert = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256))-cert-v0[01]@openssh\.com\s+/.exec(line);
if (skCert) return `${skCert[1]}@openssh.com`;
const match = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh\.com)?\s+/.exec(line);
return match?.[1];
};
export const isSkPublicKey = (publicKey: string | undefined | null): boolean =>
isSkPublicKeyType(extractOpenSshPublicKeyType(publicKey));
/**
* Renderer-safe base64 decode. Avoids Node `Buffer`, which is unavailable in
* Electron windows with `nodeIntegration: false`.
*/
const decodeBase64ToBinaryString = (payload: string): string | null => {
const normalized = payload.replace(/\s+/g, "");
if (!normalized) return null;
try {
if (typeof atob === "function") {
return atob(normalized);
}
} catch {
// fall through to Node Buffer when present (tests / main)
}
if (typeof Buffer !== "undefined") {
try {
return Buffer.from(normalized, "base64").toString("binary");
} catch {
return null;
}
}
return null;
};
/**
* Decode OpenSSH private-key PEM body (base64) for algorithm probing.
* Real sk-* handles only contain `sk-*-@openssh.com` *inside* the base64 payload
* (`@` is not a base64 alphabet character), so raw-text regex on PEM is insufficient.
*/
export const decodeOpenSshPrivateKeyBody = (
privateKey: string | undefined | null,
): string | null => {
if (typeof privateKey !== "string" || !privateKey.trim()) return null;
const match = OPENSSH_PRIVATE_KEY_RE.exec(privateKey);
if (!match) return null;
return decodeBase64ToBinaryString(match[1]);
};
const skTypeInText = (text: string | undefined | null): FidoSshKeyType | undefined => {
if (!text) return undefined;
if (text.includes(SK_ECDSA_NISTP256) || /ecdsa-sk|sk-ecdsa/i.test(text)) return "ECDSA-SK";
if (text.includes(SK_SSH_ED25519) || /ed25519-sk|sk-ssh-ed25519/i.test(text)) return "ED25519-SK";
return undefined;
};
/**
* Best-effort detection of an OpenSSH sk-* private key (handle) blob.
* Decodes the base64 body and looks for the algorithm string.
*/
export const isSkPrivateKey = (privateKey: string | undefined | null): boolean => {
if (typeof privateKey !== "string" || !privateKey.trim()) return false;
const decoded = decodeOpenSshPrivateKeyBody(privateKey);
if (decoded) {
return decoded.includes(SK_SSH_ED25519) || decoded.includes(SK_ECDSA_NISTP256);
}
// Some exporters only put the type on a comment line / nearby text.
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(privateKey);
};
export const detectFidoSshKeyType = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): FidoSshKeyType | undefined => {
if (isFidoSshVaultKeyType(args.type)) return args.type;
const pubType = extractOpenSshPublicKeyType(args.publicKey);
if (pubType === SK_SSH_ED25519) return "ED25519-SK";
if (pubType === SK_ECDSA_NISTP256) return "ECDSA-SK";
if (isSkPrivateKey(args.privateKey)) {
const decoded = decodeOpenSshPrivateKeyBody(args.privateKey);
return skTypeInText(decoded) || skTypeInText(args.privateKey) || "ED25519-SK";
}
return undefined;
};
/** True when vault/host material must authenticate via agent + hardware helper. */
export const requiresFidoSshAgentAuth = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): boolean => Boolean(detectFidoSshKeyType(args) || isSkPublicKey(args.publicKey) || isSkPrivateKey(args.privateKey));
export const fidoSshKeyTypeToOpenSsh = (type: FidoSshKeyType): SkPublicKeyType =>
type === "ECDSA-SK" ? SK_ECDSA_NISTP256 : SK_SSH_ED25519;
export const openSshSkTypeToVaultKeyType = (type: string | undefined | null): FidoSshKeyType | undefined => {
if (type === SK_SSH_ED25519) return "ED25519-SK";
if (type === SK_ECDSA_NISTP256) return "ECDSA-SK";
return undefined;
};
+48 -2
View File
@@ -1,5 +1,3 @@
import { detectVendorFromSshVersion } from './sshServerVendor.mjs';
export { detectVendorFromSshVersion } from './sshServerVendor.mjs';
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
@@ -154,7 +152,55 @@ export const normalizeDistroId = (value?: string) => {
* Arista EOS, all of which either are POSIX systems or present as
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
if (/^Cisco[-_]/i.test(s)) return 'cisco';
if (/^CiscoIOS_/i.test(s)) return 'cisco';
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
// to a specific vendor icon from the banner alone. Users who want a
// custom icon for such devices can set one via the Host Details
// manual distro override. The stats-polling gate is still handled
// correctly via `host.deviceType === 'network'`.
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
// by the fallback failure-counter path in useServerStats).
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (s === '-') return 'huawei';
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
// H3C / 3Com Comware switches
if (/^H3C[-_\s]/i.test(s)) return 'h3c';
if (/^Comware-/i.test(s)) return 'h3c';
if (/^3Com\s*OS/i.test(s)) return 'h3c';
// HPE iLO
if (/^mpSSH_/i.test(s)) return 'hpe';
// MikroTik RouterOS
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
// Fortinet FortiOS / FortiGate
if (/^FortiSSH_/i.test(s)) return 'fortinet';
// Palo Alto Networks PAN-OS
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
// Ruijie RGOS
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
return '';
};
/**
* Classify a distro/vendor ID into a high-level device class. Features that
-3
View File
@@ -9,7 +9,6 @@
*/
import type { KeyType } from './models';
import { detectFidoSshKeyType } from './fidoSsh';
export interface SshKeyTypeInfo {
type: KeyType;
@@ -155,8 +154,6 @@ export const detectSshKeyType = (
privateKey?: string | null,
publicKey?: string | null,
): SshKeyTypeInfo => {
const fidoType = detectFidoSshKeyType({ privateKey, publicKey });
if (fidoType) return { type: fidoType };
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
if (fromPrivate) return fromPrivate;
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
+1 -2
View File
@@ -313,8 +313,7 @@ export interface Host {
order?: number;
}
/** Software keys plus OpenSSH FIDO2 security-key types (agent + hardware only). */
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
type KeySource = 'generated' | 'imported' | 'reference';
export type KeyCategory = 'key' | 'certificate' | 'identity';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
-46
View File
@@ -1,46 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "./monitorChart";
import type { MonitorPoint } from "./monitorRuntime";
const points: MonitorPoint[] = [
{ at: 1000, cpu: 12.5, memory: 40.123, disk: null, rx: null, tx: null },
{ at: 2000, cpu: null, memory: 50, disk: null, rx: null, tx: null },
{ at: 3000, cpu: 80, memory: null, disk: null, rx: null, tx: null },
];
test("chart hit testing returns the exact sampled value and timestamp", () => {
assert.equal(findMonitorChartPoint(points, "cpu", 2900, 300), points[2]);
assert.equal(findMonitorChartPoint(points, "memory", 1100, 300), points[0]);
});
test("chart hit testing does not fabricate readings in missing or distant history", () => {
assert.equal(findMonitorChartPoint(points, "cpu", 2000, 300), null);
assert.equal(findMonitorChartPoint(points, "cpu", -5000, 300), null);
assert.equal(findMonitorChartPoint([], "cpu", 1000, 300), null);
});
test("full histories fit the chart budget while extrema and raw hover samples survive", () => {
const end = 1800000;
const samples = Array.from({ length: 1800 }, (_, i) => ({ ...points[0], at: (i + 1) * 1000, cpu: 50 }));
samples[700].cpu = 100;
samples[701].cpu = 0;
const geometry = buildMonitorTrendGeometry(samples, "cpu", 30, 1, end);
assert.equal(geometry.segments.length, 1);
const path = geometry.segments[0].path;
assert.ok(path.split(/ [ML] /).length <= 302);
assert.ok(path.includes(" 4.00"));
assert.ok(path.includes(" 58.00"));
assert.equal(geometry.visible.length, 1800);
assert.equal(findMonitorChartPoint(geometry.visible, "cpu", samples[699].at, 0), samples[699]);
assert.equal(samples.length, 1800);
});
test("chart reduction preserves missing samples and cadence gaps", () => {
const geometry = buildMonitorTrendGeometry([
...points,
{ ...points[0], at: 20000 },
{ ...points[0], at: 21000, cpu: Infinity },
{ ...points[0], at: 22000 },
], "cpu", 1, 1, 22000);
assert.equal(geometry.segments.length, 4);
assert.ok(geometry.segments.every((segment) => !segment.path.includes(" L ")));
});
-76
View File
@@ -1,76 +0,0 @@
import type { MonitorPoint } from "./monitorRuntime";
export type MonitorTrendField = "cpu" | "memory" | "rx" | "tx";
/** Keep extrema within each two-pixel column; hit testing still uses raw samples. */
export function buildMonitorTrendGeometry(
points: MonitorPoint[], field: MonitorTrendField, range: number, interval: number, end: number,
) {
const start = end - range * 60000;
const visible = points.filter((p) => p.at >= start && p.at <= end);
const max = field === "cpu" || field === "memory" ? 100
: Math.max(1, ...visible.map((p) => Number.isFinite(p[field]) ? p[field]! : 0));
const runs: MonitorPoint[][] = [];
let run: MonitorPoint[] = [];
for (const p of visible) {
if (p[field] == null || !Number.isFinite(p[field])) {
if (run.length) runs.push(run);
run = [];
continue;
}
if (run.length && p.at - run[run.length - 1].at > interval * 2500) {
runs.push(run);
run = [];
}
run.push(p);
}
if (run.length) runs.push(run);
const segments = runs.map((samples) => {
const selected = new Set([samples[0], samples[samples.length - 1]]);
const columns = new Map<number, { min: MonitorPoint; max: MonitorPoint }>();
for (const p of samples) {
const column = Math.min(149, Math.floor(((p.at - start) / (end - start)) * 150));
const extremes = columns.get(column);
if (!extremes) columns.set(column, { min: p, max: p });
else {
if (p[field]! < extremes.min[field]!) extremes.min = p;
if (p[field]! > extremes.max[field]!) extremes.max = p;
}
}
for (const extremes of columns.values()) {
selected.add(extremes.min);
selected.add(extremes.max);
}
const coordinates = samples.filter((p) => selected.has(p)).map((p) => ({
x: Math.max(0, Math.min(300, ((p.at - start) / (end - start)) * 300)),
y: 58 - (Math.min(max, Math.max(0, p[field]!)) / max) * 54,
}));
const last = coordinates[coordinates.length - 1];
return {
path: coordinates.map((p, i) => `${i ? "L" : "M"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" "),
firstX: coordinates[0].x, x: last.x, y: last.y,
};
});
return { start, end, visible, max, segments };
}
/** Hit actual samples within cursor tolerance, never an interpolated reading. */
export function findMonitorChartPoint(
points: MonitorPoint[],
field: MonitorTrendField,
at: number,
tolerance: number,
): MonitorPoint | null {
let closest: MonitorPoint | null = null;
let distance = tolerance;
for (const point of points) {
const value = point[field];
if (value == null || !Number.isFinite(value)) continue;
const delta = Math.abs(point.at - at);
if (delta <= distance) {
closest = point;
distance = delta;
}
}
return closest;
}
-173
View File
@@ -1,173 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
normalizeMonitorPanels,
rebaseMonitorPanel,
type MonitorPanel,
MONITOR_INTERVALS,
reorderMonitorHosts,
} from "./monitorPanels";
import { mergeSyncPayloads } from "./syncMerge";
import type { SyncPayload } from "./sync";
import {
applyLegacySyncPayload,
createConvergentSyncState,
materializeSyncPayloadFromConvergentState,
mergeConvergentSyncStates,
} from "./convergentSync";
const panel: MonitorPanel = {
id: "p1",
name: "Production",
description: "",
hostIds: ["h2", "h1"],
view: "charts",
interval: 10,
range: 15,
sort: "manual",
};
const payload = (panels?: MonitorPanel[]): SyncPayload => ({
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 1,
...(panels === undefined ? {} : { monitorPanels: panels }),
});
test("drag ordering moves either direction without mutating saved references", () => {
const saved = ["c", "a", "b", "hidden"];
assert.deepEqual(reorderMonitorHosts(saved, "c", "b"), ["a", "b", "c", "hidden"]);
assert.deepEqual(reorderMonitorHosts(saved, "b", "c"), ["b", "c", "a", "hidden"]);
assert.deepEqual(saved, ["c", "a", "b", "hidden"]);
assert.equal(reorderMonitorHosts(saved, "missing", "b"), saved);
assert.equal(reorderMonitorHosts(saved, "a", "a"), saved);
});
test("monitor intervals include each supported cadence and default to five seconds", () => {
assert.deepEqual(MONITOR_INTERVALS, [1, 2, 3, 5, 10, 30, 60]);
for (const interval of MONITOR_INTERVALS)
assert.equal(normalizeMonitorPanels([{ ...panel, interval }])[0].interval, interval);
assert.equal(normalizeMonitorPanels([{ ...panel, interval: undefined }])[0].interval, 5);
});
test("portable panel sanitizer strips all runtime data and preserves ordered references", () => {
const clean = normalizeMonitorPanels([
{
...panel,
hostIds: ["h2", "h1", "h2"],
stats: { cpu: 42 },
points: [1],
running: true,
error: "secret",
},
]);
assert.deepEqual(clean, [panel]);
assert.equal(JSON.stringify(clean).includes("secret"), false);
});
test("concurrent local edits preserve unrelated fields and deletion does not resurrect", () => {
const renamed = { ...panel, name: "New name" };
const changed = { ...panel, view: "list" as const, hostIds: ["h3", "h2"] };
assert.deepEqual(rebaseMonitorPanel([renamed], panel, changed), [
{ ...renamed, ...changed, name: renamed.name },
]);
assert.throws(() => rebaseMonitorPanel([], panel, changed), /deleted/);
assert.throws(
() => rebaseMonitorPanel([panel], null, { ...panel, id: "p2" }),
/duplicateName/,
);
});
test("three-way sync merges panel fields, preserves legacy omission and propagates deletion", () => {
const base = payload([panel]);
const local = payload([{ ...panel, name: "Renamed" }]);
const remote = payload([{ ...panel, hostIds: ["h3"], view: "list" }]);
assert.deepEqual(
mergeSyncPayloads(base, local, remote).payload.monitorPanels,
[{ ...panel, name: "Renamed", hostIds: ["h3"], view: "list" }],
);
assert.deepEqual(
mergeSyncPayloads(base, base, payload()).payload.monitorPanels,
[panel],
);
assert.deepEqual(
mergeSyncPayloads(base, base, payload([])).payload.monitorPanels,
[],
);
});
test("convergent replica carries panel creation, edits and deletion", () => {
const empty = payload([]),
initial = payload([panel]);
const state = applyLegacySyncPayload(
createConvergentSyncState(),
empty,
initial,
"a",
1,
);
const left = applyLegacySyncPayload(
state,
initial,
payload([{ ...panel, name: "A" }]),
"a",
2,
);
const right = applyLegacySyncPayload(
state,
initial,
payload([{ ...panel, hostIds: ["h3"], view: "list" }]),
"b",
3,
);
const joined = mergeConvergentSyncStates(left, right);
assert.deepEqual(
materializeSyncPayloadFromConvergentState(joined, { syncedAt: 3 })
.monitorPanels,
[{ ...panel, name: "A", hostIds: ["h3"], view: "list" }],
);
const removed = applyLegacySyncPayload(
joined,
materializeSyncPayloadFromConvergentState(joined, { syncedAt: 3 }),
empty,
"a",
4,
);
assert.deepEqual(
materializeSyncPayloadFromConvergentState(
mergeConvergentSyncStates(removed, right),
{ syncedAt: 4 },
).monitorPanels,
[],
);
});
test("independent same-name panels remain distinct after merge/import regardless of order", () => {
const left = { ...panel, id: "a", name: "Fleet" };
const right = { ...panel, id: "b", name: "fleet" };
const existingSuffix = { ...panel, id: "c", name: "Fleet (2)" };
const normalizeById = (panels: MonitorPanel[]) =>
normalizeMonitorPanels(panels).sort((a, b) => a.id.localeCompare(b.id));
const merged = mergeSyncPayloads(
payload([]),
payload([left, existingSuffix]),
payload([right]),
).payload.monitorPanels!;
const clean = normalizeById(merged);
const empty = payload([]);
const l = applyLegacySyncPayload(createConvergentSyncState(), empty, payload([left, existingSuffix]), "a", 1);
const r = applyLegacySyncPayload(createConvergentSyncState(), empty, payload([right]), "b", 1);
const crdt = materializeSyncPayloadFromConvergentState(mergeConvergentSyncStates(l, r), { syncedAt: 2 });
assert.deepEqual(normalizeById(crdt.monitorPanels!), clean);
assert.deepEqual(
clean.map((p) => [p.id, p.name]),
[
["a", "Fleet"],
["b", "fleet (3)"],
["c", "Fleet (2)"],
],
);
assert.deepEqual(normalizeById([...merged].reverse()), clean);
assert.deepEqual(normalizeById(clean), clean);
const long = normalizeMonitorPanels([
left,
{ ...right, name: "x".repeat(120) },
{ ...panel, id: "d", name: "x".repeat(120) },
]);
assert.ok(long.every((p) => p.name.length <= 120));
assert.equal(new Set(long.map((p) => p.name.toLowerCase())).size, 3);
});
-122
View File
@@ -1,122 +0,0 @@
export const MONITOR_INTERVALS = [1, 2, 3, 5, 10, 30, 60] as const;
export const DEFAULT_MONITOR_INTERVAL = 5;
/** Portable configuration only. Never add samples, connection state or errors here. */
export interface MonitorPanel {
id: string;
name: string;
description: string;
hostIds: string[];
view: "list" | "charts";
interval: (typeof MONITOR_INTERVALS)[number];
range: 5 | 15 | 30;
sort: "manual" | "name";
}
export function normalizeMonitorPanels(value: unknown): MonitorPanel[] {
if (!Array.isArray(value)) return [];
const ids = new Set<string>();
const panels = value.flatMap((raw): MonitorPanel[] => {
if (!raw || typeof raw !== "object") return [];
const p = raw as Record<string, unknown>;
if (
typeof p.id !== "string" ||
!p.id ||
ids.has(p.id) ||
typeof p.name !== "string" ||
!p.name.trim()
)
return [];
ids.add(p.id);
return [
{
id: p.id,
name: p.name.trim().slice(0, 120),
description:
typeof p.description === "string" ? p.description.slice(0, 1000) : "",
hostIds: Array.isArray(p.hostIds)
? [
...new Set(
p.hostIds.filter(
(id): id is string => typeof id === "string" && !!id,
),
),
]
: [],
view: p.view === "charts" ? "charts" : "list",
interval: MONITOR_INTERVALS.includes(p.interval as MonitorPanel["interval"])
? p.interval as MonitorPanel["interval"]
: DEFAULT_MONITOR_INTERVAL,
range: p.range === 5 || p.range === 15 ? p.range : 30,
sort: p.sort === "name" ? "name" : "manual",
},
];
});
// Independent devices can create the same name with different IDs. Keep
// every configuration, and choose suffixes by ID so merge order cannot
// change which panel is renamed. Reserve existing names before allocating.
const reserved = new Set(panels.map((p) => p.name.toLowerCase()));
const seenNames = new Set<string>();
const namesById = new Map<string, string>();
for (const p of [...panels].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
)) {
const key = p.name.toLowerCase();
if (!seenNames.has(key)) {
seenNames.add(key);
continue;
}
let suffix = 2;
let name: string;
do {
const ending = ` (${suffix++})`;
name = p.name.slice(0, 120 - ending.length).trimEnd() + ending;
} while (reserved.has(name.toLowerCase()));
reserved.add(name.toLowerCase());
namesById.set(p.id, name);
}
return panels.map((p) =>
namesById.has(p.id) ? { ...p, name: namesById.get(p.id)! } : p,
);
}
/** Move within the displayed ordering, then persist it as the saved ordering. */
export function reorderMonitorHosts(hostIds: string[], from: string, to: string): string[] {
const fromIndex = hostIds.indexOf(from);
const toIndex = hostIds.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return hostIds;
const next = [...hostIds];
next.splice(fromIndex, 1);
next.splice(toIndex, 0, from);
return next;
}
/** Rebase only edited fields onto the latest disk entity; deletion wins over a stale editor. */
export function rebaseMonitorPanel(
latest: MonitorPanel[],
base: MonitorPanel | null,
next: MonitorPanel,
): MonitorPanel[] {
const current = latest.find((p) => p.id === next.id);
if (base && !current) throw new Error("monitor.deleted");
const merged = { ...(current ?? next) };
for (const field of Object.keys(next) as (keyof MonitorPanel)[]) {
if (!base || JSON.stringify(base[field]) !== JSON.stringify(next[field])) {
Object.assign(merged, { [field]: next[field] });
}
}
if (
latest.some(
(p) =>
p.id !== merged.id &&
p.name.toLowerCase() === merged.name.trim().toLowerCase(),
)
) {
throw new Error("monitor.duplicateName");
}
return normalizeMonitorPanels(
current
? latest.map((p) => (p.id === next.id ? merged : p))
: [...latest, merged],
);
}
-93
View File
@@ -1,93 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mergeMonitorSnapshotHistory, monitorStateSince, type MonitorHostState, type MonitorSnapshot } from "./monitorRuntime";
const state: MonitorHostState = {
historyId: "endpoint-a",
status: "live", at: 2000, stats: {
cpu: 50, cpuCores: 4, cpuPerCore: [], memTotal: 100, memUsed: 25,
memFree: 75, memBuffers: 0, memCached: 0, swapTotal: 0, swapUsed: 0,
topProcesses: [], diskPercent: 30, diskUsed: 30, diskTotal: 100, disks: [],
netRxSpeed: 10, netTxSpeed: 20, latencyMs: 1, netInterfaces: [],
},
points: [1000, 2000].map((at) => ({ at, cpu: 50, memory: 25, disk: 30, rx: 10, tx: 20 })),
};
test("starting or changing cadence clears all old readings and chart fields", () => {
const cleared = monitorStateSince(state, 2000)!;
assert.deepEqual(cleared.points, []);
assert.equal(cleared.stats, null);
assert.equal(cleared.at, 0);
assert.equal(cleared.status, "queued");
assert.equal(monitorStateSince(state, undefined), undefined);
});
test("only current-run readings are visible without mutating shared history", () => {
const fresh = monitorStateSince(state, 1500)!;
assert.deepEqual(fresh.points.map((p) => p.at), [2000]);
assert.equal(fresh.stats, state.stats);
assert.equal(fresh.status, "live");
assert.equal(monitorStateSince({ ...state, status: "unsupported" }, 2000)?.status, "unsupported");
assert.equal(monitorStateSince(state, 0)?.points.length, 2);
assert.equal(state.points.length, 2);
assert.equal(monitorStateSince(state, 1500)?.points, fresh.points);
assert.equal(monitorStateSince({ ...state }, 1500)?.points, fresh.points);
assert.notEqual(monitorStateSince(state, 1000)?.points, fresh.points);
});
const snapshot = (host: MonitorHostState): MonitorSnapshot => ({
historyMode: "replace", runtimeId: "worker", epoch: 0, takenAt: 3000,
running: ["p"], startedAt: { p: 0 }, count: 1, hostCount: 1, activeSessions: [], hosts: { h: host },
});
test("incremental snapshots retain history, deduplicate races and replace on worker reset", () => {
const original = snapshot(state);
const point = { ...state.points[1], at: 3000 };
const delta: MonitorSnapshot = { ...snapshot({ ...state, points: [state.points[1], point] }), historyMode: "append", takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(original, delta);
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [1000, 2000, 3000]);
assert.deepEqual(mergeMonitorSnapshotHistory(merged, delta).hosts.h.points, merged.hosts.h.points);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...original, takenAt: 2000 }), merged);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, epoch: 1 }).hosts.h.points.length, 2);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, historyMode: "replace" }).hosts.h.points.length, 2);
assert.equal(original.hosts.h.points.length, 2);
});
test("incremental history expires after thirty minutes and remains bounded", () => {
const points = Array.from({ length: 1800 }, (_, i) => ({ ...state.points[0], at: 10000 + i * 1000 }));
const previous = snapshot({ ...state, points });
const next = { ...snapshot({ ...state, points: [{ ...points.at(-1)!, at: 1810000 }] }), historyMode: "append" as const, takenAt: 1810000 };
const merged = mergeMonitorSnapshotHistory(previous, next);
assert.equal(merged.hosts.h.points.length, 1800);
assert.equal(merged.hosts.h.points[0].at, 11000);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...next, takenAt: 4000000 }).hosts.h.points.length, 0);
});
test("delayed replies cannot restore a stopped epoch or retired worker", () => {
const stopped: MonitorSnapshot = { ...snapshot(state), epoch: 1, takenAt: 4000, running: [], hosts: {}, startedAt: {} };
const old = { ...snapshot(state), historyMode: "append" as const };
assert.equal(mergeMonitorSnapshotHistory(stopped, old), stopped);
assert.equal(mergeMonitorSnapshotHistory(stopped, { ...old, takenAt: 5000 }), stopped);
const restarted = { ...stopped, runtimeId: "new-worker", epoch: 0 };
assert.equal(mergeMonitorSnapshotHistory(restarted, old), restarted);
const fresh = { ...restarted, runtimeId: "next-worker", takenAt: 5000 };
assert.equal(mergeMonitorSnapshotHistory(restarted, fresh), fresh);
});
test("a new endpoint replaces retained points even when its first delta races a configuration response", () => {
const original = snapshot(state);
const changed: MonitorSnapshot = { ...snapshot({ ...state, historyId: "endpoint-b", points: [{ ...state.points[0], at: 2500 }] }), historyMode: "append", takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(original, changed);
assert.deepEqual(merged.hosts.h.points.map((point) => point.at), [2500]);
assert.equal(merged.hosts.h.historyId, "endpoint-b");
});
test("unchanged stats are retained by reference while fresh, cleared and new-endpoint stats replace them", () => {
const previous = snapshot(state);
const delta = { ...snapshot({ ...state, stats: undefined, points: [] }), historyMode: "append" as const, takenAt: 4000 };
const merged = mergeMonitorSnapshotHistory(previous, delta);
assert.equal(merged.hosts.h.stats, state.stats);
assert.equal(merged.hosts.h.points, state.points);
assert.equal(monitorStateSince(merged.hosts.h, 0), merged.hosts.h);
assert.deepEqual(merged.hosts.h.points, state.points);
const fresh = { ...state.stats!, cpu: 67 };
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: fresh, points: [] } } }).hosts.h.stats, fresh);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, stats: null, points: [] } } }).hosts.h.stats, null);
assert.equal(mergeMonitorSnapshotHistory(merged, { ...delta, hosts: { h: { ...state, historyId: "endpoint-b", stats: null, points: [] } } }).hosts.h.stats, null);
});
-115
View File
@@ -1,115 +0,0 @@
import type { MonitorPanel } from "./monitorPanels";
export type MonitorStats = NonNullable<
Awaited<ReturnType<NonNullable<NetcattyBridge["getServerStats"]>>>["stats"]
> & { networkReady?: boolean };
export type MonitorPoint = {
at: number;
cpu: number | null;
memory: number | null;
disk: number | null;
rx: number | null;
tx: number | null;
};
export type MonitorHostState = {
historyId: string;
status:
| "queued"
| "connecting"
| "live"
| "login"
| "error"
| "unsupported"
| "disconnected"
| "limit";
at: number;
/** Omitted by incremental replies when the receiver already has this sample. */
stats?: MonitorStats | null;
points: MonitorPoint[];
};
export type MonitorSnapshot = {
historyMode: "replace" | "append";
running: string[];
count: number;
hostCount: number;
epoch: number;
runtimeId: string;
takenAt: number;
activeSessions: string[];
startedAt: Record<string, number>;
unavailable?: boolean;
hosts: Record<string, MonitorHostState>;
};
export type MonitorHistoryCursor = {
runtimeId: string;
epoch: number;
after: Record<string, { at: number; statsAt: number; historyId: string }>;
};
/** Incremental transport keeps the full bounded history in the renderer. */
export function mergeMonitorSnapshotHistory(previous: MonitorSnapshot, next: MonitorSnapshot): MonitorSnapshot {
if (previous.runtimeId !== next.runtimeId) {
// An old worker reply can still be in transit after its replacement starts.
if (previous.runtimeId && next.runtimeId && next.takenAt <= previous.takenAt) return previous;
return next;
}
if (next.epoch < previous.epoch) return previous;
if (next.epoch > previous.epoch) return next;
if (next.takenAt < previous.takenAt) return previous;
if (next.historyMode === "replace") return next;
return {
...next,
hosts: Object.fromEntries(Object.entries(next.hosts).map(([id, host]) => {
if (previous.hosts[id]?.historyId !== host.historyId) return [id, host];
const retained = previous.hosts[id]?.points ?? [];
const last = retained.at(-1)?.at ?? 0;
const added = host.points.filter((point) => point.at > last);
const points = added.length || (retained[0] && next.takenAt - retained[0].at > 30 * 60000)
? [...retained, ...added].filter((point) => next.takenAt - point.at <= 30 * 60000).slice(-1800)
: retained;
return [id, { ...host, stats: host.stats === undefined ? previous.hosts[id]?.stats : host.stats,
points }];
})),
};
}
const filteredHistories = new WeakMap<MonitorPoint[], Map<number, MonitorPoint[]>>();
function pointsSince(points: MonitorPoint[], startedAt: number) {
if (!points.length || points[0].at > startedAt) return points;
let cache = filteredHistories.get(points);
if (!cache) {
cache = new Map();
filteredHistories.set(points, cache);
}
let filtered = cache.get(startedAt);
if (!filtered) {
filtered = points.filter((point) => point.at > startedAt);
cache.set(startedAt, filtered);
if (cache.size > 32) cache.delete(cache.keys().next().value!);
}
return filtered;
}
/** A panel only displays samples collected during its current cadence/run. */
export function monitorStateSince(state: MonitorHostState | undefined, startedAt: number | undefined): MonitorHostState | undefined {
if (!state || typeof startedAt !== "number") return undefined;
const fresh = state.at > startedAt;
if (fresh && (!state.points.length || state.points[0].at > startedAt)) return state;
return {
...state,
at: fresh ? state.at : 0,
stats: fresh ? state.stats : null,
status: !fresh && state.status === "live" ? "queued" : state.status,
points: pointsSince(state.points, startedAt),
};
}
export type MonitorPanelDemand = {
id: string;
runId: string;
interval: MonitorPanel["interval"];
hosts: {
id: string;
options?: NetcattySSHOptions;
status?: "login" | "error";
}[];
};
-109
View File
@@ -99,36 +99,6 @@ test("resolveBridgeSshAgentAuth carries system agent settings without private ma
);
});
test("resolveBridgeSshAgentAuth forces agent auth for FIDO2 SK vault keys", () => {
const skKey: SSHKey = {
id: "sk-1",
label: "YubiKey",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi",
source: "imported",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skKey, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skKey.publicKey!],
},
);
assert.equal(
resolveBridgeKeyAuth({ key: skKey }).privateKey?.includes("OPENSSH PRIVATE KEY"),
true,
);
});
test("resolveBridgeSshAgentAuth keeps certificate authentication independent", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
@@ -139,85 +109,6 @@ test("resolveBridgeSshAgentAuth keeps certificate authentication independent", (
);
});
test("resolveBridgeSshAgentAuth keeps agent path for FIDO SK certificates", () => {
const skCertKey: SSHKey = {
id: "sk-cert-1",
label: "YubiKey cert",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skCertKey.publicKey!],
},
);
});
test("resolveBridgeSshAgentAuth selects staged SK certificate under IdentitiesOnly", () => {
const barePub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi";
const cert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi";
const skCertKey: SSHKey = {
id: "sk-cert-bare-1",
label: "YubiKey cert staged",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: barePub,
certificate: cert,
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [barePub, cert],
},
);
});
test("resolveBridgeSshAgentAuth forces agent for path-only SK vault type", () => {
const skRef: SSHKey = {
id: "sk-ref-1",
label: "YubiKey file",
type: "ED25519-SK",
source: "reference",
filePath: "/home/user/.ssh/id_ed25519_sk",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skRef, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
},
);
});
test("resolveBridgeSshAgentAuth forwards a selected vault public key", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
+10 -91
View File
@@ -2,7 +2,6 @@ import type { GroupConfig, Host, HostAuthMethod, Identity, SSHKey } from "./mode
import { sanitizeCredentialValue } from "./credentials";
import { applyGroupDefaults } from "./groupConfig";
import { isSshAgentNoneValue } from "./sshAgentSettings";
import { requiresFidoSshAgentAuth } from "./fidoSsh";
type HostAuthOverride = {
authMethod?: HostAuthMethod;
@@ -241,30 +240,10 @@ export const resolveBridgeKeyAuth = (args: {
passphrase?: string;
} => {
const { key, fallbackIdentityFilePaths, passphrase } = args;
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const identityFilePaths = key?.source === "reference" && key.filePath
? [key.filePath]
: fallbackIdentityFilePaths;
// SK private material is an OpenSSH key *handle*. Pass it through so the
// main process can load it into the system agent; never use it for local
// software signing (startSession routes SK handles to ssh-add).
if (isFidoSk) {
return {
privateKey: key?.source === "reference"
? undefined
: sanitizeCredentialValue(key?.privateKey),
identityFilePaths: key?.source === "reference" && key.filePath
? [key.filePath]
: identityFilePaths,
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
};
}
return {
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
identityFilePaths,
@@ -272,23 +251,9 @@ export const resolveBridgeKeyAuth = (args: {
};
};
/** Preferred agent identity blobs for IdentitiesOnly selection. */
const resolveAgentPublicKeys = (
key?: Pick<SSHKey, "publicKey" | "certificate">,
): string[] | undefined => {
const keys: string[] = [];
const publicKey = key?.publicKey?.trim();
const certificate = key?.certificate?.trim();
if (publicKey) keys.push(publicKey);
// ssh-add -L lists the bare key and certificate as distinct identities; when
// the vault stores them separately, both must be selectable under IdentitiesOnly.
if (certificate && certificate !== publicKey) keys.push(certificate);
return keys.length > 0 ? keys : undefined;
};
export const resolveBridgeSshAgentAuth = (
host: Pick<Host, "authMethod" | "useSshAgent" | "identityAgent" | "identityFilePaths" | "identitiesOnly" | "addKeysToAgent" | "useKeychain" | "agentForwarding">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath" | "type" | "privateKey">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath">,
authMethod?: HostAuthMethod,
): {
useSshAgent?: boolean;
@@ -303,70 +268,25 @@ export const resolveBridgeSshAgentAuth = (
&& !isSshAgentNoneValue(host.identityAgent)
? { identityAgent: host.identityAgent }
: {};
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim())
// Inline imported sk private key handle still needs agent; selection uses
// the public key when present, otherwise the agent is used non-strictly.
|| (isFidoSk && key?.privateKey?.trim()),
);
// FIDO2 SK keys always require the system agent (hardware signs via
// ssh-sk-helper), whether selected explicitly or via a CA certificate
// wrapping the SK public key.
const fidoAgentAuth = () => {
if (!hasAgentSelector && !key?.privateKey?.trim()) {
return { useSshAgent: false };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: Boolean(key?.publicKey?.trim() || (key?.source === "reference" && key.filePath)),
addKeysToAgent: host.addKeysToAgent ?? "yes",
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
};
};
if (authMethod === "password") {
if (authMethod === "password" || authMethod === "certificate" || key?.certificate?.trim()) {
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "certificate" || key?.certificate?.trim()) {
// A certificate over an SK public key still needs the agent for hardware
// signing; only non-FIDO certificate auth bypasses the agent entirely.
if (isFidoSk) {
return fidoAgentAuth();
}
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "key" || isFidoSk) {
// Explicit key auth without agent only works for soft keys; FIDO SK
// material is routed through the agent regardless of authMethod.
if (isFidoSk) {
return fidoAgentAuth();
}
if (authMethod === "key") {
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim()),
);
if (host.useSshAgent !== true || !hasAgentSelector) {
return { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: true,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
}
if (host.useSshAgent !== true) {
@@ -374,14 +294,13 @@ export const resolveBridgeSshAgentAuth = (
? forwardingAgent
: { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: host.identitiesOnly,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
};
-1
View File
@@ -1 +0,0 @@
export function detectVendorFromSshVersion(softwareVersion?: string): '' | import('./host').NetworkDeviceVendor;
-50
View File
@@ -1,50 +0,0 @@
// Shared banner rules for renderer capabilities and headless SSH monitoring.
export const detectVendorFromSshVersion = (softwareVersion) => {
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
if (/^Cisco[-_]/i.test(s)) return 'cisco';
if (/^CiscoIOS_/i.test(s)) return 'cisco';
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
// to a specific vendor icon from the banner alone. Users who want a
// custom icon for such devices can set one via the Host Details
// manual distro override. The stats-polling gate is still handled
// correctly via `host.deviceType === 'network'`.
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
// by the fallback failure-counter path in useServerStats).
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (s === '-') return 'huawei';
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
// H3C / 3Com Comware switches
if (/^H3C[-_\s]/i.test(s)) return 'h3c';
if (/^Comware-/i.test(s)) return 'h3c';
if (/^3Com\s*OS/i.test(s)) return 'h3c';
// HPE iLO
if (/^mpSSH_/i.test(s)) return 'hpe';
// MikroTik RouterOS
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
// Fortinet FortiOS / FortiGate
if (/^FortiSSH_/i.test(s)) return 'fortinet';
// Palo Alto Networks PAN-OS
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
// Ruijie RGOS
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
return '';
};
-4
View File
@@ -282,8 +282,6 @@ export interface SyncPayload {
notes?: import('./models').VaultNote[];
noteGroups?: string[];
monitorPanels?: import('./monitorPanels').MonitorPanel[];
// Group configs (connection defaults per host group)
groupConfigs?: import('./models').GroupConfig[];
@@ -485,7 +483,6 @@ export const SYNC_PAYLOAD_ENTITY_KEYS = [
'browserBookmarks',
'knownHosts',
'groupConfigs',
'monitorPanels',
] as const;
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
@@ -501,7 +498,6 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'monitorPanels',
'aiSessions',
] as const;
+7 -23
View File
@@ -71,8 +71,6 @@ export interface MergeSyncPayloadsOptions {
interface EntityMergeOptions {
preserveDeletions?: boolean;
mergeFields?: boolean;
deleteWins?: boolean;
}
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
@@ -84,7 +82,6 @@ const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'monitorPanels',
'aiSessions',
]);
@@ -202,20 +199,10 @@ function mergeEntityArrays<T extends { id: string }>(
merged.push(remoteItem);
modified.remote++;
} else {
if (options?.mergeFields) {
const combined = { ...remoteItem };
let conflicted = false;
for (const field of Object.keys(localItem) as (keyof T)[]) {
const localChanged = fingerprint(localItem[field]) !== fingerprint(baseItem[field]);
const remoteChanged = fingerprint(remoteItem[field]) !== fingerprint(baseItem[field]);
if (localChanged) combined[field] = localItem[field];
if (localChanged && remoteChanged && fingerprint(localItem[field]) !== fingerprint(remoteItem[field])) conflicted = true;
}
merged.push(combined);
if (conflicted) conflicts++;
} else {
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) conflicts++;
// Both changed — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
modified.local++;
modified.remote++;
@@ -223,7 +210,7 @@ function mergeEntityArrays<T extends { id: string }>(
} else if (inBase && !inLocal && inRemote) {
// Local deleted
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (remoteChanged && !options?.deleteWins) {
if (remoteChanged) {
// Remote modified + local deleted → keep modification (safer)
merged.push(remoteItem);
conflicts++;
@@ -239,7 +226,7 @@ function mergeEntityArrays<T extends { id: string }>(
} else if (inBase && inLocal && !inRemote) {
// Remote deleted
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
if (localChanged && !options?.deleteWins) {
if (localChanged) {
// Local modified + remote deleted → keep modification (safer)
merged.push(localItem);
conflicts++;
@@ -857,8 +844,6 @@ export function mergeSyncPayloads(
mergeOptions,
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? [], tombstones('snippets'), mergeOptions);
const baseMonitorPanels = b.monitorPanels ?? [];
const monitorPanels = mergeEntityArrays(baseMonitorPanels, entityArray(local, 'monitorPanels', baseMonitorPanels), entityArray(remote, 'monitorPanels', baseMonitorPanels), tombstones('monitorPanels'), { ...mergeOptions, mergeFields: true, deleteWins: true });
const baseNotes = b.notes ?? [];
const notes = mergeEntityArrays(
baseNotes,
@@ -910,7 +895,7 @@ export function mergeSyncPayloads(
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, proxyProfiles, snippets, monitorPanels, notes, portForwardingRules, browserBookmarks, groupConfigsResult, aiSessions];
[hosts, keys, identities, proxyProfiles, snippets, notes, portForwardingRules, browserBookmarks, groupConfigsResult, aiSessions];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -1001,7 +986,6 @@ export function mergeSyncPayloads(
identities: identities.merged,
proxyProfiles: proxyProfiles.merged,
snippets: snippets.merged,
monitorPanels: monitorPanels.merged,
customGroups,
snippetPackages,
notes: notes.merged,
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more