mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(nodes): hide hub-only views when active node is remote (#1007)
* feat(nodes): hide hub-only views when active node is remote Fleet, Schedules, Audit, Logs, and Auto-Update operate on hub-owned state (node registry, fleet schedules, centralized audit, fleet-wide log aggregation, fleet-wide update preview). When the active node is remote, proxying those surfaces would show that remote's own disconnected state instead of the hub's. Hide them from the nav strip and force-redirect to Home if one was open during the node switch. Backend hubOnlyGuard middleware sits between nodeContextMiddleware and the remote proxy and rejects /api/scheduled-tasks, /api/audit-log, and /api/notification-routes with 403 + HUB_ONLY_ENDPOINT when nodeId resolves to a remote, closing the script-bypass path the UI gating cannot reach. Settings sub-sections were already gated via the hiddenOnRemote registry; this extends the same model to top-level views. * docs(nodes): note hub-only visibility on Fleet, Schedules, Audit, Logs, Auto-Update Each of the five hub-only feature pages now points readers to the canonical "What top-level views show when a remote node is active" section in multi-node.mdx, so users landing directly on a feature page understand why the nav item disappears when they switch to a remote node.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Regression guard for `hubOnlyGuard` middleware.
|
||||
*
|
||||
* Hub-only paths (e.g. /api/scheduled-tasks, /api/audit-log,
|
||||
* /api/notification-routes) manage state owned by the local hub. When a
|
||||
* request carries `x-node-id` for a remote node, the guard must reject
|
||||
* with 409 before the remote proxy forwards it. Without this guard, a
|
||||
* scripted client could trick the proxy into running hub-level operations
|
||||
* on a remote instance, crossing a node-authority boundary that the UI
|
||||
* promises will not happen.
|
||||
*
|
||||
* The guard sits between `enforceApiTokenScope` (step 12) and
|
||||
* `createRemoteProxyMiddleware` (step 14).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
describe('hubOnlyGuard', () => {
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let remoteNodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
remoteNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'hub-only-remote',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://127.0.0.1:1',
|
||||
api_token: 'hub-only-token',
|
||||
});
|
||||
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
it('rejects /api/scheduled-tasks with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/scheduled-tasks/')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
expect(res.body?.error).toMatch(/hub-only/i);
|
||||
});
|
||||
|
||||
it('rejects /api/audit-log with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects /api/notification-routes with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-routes/')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('lets /api/scheduled-tasks through to the local handler when no nodeId is set', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/scheduled-tasks/')
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
// Anything other than 403 with HUB_ONLY_ENDPOINT proves the guard fell
|
||||
// through. Local handler may return 200 or a tier-gate 403; both are
|
||||
// acceptable here, so assert on the body code rather than the status.
|
||||
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => {
|
||||
// /api/stacks is not hub-only and should be forwarded by the proxy.
|
||||
// The exact upstream-error status is not the contract here; what
|
||||
// matters is that the guard did not reject with HUB_ONLY_ENDPOINT.
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
});
|
||||
+7
-6
@@ -11,7 +11,7 @@ import './types/express';
|
||||
/**
|
||||
* Build an Express app with the full middleware pipeline installed.
|
||||
*
|
||||
* Canonical middleware order (16 steps). Do not reorder without re-running the
|
||||
* Canonical middleware order (17 steps). Do not reorder without re-running the
|
||||
* regression checklist in `docs/internal/architecture/middleware-order.md`.
|
||||
*
|
||||
* 1. trust proxy
|
||||
@@ -26,12 +26,13 @@ import './types/express';
|
||||
* 10. authGate (at /api) -- registered in index.ts
|
||||
* 11. auditLog (at /api) -- registered in index.ts
|
||||
* 12. enforceApiTokenScope (at /api) -- registered in index.ts
|
||||
* 13. createRemoteProxyMiddleware -- proxy/remoteNodeProxy.ts, registered in index.ts
|
||||
* 14. routes -- registered in index.ts from routes/*
|
||||
* 15. static serving + SPA fallback -- registered in index.ts
|
||||
* 16. errorHandler -- registered in index.ts
|
||||
* 13. hubOnlyGuard (at /api) -- middleware/hubOnlyGuard.ts, registered in index.ts
|
||||
* 14. createRemoteProxyMiddleware -- proxy/remoteNodeProxy.ts, registered in index.ts
|
||||
* 15. routes -- registered in index.ts from routes/*
|
||||
* 16. static serving + SPA fallback -- registered in index.ts
|
||||
* 17. errorHandler -- registered in index.ts
|
||||
*
|
||||
* Steps 10 to 12 and 14 must run after the public auth routers (meta, auth,
|
||||
* Steps 10 to 13 and 15 must run after the public auth routers (meta, auth,
|
||||
* mfa, sso) are registered so those routes stay reachable without a session
|
||||
* cookie. index.ts mounts those public routers before step 10 to preserve
|
||||
* that invariant.
|
||||
|
||||
@@ -22,3 +22,28 @@ export function isProxyExemptPath(path: string): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Path prefixes that are hub-only: they manage state owned by the local hub
|
||||
// (centralized audit, fleet schedules, notification routing rules). Routed
|
||||
// to the local hub when nodeId resolves to local, but rejected with 409 when
|
||||
// nodeId resolves to a remote node so a script/curl call cannot trick the
|
||||
// proxy into forwarding hub-only authority across a node boundary.
|
||||
//
|
||||
// Frontend nav surfaces are gated separately via `HUB_ONLY_VIEWS` in
|
||||
// useViewNavigationState.ts; this list is the backend defense-in-depth.
|
||||
//
|
||||
// Consumed by:
|
||||
// - middleware/hubOnlyGuard.ts → 409 when nodeId is remote
|
||||
export const HUB_ONLY_PREFIXES: readonly string[] = [
|
||||
'/api/scheduled-tasks/',
|
||||
'/api/audit-log/',
|
||||
'/api/notification-routes/',
|
||||
];
|
||||
|
||||
/** Returns true when the path is hub-only and must not be proxied to a remote node. */
|
||||
export function isHubOnlyPath(path: string): boolean {
|
||||
for (const prefix of HUB_ONLY_PREFIXES) {
|
||||
if (path.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import express, { Request, Response } from 'express';
|
||||
import './types/express';
|
||||
import { authGate, auditLog } from './middleware/authGate';
|
||||
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
||||
import { hubOnlyGuard } from './middleware/hubOnlyGuard';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { createApp } from './app';
|
||||
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
||||
@@ -82,6 +83,14 @@ app.use('/api', auditLog);
|
||||
|
||||
app.use('/api', enforceApiTokenScope);
|
||||
|
||||
// Hub-only guard: reject requests whose nodeId resolves to a remote node
|
||||
// when the path is hub-only (e.g. /api/scheduled-tasks, /api/audit-log,
|
||||
// /api/notification-routes). Without this, the proxy would forward the
|
||||
// request and process it on the remote as a local call, crossing a
|
||||
// node-authority boundary that the UI hides. See helpers/proxyExemptPaths.ts
|
||||
// for the prefix list and middleware/hubOnlyGuard.ts for the rationale.
|
||||
app.use('/api', hubOnlyGuard);
|
||||
|
||||
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted BEFORE the
|
||||
// per-group routers so a request targeting a remote node short-circuits into
|
||||
// the proxy instead of hitting a local handler that would read local state.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { isHubOnlyPath } from '../helpers/proxyExemptPaths';
|
||||
|
||||
/**
|
||||
* Reject hub-only API requests whose `req.nodeId` resolves to a remote node.
|
||||
*
|
||||
* The hub-level views in the frontend (Schedules, Audit, Notification
|
||||
* Routing config, etc.) are hidden from the nav strip when the active node
|
||||
* is remote, so a normal user never reaches these endpoints with a remote
|
||||
* nodeId. A scripted client could still craft `x-node-id: <remote>` against
|
||||
* one of these paths; without this guard, the request would be forwarded
|
||||
* by `remoteNodeProxy` and processed on the remote as if it were local,
|
||||
* silently crossing a node-authority boundary that the UI promised would
|
||||
* not happen.
|
||||
*
|
||||
* Mounted at `/api` between `nodeContextMiddleware` (which sets req.nodeId)
|
||||
* and `createRemoteProxyMiddleware` (which would otherwise forward the
|
||||
* request). Rejects with 403 — the endpoint exists but the request cannot
|
||||
* be served as routed.
|
||||
*
|
||||
* Returns 403 only for hub-only paths; non-hub paths fall through.
|
||||
*/
|
||||
export const hubOnlyGuard: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!isHubOnlyPath(`/api${req.path}`)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const node = NodeRegistry.getInstance().getNode(req.nodeId);
|
||||
if (node?.type === 'remote') {
|
||||
res.status(403).json({
|
||||
error: 'This endpoint is hub-only and cannot be proxied to a remote node. Switch the active node back to your local hub.',
|
||||
code: 'HUB_ONLY_ENDPOINT',
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -7,6 +7,10 @@ description: Track all mutating actions across your Sencho instance with a searc
|
||||
The Audit Log requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Audit is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active).
|
||||
</Note>
|
||||
|
||||
Sencho Admiral records every mutating action (deploy, stop, delete, settings changes, user management) with full attribution. The audit log answers the question every team eventually asks: **"Who changed what, and when?"**
|
||||
|
||||
## What gets logged
|
||||
|
||||
@@ -7,6 +7,10 @@ description: "Review pending container updates across your fleet, with risk tags
|
||||
Auto-Update Readiness requires a **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Auto-Update is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active).
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
Auto-Update Readiness is the launchpad for every pending update across your stacks. Instead of a list of CRUD policies, the view surfaces one card per stack with an available update and tells you, at a glance, whether it is safe to apply.
|
||||
|
||||
@@ -5,6 +5,10 @@ description: Monitor all your nodes from a single dashboard with real-time healt
|
||||
|
||||
The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment, local and remote, on one screen. It is available to all tiers, with advanced features unlocked by Skipper and Admiral.
|
||||
|
||||
<Note>
|
||||
Fleet is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active).
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/fleet-view/fleet-overview.png" alt="Fleet Overview with the fleet masthead, grid/topology toggle, and pinned local node" />
|
||||
</Frame>
|
||||
|
||||
@@ -5,6 +5,10 @@ description: A unified, searchable log stream from every container across all yo
|
||||
|
||||
The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place, with contextual metrics and filtering to focus on what matters.
|
||||
|
||||
<Note>
|
||||
Logs is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active).
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/global-observability/global-observability-overview.png" alt="Global Observability view with live masthead, signal rail, and streaming log feed" />
|
||||
</Frame>
|
||||
|
||||
@@ -66,6 +66,20 @@ Click any row to switch. All views (dashboard stats, stack list, editor, resourc
|
||||
<img src="/images/multi-node/node-switcher-dropdown.png" alt="Node switcher popover listing connected nodes with type, version, and active-row accent" />
|
||||
</Frame>
|
||||
|
||||
## What top-level views show when a remote node is active
|
||||
|
||||
Top-level views that manage fleet-wide state are scoped to the local hub and are hidden from the nav strip when a remote node is the active selection. They reappear automatically when you switch back to your local node.
|
||||
|
||||
| View | Why it is hub-only |
|
||||
|------|--------------------|
|
||||
| **Fleet** | Manages your node registry and cross-node topology; only meaningful from the hub. |
|
||||
| **Schedules** | Schedules are dispatched from the hub to target nodes. |
|
||||
| **Audit** | Audit history is centralized on the hub. |
|
||||
| **Logs** | Aggregates logs across the fleet. |
|
||||
| **Auto-Update** | Compares image versions across all nodes in your fleet. |
|
||||
|
||||
Node-level views (Home, Resources, App Store, Console, the editor) work as expected against whichever node you have selected.
|
||||
|
||||
## Per-node scheduling and update indicators
|
||||
|
||||
The Nodes table surfaces scheduling and update status at a glance for each node:
|
||||
|
||||
@@ -7,6 +7,10 @@ description: Automate recurring Docker operations like stack restarts, lifecycle
|
||||
Scheduled Operations is available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Vulnerability Scan**, and **Fleet Snapshot**. All other actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) remain Admiral.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Schedules is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active).
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest, including a full execution history log so you always know what ran and when.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AdmiralGate } from '../AdmiralGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { SettingsPage } from '../settings/SettingsPage';
|
||||
import type { SectionId } from '../settings/types';
|
||||
@@ -10,6 +11,7 @@ import ResourcesView from '../ResourcesView';
|
||||
import HomeDashboard from '../HomeDashboard';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
|
||||
// Paid-tier views and the security-history overlay are loaded on demand.
|
||||
// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render
|
||||
@@ -59,18 +61,7 @@ function LazyView({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export type ActiveView =
|
||||
| 'dashboard'
|
||||
| 'editor'
|
||||
| 'host-console'
|
||||
| 'resources'
|
||||
| 'templates'
|
||||
| 'global-observability'
|
||||
| 'fleet'
|
||||
| 'audit-log'
|
||||
| 'scheduled-ops'
|
||||
| 'auto-updates'
|
||||
| 'settings';
|
||||
export type { ActiveView };
|
||||
|
||||
export interface ViewRouterProps {
|
||||
activeView: ActiveView;
|
||||
@@ -148,50 +139,60 @@ export function ViewRouter({
|
||||
}
|
||||
if (activeView === 'global-observability') {
|
||||
return (
|
||||
<LazyView>
|
||||
<GlobalObservabilityView />
|
||||
</LazyView>
|
||||
<HubOnlyGate>
|
||||
<LazyView>
|
||||
<GlobalObservabilityView />
|
||||
</LazyView>
|
||||
</HubOnlyGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'fleet') {
|
||||
return (
|
||||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||
<LazyView>
|
||||
<FleetView onNavigateToNode={onFleetNavigateToNode} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
<HubOnlyGate>
|
||||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||
<LazyView>
|
||||
<FleetView onNavigateToNode={onFleetNavigateToNode} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</HubOnlyGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'audit-log') {
|
||||
return (
|
||||
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||||
<LazyView>
|
||||
<AuditLogView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
<HubOnlyGate>
|
||||
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||||
<LazyView>
|
||||
<AuditLogView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</HubOnlyGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'auto-updates') {
|
||||
return (
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||||
<LazyView>
|
||||
<AutoUpdateReadinessView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
<HubOnlyGate>
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||||
<LazyView>
|
||||
<AutoUpdateReadinessView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</HubOnlyGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'scheduled-ops') {
|
||||
return (
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
<LazyView>
|
||||
<ScheduledOperationsView
|
||||
filterNodeId={filterNodeId}
|
||||
onClearFilter={onClearScheduledOpsFilter}
|
||||
prefill={schedulePrefill}
|
||||
onPrefillConsumed={onPrefillConsumed}
|
||||
/>
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
<HubOnlyGate>
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
<LazyView>
|
||||
<ScheduledOperationsView
|
||||
filterNodeId={filterNodeId}
|
||||
onClearFilter={onClearScheduledOpsFilter}
|
||||
prefill={schedulePrefill}
|
||||
onPrefillConsumed={onPrefillConsumed}
|
||||
/>
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</HubOnlyGate>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -2,11 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import * as AuthContext from '@/context/AuthContext';
|
||||
import * as LicenseContext from '@/context/LicenseContext';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
|
||||
import { useViewNavigationState } from '../hooks/useViewNavigationState';
|
||||
|
||||
vi.mock('@/context/AuthContext');
|
||||
vi.mock('@/context/LicenseContext');
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
function mockActiveNode(type: 'local' | 'remote' | null) {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: type === null ? null : { type, id: 1, name: 'n' },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
}
|
||||
|
||||
function mockCommunityUser() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
@@ -44,6 +52,7 @@ function mockSkipperAdmin() {
|
||||
describe('useViewNavigationState', () => {
|
||||
beforeEach(() => {
|
||||
mockCommunityUser();
|
||||
mockActiveNode('local');
|
||||
});
|
||||
|
||||
// ── initial state ──────────────────────────────────────────────────────────
|
||||
@@ -227,4 +236,85 @@ describe('useViewNavigationState', () => {
|
||||
expect(values).not.toContain('audit-log');
|
||||
expect(values).not.toContain('scheduled-ops');
|
||||
});
|
||||
|
||||
// ── navItems: hub-only gating on remote node ───────────────────────────────
|
||||
|
||||
it('hides hub-only views from the nav strip when active node is remote', () => {
|
||||
mockAdmiralAdmin();
|
||||
mockActiveNode('remote');
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('scheduled-ops');
|
||||
expect(values).not.toContain('audit-log');
|
||||
expect(values).not.toContain('global-observability');
|
||||
expect(values).not.toContain('auto-updates');
|
||||
// Node-level views remain visible.
|
||||
expect(values).toContain('dashboard');
|
||||
expect(values).toContain('resources');
|
||||
expect(values).toContain('templates');
|
||||
expect(values).toContain('host-console');
|
||||
});
|
||||
|
||||
it('shows hub-only views again when active node switches back to local', () => {
|
||||
mockAdmiralAdmin();
|
||||
mockActiveNode('remote');
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('fleet');
|
||||
|
||||
mockActiveNode('local');
|
||||
rerender();
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('fleet');
|
||||
expect(values).toContain('scheduled-ops');
|
||||
expect(values).toContain('audit-log');
|
||||
});
|
||||
|
||||
// ── auto-redirect when on a hub-only view and node switches to remote ──────
|
||||
|
||||
it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => {
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
mockAdmiralAdmin();
|
||||
mockActiveNode('local');
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useViewNavigationState({ onNavigateToDashboard }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 7 } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeView).toBe('fleet');
|
||||
expect(result.current.filterNodeId).toBe(7);
|
||||
|
||||
mockActiveNode('remote');
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
expect(result.current.filterNodeId).toBeNull();
|
||||
expect(onNavigateToDashboard).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not redirect when a non-hub-only view is active and node becomes remote', () => {
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
mockAdmiralAdmin();
|
||||
mockActiveNode('local');
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useViewNavigationState({ onNavigateToDashboard }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'resources' } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeView).toBe('resources');
|
||||
|
||||
mockActiveNode('remote');
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeView).toBe('resources');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
|
||||
import type { SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
@@ -24,8 +25,22 @@ export type ActiveView =
|
||||
| 'auto-updates'
|
||||
| 'settings';
|
||||
|
||||
// Views that operate on hub-owned state (node registry, fleet schedules,
|
||||
// centralized audit, fleet-wide log aggregation, fleet-wide update preview).
|
||||
// Hidden from the nav strip and force-redirect to dashboard when the active
|
||||
// node is remote, since proxying them would surface that remote's own
|
||||
// disconnected state instead of the hub's. Settings sub-sections use the
|
||||
// parallel `hiddenOnRemote` registry (see settings/registry.ts).
|
||||
export const HUB_ONLY_VIEWS: ReadonlySet<ActiveView> = new Set([
|
||||
'fleet',
|
||||
'scheduled-ops',
|
||||
'audit-log',
|
||||
'global-observability',
|
||||
'auto-updates',
|
||||
]);
|
||||
|
||||
export interface NavItem {
|
||||
value: string;
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
@@ -38,6 +53,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const { onNavigateToDashboard } = options ?? {};
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
|
||||
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
|
||||
@@ -97,8 +114,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
return items;
|
||||
}, [isAdmin, isPaid, license?.variant, can]);
|
||||
return isRemote
|
||||
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))
|
||||
: items;
|
||||
}, [isAdmin, isPaid, license?.variant, can, isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemote && HUB_ONLY_VIEWS.has(activeView)) {
|
||||
onNavigateToDashboard?.();
|
||||
setActiveView('dashboard');
|
||||
setFilterNodeId(null);
|
||||
}
|
||||
}, [isRemote, activeView, onNavigateToDashboard]);
|
||||
|
||||
return {
|
||||
activeView, setActiveView,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface HubOnlyGateProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-circuits to null when the active node is remote so the wrapped
|
||||
* lazy chunk is never fetched. Must wrap *outside* any `LazyView`
|
||||
* (Suspense + lazy), otherwise the gated chunk would download just to
|
||||
* render a preview before the redirect in `useViewNavigationState`
|
||||
* fires. Same load-bearing constraint as `CapabilityGate`.
|
||||
*/
|
||||
export function HubOnlyGate({ children }: HubOnlyGateProps) {
|
||||
const { activeNode } = useNodes();
|
||||
if (activeNode?.type === 'remote') return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* HubOnlyGate short-circuits to null when the active node is remote so the
|
||||
* wrapped lazy chunk is never fetched. Locks the load-bearing behavior
|
||||
* documented in HubOnlyGate.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
function mockActiveNode(type: 'local' | 'remote' | null) {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: type === null ? null : { type, id: 1, name: 'n' },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
}
|
||||
|
||||
describe('HubOnlyGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when active node is local', () => {
|
||||
mockActiveNode('local');
|
||||
render(
|
||||
<HubOnlyGate>
|
||||
<div data-testid="payload">hub content</div>
|
||||
</HubOnlyGate>,
|
||||
);
|
||||
expect(screen.getByTestId('payload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders children when active node is null (initial load)', () => {
|
||||
mockActiveNode(null);
|
||||
render(
|
||||
<HubOnlyGate>
|
||||
<div data-testid="payload">hub content</div>
|
||||
</HubOnlyGate>,
|
||||
);
|
||||
expect(screen.getByTestId('payload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null when active node is remote', () => {
|
||||
mockActiveNode('remote');
|
||||
const { container } = render(
|
||||
<HubOnlyGate>
|
||||
<div data-testid="payload">hub content</div>
|
||||
</HubOnlyGate>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId('payload')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user