mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export const CAPABILITIES = [
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
@@ -40,6 +41,12 @@ export const CAPABILITIES = [
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
|
||||
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
|
||||
|
||||
/** Host Console works without a paid license on this node. */
|
||||
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
|
||||
|
||||
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
||||
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
|
||||
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
||||
|
||||
@@ -77,19 +77,31 @@ describe('buildNavigationModel', () => {
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
|
||||
it('includes Console for system:console regardless of experimental discovery', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
|
||||
buildNavigationModel(makeCtx({
|
||||
experimentalReady: true,
|
||||
experimental: false,
|
||||
isPaid: false,
|
||||
can: (a) => a === 'system:console' || a === 'node:read',
|
||||
}))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({
|
||||
experimentalReady: false,
|
||||
experimental: false,
|
||||
can: (a) => a === 'system:console' || a === 'node:read',
|
||||
}))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
});
|
||||
|
||||
it('omits Console without system:console', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ can: () => false, isAdmin: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
});
|
||||
|
||||
it('excludes hidden views from quick-link candidates', () => {
|
||||
|
||||
@@ -28,12 +28,6 @@ function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext)
|
||||
// Settings is always discoverable in the launcher when the operator can open Settings.
|
||||
return true;
|
||||
}
|
||||
// Console: fail-closed visual discovery until /meta settles and the flag is on.
|
||||
// URL normalization still uses isViewHidden cold-load deferral separately.
|
||||
if (item.value === 'host-console') {
|
||||
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,21 @@ const DEFAULT: UrlRouteState = {
|
||||
filterNodeId: null,
|
||||
};
|
||||
|
||||
/** True when the current URL is a stack workspace deep link (detail or editor). */
|
||||
export function isStackEditorDeepLink(): boolean {
|
||||
/** True when the URL is a stack-scoped deep link for the given view. */
|
||||
function isStackScopedDeepLink(view: ActiveView): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const parsed = parsePath(window.location.pathname, window.location.search);
|
||||
return parsed.view === 'editor' && parsed.stackName != null;
|
||||
return parsed.view === view && parsed.stackName != null;
|
||||
}
|
||||
|
||||
/** True when the current URL is a stack workspace deep link (detail or editor). */
|
||||
export function isStackEditorDeepLink(): boolean {
|
||||
return isStackScopedDeepLink('editor');
|
||||
}
|
||||
|
||||
/** True when the URL targets Host Console rooted in a stack directory. */
|
||||
export function isHostConsoleStackDeepLink(): boolean {
|
||||
return isStackScopedDeepLink('host-console');
|
||||
}
|
||||
|
||||
/** Read shell navigation fields from the current browser URL (cold-load bootstrap). */
|
||||
|
||||
@@ -165,4 +165,21 @@ describe('senchoRoute', () => {
|
||||
expect(parsed.view).toBe('networking');
|
||||
expect(parsed.nodeSlug).toBe('local');
|
||||
});
|
||||
|
||||
it('round-trips Host Console without a stack', () => {
|
||||
const path = buildPath({ ...base, activeView: 'host-console', stackName: null });
|
||||
expect(path).toBe('/nodes/local/host-console');
|
||||
const parsed = parsePath(path, '');
|
||||
expect(parsed.view).toBe('host-console');
|
||||
expect(parsed.nodeSlug).toBe('local');
|
||||
expect(parsed.stackName).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips Host Console rooted in a stack directory', () => {
|
||||
const path = buildPath({ ...base, activeView: 'host-console', stackName: 'radarr' });
|
||||
expect(path).toBe('/nodes/local/host-console/radarr');
|
||||
const parsed = parsePath(path, '');
|
||||
expect(parsed.view).toBe('host-console');
|
||||
expect(parsed.stackName).toBe('radarr');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveHostConsoleCapability } from './hostConsoleCapability';
|
||||
|
||||
describe('resolveHostConsoleCapability', () => {
|
||||
it('returns loading when the active node is unresolved', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: false,
|
||||
isRemote: false,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows local nodes without waiting for meta', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: false,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('returns loading when remote meta is absent', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows Community when remote advertises host-console-community', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console', 'host-console-community'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('locks Community when remote only has legacy host-console', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('locked');
|
||||
});
|
||||
|
||||
it('allows Admiral when remote only has legacy host-console', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('returns loading for legacy-only remote while license is not ready', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows community-capable remote without waiting on license', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
activeNodeMeta: { capabilities: ['host-console-community'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('locks Pilot / empty capability lists', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['stacks', 'fleet'] },
|
||||
})).toBe('locked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
HOST_CONSOLE_CAPABILITY,
|
||||
HOST_CONSOLE_COMMUNITY_CAPABILITY,
|
||||
} from '@/lib/capabilities';
|
||||
|
||||
export type HostConsoleCapabilityState = 'loading' | 'allowed' | 'locked';
|
||||
|
||||
export interface HostConsoleCapabilityInput {
|
||||
/** False until NodeContext resolves an active node (cold load). Never treat as local. */
|
||||
nodeResolved: boolean;
|
||||
/** True when the resolved active node is a remote Distributed API Proxy or Pilot node. */
|
||||
isRemote: boolean;
|
||||
/** Hub license: Admiral may accept legacy `host-console` on remotes. */
|
||||
isPaid: boolean;
|
||||
/**
|
||||
* False while LicenseContext is still loading. Legacy-remote allowance
|
||||
* must wait so a cold load does not flash LockCard as Community.
|
||||
*/
|
||||
licenseReady: boolean;
|
||||
/**
|
||||
* Cached `/api/meta` for the active node. Null means metadata has not been
|
||||
* fetched yet (or the node is unresolved). Must not be confused with
|
||||
* optimistic `hasCapability()` which returns true while meta is absent.
|
||||
*/
|
||||
activeNodeMeta: { capabilities: readonly string[] } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Host Console content may mount for the active node.
|
||||
*
|
||||
* Unresolved nodes stay in `loading`. Local nodes are treated as compatible
|
||||
* once RBAC passed (same build). Remote nodes wait for metadata, then require
|
||||
* `host-console-community`, or (Admiral only) legacy `host-console`.
|
||||
*/
|
||||
export function resolveHostConsoleCapability(
|
||||
input: HostConsoleCapabilityInput,
|
||||
): HostConsoleCapabilityState {
|
||||
const { nodeResolved, isRemote, isPaid, licenseReady, activeNodeMeta } = input;
|
||||
if (!nodeResolved) return 'loading';
|
||||
if (!isRemote) return 'allowed';
|
||||
if (!activeNodeMeta) return 'loading';
|
||||
|
||||
const caps = activeNodeMeta.capabilities;
|
||||
if (caps.includes(HOST_CONSOLE_COMMUNITY_CAPABILITY)) return 'allowed';
|
||||
if (!caps.includes(HOST_CONSOLE_CAPABILITY)) return 'locked';
|
||||
// Legacy host-console only: Admiral hubs may open it; wait for license first.
|
||||
if (!licenseReady) return 'loading';
|
||||
return isPaid ? 'allowed' : 'locked';
|
||||
}
|
||||
@@ -49,25 +49,25 @@ describe('reachability', () => {
|
||||
expect(isViewHidden('fleet', noFleet)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves paid views when license metadata failed', () => {
|
||||
const licenseError = ctx({ licenseStatus: 'error', experimental: true });
|
||||
it('preserves host-console when authz is not ready', () => {
|
||||
const licenseError = ctx({ licenseStatus: 'error', can: (a) => a === 'system:console' });
|
||||
expect(isViewHidden('host-console', licenseError)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply experimental hide to host-console until experimentalReady', () => {
|
||||
const loading = ctx({ experimental: false, experimentalReady: false, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', loading)).toBe(false);
|
||||
it('hides host-console without system:console when ready', () => {
|
||||
const noConsole = ctx({ can: () => false, isPaid: false, experimental: false });
|
||||
expect(isViewHidden('host-console', noConsole)).toBe(true);
|
||||
expect(normalizeHiddenView('host-console', noConsole)).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('hides host-console when experimental is ready and off even for paid admin', () => {
|
||||
const off = ctx({ experimental: false, experimentalReady: true, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', off)).toBe(true);
|
||||
expect(normalizeHiddenView('host-console', off)).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('keeps host-console when experimental is on for paid admin', () => {
|
||||
const on = ctx({ experimental: true, experimentalReady: true, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', on)).toBe(false);
|
||||
it('keeps host-console for system:console regardless of tier or experimental', () => {
|
||||
const community = ctx({
|
||||
isPaid: false,
|
||||
experimental: false,
|
||||
experimentalReady: true,
|
||||
can: (a) => a === 'system:console',
|
||||
});
|
||||
expect(isViewHidden('host-console', community)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides routing and secrets fleet tabs only after experimentalReady when off', () => {
|
||||
|
||||
@@ -43,11 +43,7 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea
|
||||
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
|
||||
if (!ctx.can('node:read') && view === 'fleet') return true;
|
||||
if (view === 'host-console') {
|
||||
// Defer experimental hide until ready so enabled deep links survive cold load.
|
||||
if (experimentalDiscoveryReady(ctx) && !ctx.experimental) return true;
|
||||
if (!ctx.isPaid) return true;
|
||||
if (!ctx.isAdmin) return true;
|
||||
return false;
|
||||
return !ctx.can('system:console');
|
||||
}
|
||||
if (!ctx.isPaid) {
|
||||
if (view === 'audit-log') return true;
|
||||
@@ -67,7 +63,7 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex
|
||||
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
|
||||
if (!authzReady(ctx)) return false;
|
||||
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
|
||||
// Defer experimental hide until ready (same cold-load contract as host-console).
|
||||
// Defer experimental hide until ready so deep links survive cold load.
|
||||
if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user