feat: add routable browser URLs for stacks and shell views (#1586)

* feat: add routable browser URLs for stacks and shell views

Sync in-memory navigation to the address bar via a History API hook so
deep links, refresh, Back/Forward, and bookmarks work across nodes,
views, stack editor tabs, and mobile surfaces. Gate role/tier URL
normalization on permissions and license readiness, preserve URLs on
metadata fetch failure, and surface retryable stack-list errors without
rewriting pending stack paths.

* fix: preserve deep-link views on cold load and refresh

Stop the node-switch effect from resetting to dashboard on initial mount.

Defer URL writer settlement until hydrated activeView matches the route.

Adds E2E coverage for shell cold loads, stack refresh, and compose env tab.

* fix: keep mobile dashboard on list surface so sidebar renders

On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the
content surface, hiding the stack list sidebar. This prevented the
data-stacks-loaded sentinel from appearing, causing sidebar truncation
E2E tests to time out after reload on a mobile viewport.

Mobile dashboard now stays on the list surface; other non-editor views
still render on the content surface.

* fix: complete mobile URL routing follow-ups for stack deep links

Restore mobile /dashboard vs /stacks, list surface always writes /stacks.

Hydrate pendingDetailStack, freeze compose failures with routeDetailError,

and add unit plus E2E coverage.

* fix: hydrate shell views from URL and sync in-app navigation

Bootstrap activeView and tab state from the pathname on cold load.

Settle route phase when state already matches, normalize unknown segments,

and open Monaco editor tabs from stack deep links via applyEditorRouteState.

* fix: prevent mobile stack deep links from hanging on cold load

The resolvePendingStack effect did not re-fire when the pending stack ref
was populated during URL hydration, because the urlHydratingStack state
set in the same callback was not listed in the effect's dependency array.
Adding it causes the effect to retry once hydration has committed.

A resolvingRef mutex prevents concurrent invocations. When the target file
is already loaded, route state is applied directly without calling
loadFileForRoute, which avoids unmounting the editor (and hiding the
recovery chip) if a background refresh triggers route resolution during
a deploy operation.

* test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs

* ci: raise E2E Playwright job timeout to 20 minutes
This commit is contained in:
Anso
2026-07-08 13:07:16 -04:00
committed by GitHub
parent 0c37d18586
commit 5fe0843eb2
35 changed files with 2263 additions and 199 deletions
+1 -8
View File
@@ -47,14 +47,7 @@ export interface FleetDossierInput {
nodes: FleetDossierNode[];
}
/** Slugify a node or stack name into a safe, lowercase filename segment. */
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '') || 'unnamed';
}
import { slugify } from '@/lib/slugify';
// Escape a value for a Markdown table cell: backslash first (so it cannot defeat
// the pipe escaping), then pipes, then collapse line breaks onto one line.
function cell(value: string): string {
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { nodeIdToSlug, slugToNodeId, nodeSlugMap } from './nodeSlug';
import type { Node } from '@/context/NodeContext';
function node(over: Partial<Node> & Pick<Node, 'id' | 'name' | 'type'>): Node {
return {
url: 'http://127.0.0.1:1852',
is_default: false,
compose_dir: '/compose',
...over,
} as Node;
}
describe('nodeSlug', () => {
const nodes: Node[] = [
node({ id: 1, name: 'Local', type: 'local', is_default: true }),
node({ id: 42, name: 'NAS Box', type: 'remote' }),
node({ id: 7, name: 'local', type: 'remote' }),
];
it('maps default local node to reserved local slug', () => {
expect(nodeIdToSlug(1, nodes)).toBe('local');
});
it('maps remote nodes to name-id slugs', () => {
expect(nodeIdToSlug(42, nodes)).toBe('nas-box-42');
expect(nodeIdToSlug(7, nodes)).toBe('local-7');
});
it('resolves slugs back to node ids', () => {
expect(slugToNodeId('local', nodes)).toBe(1);
expect(slugToNodeId('nas-box-42', nodes)).toBe(42);
expect(slugToNodeId('local-7', nodes)).toBe(7);
});
it('produces a bijective slug map', () => {
const map = nodeSlugMap(nodes);
const slugs = [...map.values()];
expect(new Set(slugs).size).toBe(slugs.length);
expect(slugs).toContain('local');
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { Node } from '@/context/NodeContext';
import { slugify } from '@/lib/slugify';
/** The local node that owns the reserved `local` slug. */
export function pickReservedLocalNode(nodes: Node[]): Node | null {
const locals = nodes.filter(n => n.type === 'local');
if (locals.length === 0) return null;
const defaultLocal = locals.find(n => n.is_default);
if (defaultLocal) return defaultLocal;
return locals.reduce((a, b) => (a.id < b.id ? a : b));
}
/** Bijective node id -> URL slug map. Default local -> `local`; all others -> `<name>-<id>`. */
export function nodeSlugMap(nodes: Node[]): Map<number, string> {
const reservedLocal = pickReservedLocalNode(nodes);
const map = new Map<number, string>();
for (const node of nodes) {
if (reservedLocal && node.id === reservedLocal.id) {
map.set(node.id, 'local');
} else {
map.set(node.id, `${slugify(node.name)}-${node.id}`);
}
}
return map;
}
export function nodeIdToSlug(nodeId: number, nodes: Node[]): string | null {
return nodeSlugMap(nodes).get(nodeId) ?? null;
}
/** Resolve a URL slug to a node id. Falls back to trailing `-<id>` for rename drift. */
export function slugToNodeId(slug: string, nodes: Node[]): number | null {
const normalized = slug.toLowerCase();
for (const [id, s] of nodeSlugMap(nodes)) {
if (s === normalized) return id;
}
const match = normalized.match(/-(\d+)$/);
if (match) {
const id = Number(match[1]);
if (Number.isInteger(id) && nodes.some(n => n.id === id)) return id;
}
return null;
}
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { readUrlRouteState } from './readUrlRouteState';
describe('readUrlRouteState', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/nodes/local/dashboard');
});
it('reads fleet from the current URL', () => {
window.history.replaceState({}, '', '/nodes/local/fleet/snapshots');
expect(readUrlRouteState().activeView).toBe('fleet');
expect(readUrlRouteState().fleetActiveTab).toBe('snapshots');
});
it('reads security tab from the current URL', () => {
window.history.replaceState({}, '', '/nodes/local/security/images');
expect(readUrlRouteState().activeView).toBe('security');
expect(readUrlRouteState().securityTab).toBe('images');
});
it('defaults to dashboard for unknown segments', () => {
window.history.replaceState({}, '', '/nodes/local/not-a-view');
expect(readUrlRouteState().activeView).toBe('dashboard');
});
});
@@ -0,0 +1,33 @@
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView } from './routeTypes';
import { parsePath } from './senchoRoute';
export interface UrlRouteState {
activeView: ActiveView;
settingsSection: SectionId;
securityTab: SecurityTab;
fleetActiveTab: FleetTab;
filterNodeId: number | null;
}
const DEFAULT: UrlRouteState = {
activeView: 'dashboard',
settingsSection: 'appearance',
securityTab: 'overview',
fleetActiveTab: 'overview',
filterNodeId: null,
};
/** Read shell navigation fields from the current browser URL (cold-load bootstrap). */
export function readUrlRouteState(): UrlRouteState {
if (typeof window === 'undefined') return DEFAULT;
const parsed = parsePath(window.location.pathname, window.location.search);
return {
activeView: parsed.view ?? 'dashboard',
settingsSection: (parsed.settingsSection ?? 'appearance') as SectionId,
securityTab: parsed.securityTab ?? 'overview',
fleetActiveTab: parsed.fleetTab ?? 'overview',
filterNodeId: parsed.filterNodeId,
};
}
+59
View File
@@ -0,0 +1,59 @@
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
/** Hub-owned views hidden when a remote node is active. */
export const HUB_ONLY_VIEWS: ReadonlySet<ActiveView> = new Set([
'fleet',
'scheduled-ops',
'audit-log',
'global-observability',
'auto-updates',
]);
export type ActiveView =
| 'dashboard'
| 'editor'
| 'host-console'
| 'resources'
| 'templates'
| 'global-observability'
| 'fleet'
| 'security'
| 'audit-log'
| 'scheduled-ops'
| 'auto-updates'
| 'settings';
export type EditorTab = 'compose' | 'env' | 'files';
/** Mobile shell surface encoded in the URL (desktop uses subset). */
export type MobileRouteSurface = 'list' | 'content' | 'detail';
export interface RouteState {
nodeSlug: string;
activeView: ActiveView;
/** Stack directory name when activeView is editor or host-console. */
stackName: string | null;
editorTab: EditorTab;
envFile: string | null;
securityTab: SecurityTab;
fleetActiveTab: FleetTab;
settingsSection: SectionId | null;
filterNodeId: number | null;
mobileSurface: MobileRouteSurface | null;
isMobile: boolean;
}
export interface ParsedRoute {
nodeSlug: string | null;
view: ActiveView | null;
stackName: string | null;
editorTab: EditorTab | null;
envFile: string | null;
securityTab: SecurityTab | null;
fleetTab: FleetTab | null;
settingsSection: SectionId | null;
filterNodeId: number | null;
/** True when path is /stacks without a stack segment (stack list). */
isStackList: boolean;
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { buildPath, parsePath } from './senchoRoute';
import type { RouteState } from './routeTypes';
const base: RouteState = {
nodeSlug: 'local',
activeView: 'dashboard',
stackName: null,
editorTab: 'compose',
envFile: null,
securityTab: 'overview',
fleetActiveTab: 'overview',
settingsSection: 'appearance',
filterNodeId: null,
mobileSurface: null,
isMobile: false,
};
describe('senchoRoute', () => {
it('round-trips dashboard', () => {
const path = buildPath(base);
expect(path).toBe('/nodes/local/dashboard');
const parsed = parsePath(path, '');
expect(parsed.view).toBe('dashboard');
expect(parsed.nodeSlug).toBe('local');
});
it('round-trips stack editor with tab and env query', () => {
const full = buildPath({
...base,
activeView: 'editor',
stackName: 'radarr',
editorTab: 'env',
envFile: '.env.production',
});
expect(full).toBe('/nodes/local/stacks/radarr/env?env=.env.production');
const q = full.indexOf('?');
const parsed = parsePath(
q === -1 ? full : full.slice(0, q),
q === -1 ? '' : full.slice(q),
);
expect(parsed.view).toBe('editor');
expect(parsed.stackName).toBe('radarr');
expect(parsed.editorTab).toBe('env');
expect(parsed.envFile).toBe('.env.production');
});
it('maps mobile stack list to /stacks without a stack segment', () => {
const path = buildPath({
...base,
isMobile: true,
mobileSurface: 'list',
});
expect(path).toBe('/nodes/local/stacks');
const parsed = parsePath(path, '');
expect(parsed.isStackList).toBe(true);
expect(parsed.view).toBe('dashboard');
});
it('maps mobile list surface to /stacks regardless of activeView', () => {
const path = buildPath({
...base,
isMobile: true,
mobileSurface: 'list',
activeView: 'fleet',
fleetActiveTab: 'snapshots',
});
expect(path).toBe('/nodes/local/stacks');
});
it('parses fleet and settings sections', () => {
const fleet = parsePath('/nodes/local/fleet/snapshots', '');
expect(fleet.view).toBe('fleet');
expect(fleet.fleetTab).toBe('snapshots');
const settings = parsePath('/nodes/local/settings/nodes', '');
expect(settings.view).toBe('settings');
expect(settings.settingsSection).toBe('nodes');
});
it('normalizes trailing slashes and ignores unknown query keys', () => {
const parsed = parsePath('/nodes/local/dashboard/', '?foo=bar');
expect(parsed.view).toBe('dashboard');
expect(parsed.nodeSlug).toBe('local');
});
it('rejects invalid node filter query values', () => {
const parsed = parsePath('/nodes/local/stacks/radarr/compose', '?node=-1');
expect(parsed.filterNodeId).toBeNull();
const overflow = parsePath('/nodes/local/stacks/radarr/compose', '?node=999999999999999999999');
expect(overflow.filterNodeId).toBeNull();
});
it('canonicalizes desktop settings to a concrete section', () => {
const path = buildPath({ ...base, activeView: 'settings', settingsSection: 'appearance' });
expect(path).toBe('/nodes/local/settings/appearance');
});
it('parses stack list path as mobile list surface', () => {
const parsed = parsePath('/nodes/local/stacks', '');
expect(parsed.isStackList).toBe(true);
expect(parsed.stackName).toBeNull();
});
});
+189
View File
@@ -0,0 +1,189 @@
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView, EditorTab, ParsedRoute, RouteState } from './routeTypes';
const VIEW_SEGMENTS = {
dashboard: 'dashboard',
editor: 'stacks',
'host-console': 'host-console',
resources: 'resources',
templates: 'templates',
'global-observability': 'logs',
fleet: 'fleet',
security: 'security',
'audit-log': 'audit',
'scheduled-ops': 'schedules',
'auto-updates': 'updates',
settings: 'settings',
} as const satisfies Record<ActiveView, string>;
const SEGMENT_TO_VIEW: Record<string, ActiveView> = Object.fromEntries(
Object.entries(VIEW_SEGMENTS).map(([view, seg]) => [seg, view as ActiveView]),
) as Record<string, ActiveView>;
const EDITOR_TABS = new Set<EditorTab>(['compose', 'env', 'files']);
const SECURITY_TABS = new Set<SecurityTab>([
'overview', 'images', 'compose', 'secrets', 'policies', 'suppressions', 'history', 'scanner',
]);
const FLEET_TABS = new Set<FleetTab>([
'overview', 'snapshots', 'configuration', 'dependencies', 'container-labels',
'deployments', 'routing', 'federation', 'actions', 'secrets',
]);
const MAX_QUERY_LEN = 512;
function normalizePathname(pathname: string): string {
const trimmed = pathname.replace(/\/+$/, '') || '/';
return trimmed.toLowerCase();
}
function parseBoundedPositiveInt(raw: string | null): number | null {
if (!raw || raw.length > 12) return null;
if (!/^\d+$/.test(raw)) return null;
const n = Number(raw);
if (!Number.isSafeInteger(n) || n <= 0) return null;
return n;
}
function safeDecodeQueryValue(raw: string): string | null {
if (raw.length > MAX_QUERY_LEN) return null;
try {
return decodeURIComponent(raw);
} catch {
return null;
}
}
export function parsePath(pathname: string, search: string): ParsedRoute {
const empty: ParsedRoute = {
nodeSlug: null,
view: null,
stackName: null,
editorTab: null,
envFile: null,
securityTab: null,
fleetTab: null,
settingsSection: null,
filterNodeId: null,
isStackList: false,
};
const path = normalizePathname(pathname);
if (path === '/') return empty;
const parts = path.split('/').filter(Boolean);
if (parts[0] !== 'nodes' || parts.length < 3) return empty;
const nodeSlug = parts[1];
const segment = parts[2];
let params: URLSearchParams;
try {
params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
} catch {
return { ...empty, nodeSlug };
}
const filterNodeId = parseBoundedPositiveInt(params.get('node'));
const envFile = safeDecodeQueryValue(params.get('env') ?? '');
if (segment === 'stacks') {
if (parts.length === 3) {
return { ...empty, nodeSlug, view: 'dashboard', isStackList: true };
}
const stackName = parts[3];
const tabRaw = parts[4]?.toLowerCase();
const editorTab = tabRaw && EDITOR_TABS.has(tabRaw as EditorTab)
? (tabRaw as EditorTab)
: 'compose';
return {
...empty,
nodeSlug,
view: 'editor',
stackName,
editorTab,
envFile: envFile || null,
filterNodeId,
};
}
const view = SEGMENT_TO_VIEW[segment];
if (!view) return { ...empty, nodeSlug };
const result: ParsedRoute = { ...empty, nodeSlug, view, filterNodeId };
if (view === 'security' && parts[3]) {
const tab = parts[3].toLowerCase();
if (SECURITY_TABS.has(tab as SecurityTab)) result.securityTab = tab as SecurityTab;
}
if (view === 'fleet' && parts[3]) {
const tab = parts[3].toLowerCase();
if (FLEET_TABS.has(tab as FleetTab)) result.fleetTab = tab as FleetTab;
}
if (view === 'settings' && parts[3]) {
result.settingsSection = parts[3] as SectionId;
}
if (view === 'host-console' && parts[3]) {
result.stackName = parts[3];
}
return result;
}
export function buildPath(state: RouteState): string {
const base = `/nodes/${encodeURIComponent(state.nodeSlug)}`;
const url = new URL('http://local');
if (state.activeView === 'editor' && state.stackName) {
const tab = state.editorTab || 'compose';
url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`;
if (tab === 'env' && state.envFile) {
url.searchParams.set('env', state.envFile);
}
if (state.filterNodeId != null) {
url.searchParams.set('node', String(state.filterNodeId));
}
return url.pathname + url.search;
}
if (state.isMobile && state.mobileSurface === 'list') {
url.pathname = `${base}/stacks`;
return url.pathname;
}
if (state.activeView === 'dashboard') {
url.pathname = `${base}/dashboard`;
return url.pathname;
}
const segment = VIEW_SEGMENTS[state.activeView];
url.pathname = `${base}/${segment}`;
if (state.activeView === 'security' && state.securityTab !== 'overview') {
url.pathname += `/${state.securityTab}`;
}
if (state.activeView === 'fleet' && state.fleetActiveTab !== 'overview') {
if (!state.isMobile) {
url.pathname += `/${state.fleetActiveTab}`;
}
}
if (state.activeView === 'settings') {
const section = state.isMobile
? state.settingsSection
: (state.settingsSection ?? 'appearance');
if (section) {
url.pathname += `/${section}`;
}
}
if (state.activeView === 'host-console' && state.stackName) {
url.pathname += `/${encodeURIComponent(state.stackName)}`;
}
if (state.activeView === 'scheduled-ops' && state.filterNodeId != null) {
url.searchParams.set('node', String(state.filterNodeId));
}
return url.pathname + url.search;
}
export { VIEW_SEGMENTS, SEGMENT_TO_VIEW };
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import {
authzReady,
isViewHidden,
normalizeHiddenView,
type ReachabilityContext,
} from './reachability';
function ctx(over: Partial<ReachabilityContext> = {}): ReachabilityContext {
return {
isAdmin: true,
isPaid: true,
can: () => true,
isRemote: false,
hasFleetCapability: true,
containerLabelsEnabled: true,
permissionsStatus: 'ready',
licenseStatus: 'ready',
...over,
};
}
describe('reachability', () => {
it('does not hide views while authz is loading', () => {
const loading = ctx({ permissionsStatus: 'loading' });
expect(authzReady(loading)).toBe(false);
expect(isViewHidden('audit-log', loading)).toBe(false);
});
it('hides hub-only views on remote nodes when ready', () => {
const remote = ctx({ isRemote: true });
expect(isViewHidden('audit-log', remote)).toBe(true);
expect(normalizeHiddenView('audit-log', remote)).toBe('dashboard');
});
it('hides admin-only operator views for non-admins when ready', () => {
const viewer = ctx({ isAdmin: false });
expect(isViewHidden('global-observability', viewer)).toBe(true);
expect(isViewHidden('auto-updates', viewer)).toBe(true);
expect(isViewHidden('scheduled-ops', viewer)).toBe(true);
});
it('hides fleet without node:read when ready', () => {
const noFleet = ctx({ can: () => false });
expect(isViewHidden('fleet', noFleet)).toBe(true);
});
it('preserves paid views when license metadata failed', () => {
const licenseError = ctx({ licenseStatus: 'error' });
expect(isViewHidden('host-console', licenseError)).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import { getSettingsItem } from '@/components/settings/registry';
import type { ActiveView } from '@/lib/router/routeTypes';
import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
export type ReadinessStatus = 'loading' | 'ready' | 'error';
export interface ReachabilityContext {
isAdmin: boolean;
isPaid: boolean;
can: (action: string) => boolean;
isRemote: boolean;
hasFleetCapability: boolean;
containerLabelsEnabled: boolean;
permissionsStatus: ReadinessStatus;
licenseStatus: ReadinessStatus;
}
/** RBAC/tier gates apply only when permission and license metadata are ready. */
export function authzReady(ctx: ReachabilityContext): boolean {
return ctx.permissionsStatus === 'ready' && ctx.licenseStatus === 'ready';
}
/** Role/tier hidden views normalize away only when permission and license metadata are ready. */
export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
if (ctx.isRemote && HUB_ONLY_VIEWS.has(view)) return true;
if (!ctx.isAdmin && view === 'global-observability') return true;
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
if (!ctx.can('node:read') && view === 'fleet') return true;
if (!ctx.isPaid) {
if (view === 'host-console' || view === 'audit-log') return true;
} else {
if (view === 'audit-log' && !ctx.can('system:audit')) return true;
if (view === 'host-console' && !ctx.isAdmin) return true;
}
return false;
}
/** Capability-locked views stay reachable but render a lock card. */
export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
if (view === 'fleet') return !ctx.hasFleetCapability;
return false;
}
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
return false;
}
export function isSettingsSectionHidden(section: SectionId, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
const item = getSettingsItem(section);
if (!item) return true;
if (ctx.isRemote && item.hiddenOnRemote) return true;
if (item.adminOnly && !ctx.isAdmin) return true;
if (item.tier === 'paid' && !ctx.isPaid) return true;
return false;
}
/** Normalize a hidden view to dashboard on the active node. */
export function normalizeHiddenView(view: ActiveView, ctx: ReachabilityContext): ActiveView {
return isViewHidden(view, ctx) ? 'dashboard' : view;
}
+7
View File
@@ -0,0 +1,7 @@
/** Slugify a name into a safe, lowercase URL/filename segment. */
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '') || 'unnamed';
}