mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user