feat: graduate Host Console to Community admins (#1669)

* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
This commit is contained in:
Anso
2026-07-23 12:59:53 -04:00
committed by GitHub
parent ed5ca9c4f6
commit dd54a2e483
43 changed files with 1230 additions and 199 deletions
+13
View File
@@ -27,6 +27,7 @@ interface AuditEntry {
node_id: number | null;
ip_address: string;
summary: string;
acting_as?: string | null;
flags?: AnomalyFlag[];
}
@@ -464,6 +465,11 @@ export function AuditLogView({ headerActions }: AuditLogViewProps = {}) {
</TableCell>
<TableCell className="font-medium text-sm">
{entry.username}
{entry.acting_as ? (
<span className="block text-xs text-muted-foreground font-normal">
acting as {entry.acting_as}
</span>
) : null}
</TableCell>
<TableCell>
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
@@ -492,6 +498,10 @@ export function AuditLogView({ headerActions }: AuditLogViewProps = {}) {
<span className="text-muted-foreground text-xs block">IP Address</span>
<span className="font-mono text-xs tabular-nums">{entry.ip_address || '-'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Acting as</span>
<span className="font-mono text-xs">{entry.acting_as || '-'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Node ID</span>
<span className="font-mono text-xs tabular-nums">{entry.node_id ?? 'Local'}</span>
@@ -673,6 +683,9 @@ function StreamRow({ entry, now }: StreamRowProps) {
<div className="min-w-0">
<div className="text-sm leading-snug">
<span className="font-semibold">{entry.username || 'system'}</span>
{entry.acting_as ? (
<span className="text-muted-foreground"> (acting as {entry.acting_as})</span>
) : null}
<span className="text-muted-foreground"> {verb.toLowerCase()} </span>
<span className="font-semibold">{target || entry.path}</span>
</div>
@@ -1,8 +1,11 @@
import { Suspense, lazy, type ReactNode } from 'react';
import { Unplug } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { useExperimental } from '@/hooks/useExperimental';
import { PaidGate } from '../PaidGate';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { resolveHostConsoleCapability } from '@/lib/routing/hostConsoleCapability';
import { LockCard } from '../ui/LockCard';
import { CapabilityGate } from '../CapabilityGate';
import { HubOnlyGate } from '../HubOnlyGate';
import LazyBoundary from '../LazyBoundary';
@@ -17,7 +20,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import type { SecurityTab, FleetTab } from '@/lib/events';
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
import { isStackEditorDeepLink, isHostConsoleStackDeepLink } from '@/lib/router/readUrlRouteState';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
// Paid-tier views are loaded on demand. Their internal PaidGate /
@@ -147,7 +150,8 @@ export function ViewRouter({
quickLinkCandidates,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
const { experimental, experimentalReady } = useExperimental();
const { isPaid, licenseReady } = useLicense();
const { activeNode, activeNodeMeta } = useNodes();
if (activeView === 'settings') {
return (
<SettingsPage
@@ -184,20 +188,48 @@ export function ViewRouter({
);
}
if (activeView === 'host-console') {
// Discovery + paid/RBAC: hide until experimental discovery is on,
// then mirror backend gates (system:console admin-only + PaidGate +
// capability). Nav is already gated the same way; this stops a
// deep link from mounting a console the operator cannot use.
if (!experimentalReady || !experimental) return null;
// RBAC + mixed-version capability. Wait for a resolved active node and
// remote meta; null activeNode must not be treated as local (wrong-node
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
// wait so we never open a compose-root shell, then reconnect into the stack.
if (!can('system:console')) return null;
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
return <ViewSkeleton />;
}
if (activeNode == null) return <ViewSkeleton />;
const capState = resolveHostConsoleCapability({
nodeResolved: true,
isRemote: activeNode.type === 'remote',
isPaid,
licenseReady,
activeNodeMeta,
});
if (capState === 'loading') return <ViewSkeleton />;
if (capState === 'locked') {
const nodeName = activeNode.name;
const version = activeNodeMeta?.version;
let versionHint = `${nodeName} does not advertise this capability.`;
if (version && version !== 'unknown' && version !== '0.0.0-dev') {
versionHint = `${nodeName} is running v${version}.`;
}
return (
<LockCard
icon={Unplug}
title="Host Console is not available on this node"
body={`${versionHint} Upgrade the node to use this feature.`}
/>
);
}
const nodeId = activeNode.id;
return (
<PaidGate>
<CapabilityGate capability="host-console" featureName="Host Console">
<LazyView>
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
</LazyView>
</CapabilityGate>
</PaidGate>
<LazyView>
<HostConsole
key={`${nodeId}:${selectedFile ?? ''}`}
nodeId={nodeId}
stackName={selectedFile}
onClose={onHostConsoleClose}
/>
</LazyView>
);
}
// Stack workspace: keep a loading shell while the stack URL hydrates.
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import * as AuthContext from '@/context/AuthContext';
import * as LicenseContext from '@/context/LicenseContext';
import * as NodeContext from '@/context/NodeContext';
import { ViewRouter } from '../ViewRouter';
vi.mock('@/context/AuthContext');
vi.mock('@/context/LicenseContext');
vi.mock('@/context/NodeContext');
vi.mock('../../HostConsole', () => ({
default: ({ nodeId, stackName }: { nodeId: number; stackName?: string | null }) => (
<div
data-testid="host-console"
data-node-id={String(nodeId)}
data-stack={stackName ?? ''}
>
Host Console
</div>
),
}));
vi.mock('../../LazyBoundary', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const baseProps = {
activeView: 'host-console' as const,
selectedFile: null as string | null,
isLoading: false,
settingsSection: 'appearance' as const,
onSettingsSectionChange: vi.fn(),
onTemplateDeploySuccess: vi.fn(),
onHostConsoleClose: vi.fn(),
onFleetNavigateToNode: vi.fn(),
onOpenNodeNetworking: vi.fn(),
filterNodeId: null,
onClearScheduledOpsFilter: vi.fn(),
schedulePrefill: null,
onPrefillConsumed: vi.fn(),
muteRulePrefill: null,
onMutePrefillConsumed: vi.fn(),
notifications: [] as [],
onNavigateToStack: vi.fn(),
onOpenSettingsSection: vi.fn(),
onClearNotifications: vi.fn(),
securityTab: 'overview' as const,
onSecurityTabChange: vi.fn(),
renderEditor: () => null,
stackUpdates: {},
urlHydratingStack: null as string | null,
isFileLoading: false,
quickLinkCandidates: [],
};
describe('ViewRouter host-console', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/nodes/local/host-console');
vi.mocked(AuthContext.useAuth).mockReturnValue({
can: (p: string) => p === 'system:console',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseReady: true,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
activeNodeMeta: { version: '0.96.0', capabilities: ['host-console', 'host-console-community'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
it('renders Host Console for a Community admin on the local node', async () => {
render(<ViewRouter {...baseProps} />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-node-id')).toBe('1');
});
it('does not mount HostConsole while the active node is unresolved', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: null,
activeNodeMeta: null,
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('does not mount HostConsole while a stack deep link is still hydrating', () => {
render(<ViewRouter {...baseProps} urlHydratingStack="radarr" selectedFile={null} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('does not mount a root shell while the URL targets a stack-scoped Console', () => {
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
render(<ViewRouter {...baseProps} selectedFile={null} urlHydratingStack={null} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('mounts stack-scoped Console only after selectedFile matches the route', async () => {
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
render(<ViewRouter {...baseProps} selectedFile="radarr" />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-stack')).toBe('radarr');
expect(el.getAttribute('data-node-id')).toBe('1');
});
it('renders nothing without system:console', () => {
vi.mocked(AuthContext.useAuth).mockReturnValue({
can: () => false,
} as unknown as ReturnType<typeof AuthContext.useAuth>);
const { container } = render(<ViewRouter {...baseProps} />);
expect(container.querySelector('[data-testid="host-console"]')).toBeNull();
});
it('shows a skeleton while remote metadata is loading (does not mount HostConsole)', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: null,
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
});
it('shows a lock card for Community + legacy remote without mounting HostConsole', () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
expect(screen.getByText(/Host Console is not available on this node/i)).toBeTruthy();
});
it('mounts Host Console for Admiral + legacy remote after meta resolves', async () => {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
licenseReady: true,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
const el = await screen.findByTestId('host-console');
expect(el.getAttribute('data-node-id')).toBe('2');
});
it('shows a skeleton for legacy-only remote while license is still loading', () => {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseReady: false,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(screen.queryByTestId('host-console')).toBeNull();
expect(screen.queryByText(/Host Console is not available on this node/i)).toBeNull();
});
it('mounts Host Console for Community + host-console-community remote', async () => {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 3, name: 'NewPeer', type: 'remote' },
activeNodeMeta: {
version: '0.96.0',
capabilities: ['host-console', 'host-console-community'],
fetchedAt: 1,
},
} as unknown as ReturnType<typeof NodeContext.useNodes>);
render(<ViewRouter {...baseProps} />);
expect(await screen.findByTestId('host-console')).toBeTruthy();
});
});
@@ -51,7 +51,7 @@ function mockDeployer() {
function mockPaidAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit' || p === 'node:read',
can: (p: string) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
@@ -63,7 +63,7 @@ function mockPaidAdmin() {
function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'node:read',
can: (p: string) => p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
@@ -269,13 +269,13 @@ describe('useViewNavigationState', () => {
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
});
it('shows Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
it('shows Update, Schedules, and Console for a community admin; Audit stays paid', () => {
mockCommunityAdmin();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('auto-updates');
expect(values).toContain('scheduled-ops');
expect(values).not.toContain('host-console');
expect(values).toContain('host-console');
expect(values).not.toContain('audit-log');
// The auto-updates nav item surfaces under the short label "Update".
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
@@ -429,53 +429,29 @@ describe('useViewNavigationState', () => {
expect(result.current.securityTab).toBe('overview');
});
// ── experimental discovery ─────────────────────────────────────────────────
// ── host-console discovery (no longer experimental) ────────────────────────
it('hides Console from nav for a paid admin when experimental discovery is off', () => {
it('keeps Console in nav for a paid admin when experimental discovery is off', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
});
it('hides Console from nav while experimental metadata is still loading', () => {
it('keeps Console in nav while experimental metadata is still loading', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
});
it('does not normalize a host-console deep link before experimental readiness', () => {
it('keeps a host-console deep link selected when experimental is off', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
const onNavigateToDashboard = vi.fn();
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
expect(result.current.activeView).toBe('host-console');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('keeps host-console selected when delayed experimental resolves enabled', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const onNavigateToDashboard = vi.fn();
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
rerender();
expect(result.current.activeView).toBe('host-console');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('normalizes host-console once when experimental resolves disabled', () => {
mockPaidAdmin();
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
const onNavigateToDashboard = vi.fn();
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => result.current.setActiveView('host-console'));
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
rerender();
expect(result.current.activeView).toBe('dashboard');
expect(onNavigateToDashboard).toHaveBeenCalled();
});
});
+14 -9
View File
@@ -8,6 +8,8 @@ import { useNodes } from '@/context/NodeContext';
import { copyToClipboard } from '@/lib/clipboard';
interface HostConsoleProps {
/** Resolved active node id; WebSocket must target this id, not localStorage. */
nodeId: number;
stackName?: string | null;
onClose: () => void;
}
@@ -27,7 +29,7 @@ function formatUptime(ms: number): string {
type ConnState = 'reconnecting' | 'connected' | 'disconnected';
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
export default function HostConsole({ nodeId, stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
@@ -93,12 +95,12 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
});
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
const ws = new WebSocket(wsUrl);
const qs = stackName
? `nodeId=${nodeId}&stack=${encodeURIComponent(stackName)}`
: `nodeId=${nodeId}`;
const ws = new WebSocket(
`${wsProtocol}//${window.location.host}/api/system/host-console?${qs}`,
);
wsRef.current = ws;
ws.onopen = () => {
@@ -194,7 +196,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
fitAddonRef.current = null;
serializeRef.current = null;
};
}, [stackName, reconnectNonce]);
}, [nodeId, stackName, reconnectNonce]);
const handleCopy = useCallback(() => {
const term = xtermRef.current;
@@ -236,7 +238,10 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const stateWord = connState === 'disconnected'
? 'Disconnected'
: connState === 'reconnecting' ? 'Reconnecting' : 'Connected';
const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL';
let nodeLabel = `NODE ${nodeId}`;
if (activeNode?.id === nodeId) {
nodeLabel = activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase();
}
const kicker = `HOST CONSOLE · ${nodeLabel}`;
const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—';
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import * as NodeContext from '@/context/NodeContext';
import HostConsole from '../HostConsole';
vi.mock('@/context/NodeContext');
vi.mock('@/lib/xtermLoader', () => {
class FakeTerminal {
cols = 80;
rows = 24;
open = vi.fn();
focus = vi.fn();
write = vi.fn();
clear = vi.fn();
dispose = vi.fn();
getSelection = vi.fn(() => '');
loadAddon = vi.fn();
onData = vi.fn();
}
class FakeFitAddon {
fit = vi.fn();
}
class FakeSerializeAddon {
serialize = vi.fn(() => '');
}
return {
loadXtermModules: async () => ({
Terminal: FakeTerminal,
FitAddon: FakeFitAddon,
SerializeAddon: FakeSerializeAddon,
}),
};
});
vi.mock('../ui/PageMasthead', () => ({
PageMasthead: ({ children }: { children?: ReactNode }) => <div data-testid="masthead">{children}</div>,
}));
vi.mock('../ui/button', () => ({
Button: ({ children, ...props }: { children?: ReactNode } & Record<string, unknown>) => (
<button type="button" {...props}>{children}</button>
),
}));
vi.mock('@/lib/clipboard', () => ({
copyToClipboard: vi.fn(async () => undefined),
}));
type FakeWs = {
url: string;
readyState: number;
close: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
onopen: ((ev?: unknown) => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onerror: ((ev?: unknown) => void) | null;
onclose: ((ev?: unknown) => void) | null;
};
describe('HostConsole socket targeting', () => {
const sockets: FakeWs[] = [];
let OriginalWebSocket: typeof WebSocket;
beforeEach(() => {
sockets.length = 0;
OriginalWebSocket = globalThis.WebSocket;
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
globalThis.WebSocket = class {
static OPEN = 1;
static CLOSED = 3;
url: string;
readyState = 0;
close = vi.fn(() => { this.readyState = 3; });
send = vi.fn();
onopen: FakeWs['onopen'] = null;
onmessage: FakeWs['onmessage'] = null;
onerror: FakeWs['onerror'] = null;
onclose: FakeWs['onclose'] = null;
constructor(url: string) {
this.url = url;
sockets.push(this as unknown as FakeWs);
queueMicrotask(() => {
this.readyState = 1;
this.onopen?.(undefined);
});
}
} as unknown as typeof WebSocket;
});
afterEach(() => {
globalThis.WebSocket = OriginalWebSocket;
localStorage.removeItem('sencho-active-node');
vi.clearAllMocks();
});
it('opens the WebSocket with the explicit nodeId (not localStorage)', async () => {
localStorage.setItem('sencho-active-node', '99');
render(<HostConsole nodeId={7} stackName={null} onClose={vi.fn()} />);
await waitFor(() => expect(sockets.length).toBe(1));
expect(sockets[0].url).toContain('nodeId=7');
expect(sockets[0].url).not.toContain('nodeId=99');
});
it('includes the stack parameter when provided', async () => {
render(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
await waitFor(() => expect(sockets.length).toBe(1));
expect(sockets[0].url).toContain('stack=radarr');
});
it('closes the prior socket and opens a new one when nodeId changes', async () => {
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
await waitFor(() => expect(sockets.length).toBe(1));
const first = sockets[0];
await act(async () => {
rerender(<HostConsole nodeId={2} stackName={null} onClose={vi.fn()} />);
});
await waitFor(() => expect(sockets.length).toBe(2));
expect(first.close).toHaveBeenCalled();
expect(sockets[1].url).toContain('nodeId=2');
});
it('reconnects when stackName changes so a root shell is not retained', async () => {
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
await waitFor(() => expect(sockets.length).toBe(1));
const first = sockets[0];
expect(first.url).not.toContain('stack=');
await act(async () => {
rerender(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
});
await waitFor(() => expect(sockets.length).toBe(2));
expect(first.close).toHaveBeenCalled();
expect(sockets[1].url).toContain('stack=radarr');
});
});