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
+136 -12
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Button } from './ui/button';
import { Plus, Loader2, ChevronLeft } from 'lucide-react';
import { Plus, Loader2, ChevronLeft, AlertCircle, RefreshCw } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { NotificationPanel } from './NotificationPanel';
import { TopBar } from './TopBar';
@@ -12,6 +12,8 @@ import { classifyFailedGate } from './EditorLayout/failed-gate-recovery';
import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState';
import { useStackListState } from './EditorLayout/hooks/useStackListState';
import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState';
import { useUrlSync } from './EditorLayout/hooks/useUrlSync';
import { shouldClearPendingDetailStack } from './EditorLayout/mobile-pending-detail';
import { useOverlayState } from './EditorLayout/hooks/useOverlayState';
import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions';
import { useTheme } from '@/hooks/use-theme';
@@ -136,9 +138,14 @@ export default function EditorLayout() {
lastActionResult,
clearActionRecords,
dismissActionResult,
files,
filesNodeId,
stacksLoadStatus,
stacksLoadError,
stacksLoadNodeId,
} = stackListState;
const { nodes, activeNode, setActiveNode, hasCapability } = useNodes();
const { nodes, activeNode, setActiveNode, hasCapability, isLoading: nodesLoading } = useNodes();
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
// post-create handoff) can detect a node switch that happened mid-flight.
@@ -173,12 +180,14 @@ export default function EditorLayout() {
const navState = useViewNavigationState({
onNavigateToDashboard: () => resetEditorStateRef.current(),
hasFleetCapability: hasCapability('fleet'),
containerLabelsEnabled: hasCapability('container-label-inventory'),
});
const {
activeView, setActiveView,
settingsSection, setSettingsSection,
securityTab, setSecurityTab,
fleetTab, setFleetTab,
fleetActiveTab, setFleetActiveTab,
filterNodeId, setFilterNodeId,
schedulePrefill,
muteRulePrefill,
@@ -189,6 +198,7 @@ export default function EditorLayout() {
handleNavigate,
navItems,
openMuteRulesWithPrefill,
reachCtx,
} = navState;
const {
@@ -301,16 +311,12 @@ export default function EditorLayout() {
// `activeView`, so 'dashboard' still maps to HomeDashboard everywhere.
const isMobile = useIsMobile();
const [mobileView, setMobileView] = useState<MobileView>('list');
const [mobileSettingsSection, setMobileSettingsSection] = useState<SectionId | null>(null);
// Optimistically flip to the detail surface the instant a row is tapped,
// before loadFile's fetch resolves selectedFile; cleared once it settles.
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null);
}, [isFileLoading, pendingDetailStack]);
const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []);
const { surface: mobileSurface, detailReady, detailOpen } = deriveMobileSurface({
@@ -320,6 +326,62 @@ export default function EditorLayout() {
pendingDetailStack,
});
const { retryFrozenRoute, urlHydratingStack, routeDetailError } = useUrlSync({
nodes,
nodesLoaded: !nodesLoading,
activeNode,
setActiveNode,
activeView,
setActiveView,
settingsSection,
setSettingsSection,
securityTab,
setSecurityTab,
fleetActiveTab,
setFleetActiveTab,
filterNodeId,
setFilterNodeId,
selectedFile,
files,
filesNodeId,
stacksLoadStatus,
stacksLoadNodeId,
isFileLoading,
activeTab,
setActiveTab,
selectedEnvFile,
envFiles,
loadFileForRoute: stackActions.loadFileForRoute,
changeEnvFile: stackActions.changeEnvFile,
applyEditorRouteState: stackActions.applyEditorRouteState,
refreshStacks,
reachCtx,
isMobile,
mobileSurface,
setMobileSurface: (surface) => {
if (surface === 'list') setMobileView('list');
else if (surface === 'content') setMobileView('content');
},
mobileSettingsSection,
setMobileSettingsSection,
setPendingDetailStack,
attemptPopstateNavigation: stackActions.attemptPopstateNavigation,
});
useEffect(() => {
if (shouldClearPendingDetailStack({
pendingDetailStack,
detailReady,
isFileLoading,
stacksLoadStatus,
urlHydratingStack,
routeDetailError,
})) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPendingDetailStack(null);
}
}, [pendingDetailStack, detailReady, isFileLoading, stacksLoadStatus, urlHydratingStack, routeDetailError]);
// A phone shows one surface at a time, so every mobile navigation tears down
// the current detail and switches surfaces, guarding a dirty editor first.
// `then` runs the destination-specific work (navigate to a view, open
@@ -572,7 +634,7 @@ export default function EditorLayout() {
if (pendingStack) {
void stackActions.loadFile(pendingStack);
} else {
} else if (isRealSwitch) {
setActiveView('dashboard');
}
@@ -687,6 +749,9 @@ export default function EditorLayout() {
filterChip,
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
openMuteRulesWithPrefill,
stacksLoadStatus,
stacksLoadError,
onRetryStacksLoad: () => { void retryFrozenRoute(); },
}}
activitySummary={activitySummary}
onActivityAction={handleActivityAction}
@@ -774,8 +839,8 @@ export default function EditorLayout() {
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
securityTab={securityTab}
onSecurityTabChange={setSecurityTab}
fleetTab={fleetTab}
onFleetTabConsumed={() => setFleetTab(null)}
fleetActiveTab={fleetActiveTab}
onFleetActiveTabChange={setFleetActiveTab}
renderEditor={renderEditor}
stackUpdates={stackUpdates}
/>
@@ -842,7 +907,13 @@ export default function EditorLayout() {
case 'scheduled-ops':
return <MobileSchedules headerActions={mobileMastheadActions} />;
case 'settings':
return <MobileSettings headerActions={mobileMastheadActions} />;
return (
<MobileSettings
headerActions={mobileMastheadActions}
selectedSection={mobileSettingsSection}
onSelectedSectionChange={setMobileSettingsSection}
/>
);
case 'security':
return (
<Suspense
@@ -960,6 +1031,17 @@ export default function EditorLayout() {
{mobileSurface === 'detail' && (
detailReady ? (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor(mobileMastheadActions)}</div>
) : (
(stacksLoadStatus === 'error' && stacksLoadNodeId === activeNode?.id && pendingDetailStack)
|| (routeDetailError && pendingDetailStack)
) ? (
<MobileDetailError
name={pendingDetailStack}
message={routeDetailError ?? stacksLoadError ?? 'Could not load stacks for this node.'}
onBack={goToMobileList}
onRetry={() => { void retryFrozenRoute(); }}
headerActions={mobileMastheadActions}
/>
) : (
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} headerActions={mobileMastheadActions} />
)
@@ -1026,3 +1108,45 @@ function MobileDetailLoading({ name, onBack, headerActions }: { name: string; on
</div>
);
}
function MobileDetailError({
name,
message,
onBack,
onRetry,
headerActions,
}: {
name: string;
message: string;
onBack: () => void;
onRetry: () => void;
headerActions?: ReactNode;
}) {
return (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex items-center gap-1 border-b border-hairline px-3 py-2">
<button
type="button"
onClick={onBack}
aria-label="Back to stacks"
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Stacks
</button>
<span className="min-w-0 flex-1 truncate font-heading text-2xl text-stat-value">
{name.replace(/\.(ya?ml)$/, '')}
</span>
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center text-stat-subtitle">
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm">{message}</p>
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
</div>
</div>
);
}
@@ -94,8 +94,8 @@ export interface ViewRouterProps {
onSecurityTabChange: (tab: SecurityTab) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
fleetTab?: FleetTab | null;
onFleetTabConsumed?: () => void;
fleetActiveTab?: FleetTab;
onFleetActiveTabChange?: (tab: FleetTab) => void;
// Render slot for the inline editor view. Kept as a callback so the
// (large) editor JSX is only allocated when activeView === 'editor',
// not on every parent render that lands on a different view.
@@ -127,8 +127,8 @@ export function ViewRouter({
onSecurityTabChange,
fleetUpdatesIntent,
onFleetUpdatesIntentConsumed,
fleetTab,
onFleetTabConsumed,
fleetActiveTab,
onFleetActiveTabChange,
renderEditor,
stackUpdates,
}: ViewRouterProps): ReactNode {
@@ -202,8 +202,8 @@ export function ViewRouter({
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
onFleetTabConsumed={onFleetTabConsumed}
fleetActiveTab={fleetActiveTab}
onFleetActiveTabChange={onFleetActiveTabChange}
/>
</LazyView>
</CapabilityGate>
@@ -22,9 +22,11 @@ function mockCommunityUser() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: (p: string) => p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
@@ -33,9 +35,11 @@ function mockDeployer() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: (p: string) => p === 'stack:read' || p === 'stack:deploy',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
@@ -43,9 +47,11 @@ function mockPaidAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
@@ -53,9 +59,11 @@ function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
@@ -176,7 +184,7 @@ describe('useViewNavigationState', () => {
);
});
expect(result.current.activeView).toBe('fleet');
expect(result.current.fleetTab).toBe('snapshots');
expect(result.current.fleetActiveTab).toBe('snapshots');
});
it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => {
@@ -45,7 +45,10 @@ export function useOverlayState() {
// bottom-tab / hamburger destination). Wrapped in an object so the state
// setter is not mistaken for a functional update. Runs after the user
// confirms the unsaved-changes dialog. See useStackActions.attemptLeaveEditor.
const [pendingLeaveAction, setPendingLeaveAction] = useState<{ run: () => void } | null>(null);
const [pendingLeaveAction, setPendingLeaveAction] = useState<{
run: () => void;
onCancel?: () => void;
} | null>(null);
const [bashModalOpen, setBashModalOpen] = useState(false);
const [selectedContainer, setSelectedContainer] = useState<Container | null>(null);
@@ -8,6 +8,8 @@ import type { useViewNavigationState } from './useViewNavigationState';
import type { OverlayState } from './useOverlayState';
import type { Node } from '@/context/NodeContext';
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import { parsePath } from '@/lib/router/senchoRoute';
import type { EditorTab } from '@/lib/router/routeTypes';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
@@ -272,6 +274,9 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.content !== editorState.originalContent ||
editorState.envContent !== editorState.originalEnvContent;
const isComposeDirty = () => editorState.content !== editorState.originalContent;
const isEnvDirty = () => editorState.envContent !== editorState.originalEnvContent;
const getStackMenuVisibility = (file: string) => {
// A partial stack has running containers, so it shows the running-stack
// lifecycle actions (stop/restart/update) rather than deploy.
@@ -481,18 +486,22 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const loadFile = async (filename: string) => {
if (!filename) return;
const applyEditorRouteState = (tab: EditorTab) => {
editorState.setActiveTab(tab);
editorState.setEditingCompose(true);
editorState.setIsEditing(false);
};
const loadFileCore = async (filename: string): Promise<boolean> => {
if (!filename) return false;
if (
stackListState.selectedFile &&
filename !== stackListState.selectedFile &&
hasUnsavedChanges()
) {
overlayState.setPendingUnsavedLoad(filename);
return;
return false;
}
// Cancel any in-flight load before starting a new one. A late response
// from the previous stack must not overwrite the freshly-loaded one.
loadFileAbortRef.current?.abort();
const controller = new AbortController();
loadFileAbortRef.current = controller;
@@ -504,9 +513,12 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setActiveTab('compose');
try {
const res = await apiFetch(`/stacks/${filename}`, { signal });
if (signal.aborted) return;
if (signal.aborted) return false;
const text = await res.text();
if (signal.aborted) return;
if (signal.aborted) return false;
if (!res.ok) {
throw new Error(`Failed to load stack: ${res.status}`);
}
stackListState.setSelectedFile(filename);
navState.setActiveView('editor');
editorState.setContent(text || '');
@@ -515,12 +527,10 @@ export function useStackActions(options: UseStackActionsOptions) {
await loadEnvState(filename, signal);
await loadContainerState(filename, signal);
await loadBackupState(filename, signal);
return true;
} catch (error) {
if (isAbortError(error) || signal.aborted) return;
if (isAbortError(error) || signal.aborted) return false;
console.error('Failed to load file:', error);
// Surface the failure so a tap that cannot load (offline, dead remote
// node, 5xx) is not a silent no-op, especially on mobile where the row
// tap optimistically opens the detail surface.
toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
stackListState.setSelectedFile(null);
editorState.setContent('');
@@ -530,6 +540,7 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent('');
editorState.setEnvEtag(null);
editorState.setContainers([]);
return false;
} finally {
if (!signal.aborted) {
editorState.setIsFileLoading(false);
@@ -537,6 +548,14 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const loadFile = async (filename: string) => {
await loadFileCore(filename);
};
const loadFileForRoute = async (filename: string): Promise<boolean> => {
return loadFileCore(filename);
};
// Keep ref in sync so loadFileOnNode always calls the latest loadFile closure
loadFileRef.current = loadFile;
@@ -1209,18 +1228,48 @@ export function useStackActions(options: UseStackActionsOptions) {
// destination. When the editor is dirty the navigation is stashed and the
// unsaved-changes dialog opens; discardAndLoadPending runs it on confirm.
// When clean it runs immediately.
const attemptLeaveEditor = (perform: () => void) => {
const attemptLeaveEditor = (perform: () => void, onCancel?: () => void) => {
if (stackListState.selectedFile && hasUnsavedChanges()) {
overlayState.setPendingLeaveAction({ run: perform });
overlayState.setPendingLeaveAction({ run: perform, onCancel });
return;
}
perform();
};
const wouldDiscardOnPopstate = (): boolean => {
if (!stackListState.selectedFile) return false;
const parsed = parsePath(window.location.pathname, window.location.search);
const targetStack = parsed.stackName;
const targetView = parsed.view;
if (targetView !== 'editor' || !targetStack || targetStack !== stackListState.selectedFile) {
return isComposeDirty() || isEnvDirty();
}
if (
parsed.editorTab === 'env'
&& editorState.activeTab === 'env'
&& parsed.envFile
&& parsed.envFile !== editorState.selectedEnvFile
) {
return isEnvDirty();
}
return false;
};
const attemptPopstateNavigation = (apply: () => void, onCancel: () => void) => {
if (wouldDiscardOnPopstate()) {
overlayState.setPendingLeaveAction({ run: apply, onCancel });
return;
}
apply();
};
const cancelPendingUnsavedLoad = () => {
const cancel = overlayState.pendingLeaveAction?.onCancel;
overlayState.setPendingUnsavedLoad(null);
overlayState.setPendingUnsavedNode(null);
overlayState.setPendingLeaveAction(null);
cancel?.();
};
const discardAndLoadPending = () => {
@@ -1398,7 +1447,9 @@ export function useStackActions(options: UseStackActionsOptions) {
refreshSelectedContainers,
refreshGitSourcePending,
loadFile,
loadFileForRoute,
loadFileOnNode,
applyEditorRouteState,
navigateToNotification,
changeEnvFile,
saveFile,
@@ -1419,6 +1470,7 @@ export function useStackActions(options: UseStackActionsOptions) {
requestStackUpdate,
deleteStack,
attemptLeaveEditor,
attemptPopstateNavigation,
cancelPendingUnsavedLoad,
discardAndLoadPending,
requestDeleteStack,
@@ -64,6 +64,8 @@ export interface RemoteResult {
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
export type StacksLoadStatus = 'idle' | 'loading' | 'success' | 'error';
export function useStackListState() {
const { nodes, activeNode } = useNodes();
@@ -100,6 +102,10 @@ export function useStackListState() {
const [filterChip, setFilterChip] = useState<FilterChip>('all');
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [stacksLoadStatus, setStacksLoadStatus] = useState<StacksLoadStatus>('idle');
const [stacksLoadError, setStacksLoadError] = useState<string | null>(null);
const [stacksLoadNodeId, setStacksLoadNodeId] = useState<number | null>(null);
const hadSuccessfulListRef = useRef(false);
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
@@ -117,6 +123,12 @@ export function useStackListState() {
if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).');
}, [evictedOldest]);
useEffect(() => {
hadSuccessfulListRef.current = false;
setStacksLoadStatus('idle');
setStacksLoadError(null);
}, [activeNode?.id]);
// Ref is updated synchronously alongside the state setter so any code that
// runs right after (e.g. `refreshStacks(true)` in an action's finally block)
// observes the cleared map before React commits the next render. Without
@@ -180,25 +192,39 @@ export function useStackListState() {
}, [refreshLabels]);
const refreshStacks = async (background = false): Promise<string[]> => {
if (!background) setIsLoading(true);
// Snapshot the node this fetch targets and a sequence token so a superseded
// or out-of-order resolution (from a rapid node switch) cannot overwrite a
// newer node's list, keeping `files` and `filesNodeId` consistent.
const fetchNodeId = activeNode?.id ?? null;
const mySeq = ++fetchSeqRef.current;
const stale = () => fetchSeqRef.current !== mySeq;
if (!background) setIsLoading(true);
setStacksLoadNodeId(fetchNodeId);
if (!background || !hadSuccessfulListRef.current) {
setStacksLoadStatus('loading');
setStacksLoadError(null);
}
try {
const res = await apiFetch('/stacks');
if (stale()) return [];
if (!res.ok) {
const message = `Could not load stacks (${res.status})`;
if (background && hadSuccessfulListRef.current) {
setStacksLoadError(message);
return files;
}
setFiles([]);
setFilesNodeId(fetchNodeId);
setStacksLoadStatus('error');
setStacksLoadError(message);
return [];
}
const data = await res.json();
const fileList: string[] = Array.isArray(data) ? data : [];
setFiles(fileList);
setFilesNodeId(fetchNodeId);
hadSuccessfulListRef.current = true;
setStacksLoadStatus('success');
setStacksLoadError(null);
// Fetch all stack statuses in a single bulk call. Only the current object
// format can express `partial`; a node lacking the endpoint or returning
@@ -244,8 +270,15 @@ export function useStackListState() {
} catch (error) {
if (stale()) return [];
console.error('Failed to refresh stacks:', error);
const message = error instanceof Error ? error.message : 'Failed to load stacks';
if (background && hadSuccessfulListRef.current) {
setStacksLoadError(message);
return files;
}
setFiles([]);
setFilesNodeId(fetchNodeId);
setStacksLoadStatus('error');
setStacksLoadError(message);
return [];
} finally {
setIsLoading(false);
@@ -434,5 +467,8 @@ export function useStackListState() {
isCollapsed, toggleCollapse,
remoteSearchLoading,
remoteSearchFailedNodes,
stacksLoadStatus,
stacksLoadError,
stacksLoadNodeId,
} as const;
}
@@ -0,0 +1,340 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { Node } from '@/context/NodeContext';
import type { ReachabilityContext } from '@/lib/routing/reachability';
import { useUrlSync, type UseUrlSyncOptions } from './useUrlSync';
function makeNode(over: Partial<Node> = {}): Node {
return {
id: 1,
name: 'local',
type: 'local',
is_default: true,
url: 'http://127.0.0.1:1852',
compose_dir: '/compose',
...over,
} as Node;
}
function makeReachCtx(over: Partial<ReachabilityContext> = {}): ReachabilityContext {
return {
isAdmin: true,
isPaid: true,
can: () => true,
isRemote: false,
hasFleetCapability: true,
containerLabelsEnabled: true,
permissionsStatus: 'ready',
licenseStatus: 'ready',
...over,
};
}
function makeOpts(over: Partial<UseUrlSyncOptions> = {}): UseUrlSyncOptions {
const node = makeNode();
return {
nodes: [node],
nodesLoaded: true,
activeNode: node,
setActiveNode: vi.fn(),
activeView: 'dashboard',
setActiveView: vi.fn(),
settingsSection: 'appearance',
setSettingsSection: vi.fn(),
securityTab: 'overview',
setSecurityTab: vi.fn(),
fleetActiveTab: 'overview',
setFleetActiveTab: vi.fn(),
filterNodeId: null,
setFilterNodeId: vi.fn(),
selectedFile: null,
files: ['radarr'],
filesNodeId: 1,
stacksLoadStatus: 'success',
stacksLoadNodeId: 1,
isFileLoading: false,
activeTab: 'compose',
setActiveTab: vi.fn(),
selectedEnvFile: '',
envFiles: [],
loadFileForRoute: vi.fn().mockResolvedValue(true),
changeEnvFile: vi.fn().mockResolvedValue(undefined),
applyEditorRouteState: vi.fn(),
refreshStacks: vi.fn().mockResolvedValue(['radarr']),
reachCtx: makeReachCtx(),
isMobile: false,
mobileSurface: null,
setMobileSurface: vi.fn(),
mobileSettingsSection: null,
setMobileSettingsSection: vi.fn(),
setPendingDetailStack: vi.fn(),
attemptPopstateNavigation: (apply) => { apply(); },
...over,
};
}
describe('useUrlSync', () => {
beforeEach(() => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/dashboard');
});
it('mounts and hydrates when history state lacks senchoIdx', () => {
window.history.replaceState({}, '', '/nodes/local/security');
const setSecurityTab = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'security',
setSecurityTab,
}),
},
);
});
expect(window.location.pathname).toBe('/nodes/local/security');
});
it('pushState increments senchoIdx on user navigation', () => {
const pushSpy = vi.spyOn(window.history, 'pushState');
const { rerender } = renderHook(
(props) => useUrlSync(props),
{ initialProps: makeOpts({ activeView: 'dashboard' }) },
);
act(() => {
rerender(makeOpts({ activeView: 'resources' }));
});
const pushed = pushSpy.mock.calls.find(call => String(call[2]).includes('/resources'));
expect(pushed).toBeDefined();
expect((pushed?.[0] as { senchoIdx?: number }).senchoIdx).toBe(1);
pushSpy.mockRestore();
});
it('keeps a pending stack deep link when stack list load fails', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const setActiveView = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'editor',
setActiveView,
files: [],
stacksLoadStatus: 'error',
stacksLoadNodeId: 1,
}),
},
);
});
expect(setActiveView).not.toHaveBeenCalledWith('dashboard');
expect(window.location.pathname).toBe('/nodes/local/stacks/radarr/compose');
});
it('routes popstate through attemptPopstateNavigation', () => {
const attempt = vi.fn();
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
attemptPopstateNavigation: attempt,
}),
},
);
act(() => {
window.history.pushState({ senchoIdx: 1 }, '', '/nodes/local/resources');
window.dispatchEvent(new PopStateEvent('popstate', { state: { senchoIdx: 0 } }));
});
expect(attempt).toHaveBeenCalledTimes(1);
expect(attempt.mock.calls[0]).toHaveLength(2);
});
it('does not normalize a paid URL while permissions metadata is still loading', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/audit');
const setActiveView = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'audit-log',
setActiveView,
reachCtx: makeReachCtx({ permissionsStatus: 'loading' }),
}),
},
);
});
expect(setActiveView).not.toHaveBeenCalledWith('dashboard');
expect(window.location.pathname).toBe('/nodes/local/audit');
});
it('retryFrozenRoute triggers a foreground stack refresh', async () => {
const refreshStacks = vi.fn().mockResolvedValue(['radarr']);
const { result } = renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
refreshStacks,
stacksLoadStatus: 'error',
files: [],
}),
},
);
await act(async () => {
await result.current.retryFrozenRoute();
});
expect(refreshStacks).toHaveBeenCalledWith(false);
});
it('hydrates fleet view from URL', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/fleet');
const setActiveView = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'fleet',
setActiveView,
}),
},
);
});
expect(setActiveView).toHaveBeenCalledWith('fleet');
});
it('normalizes unknown view segments to dashboard', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/not-a-view');
const setActiveView = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'dashboard',
setActiveView,
}),
},
);
});
expect(window.location.pathname).toBe('/nodes/local/dashboard');
});
it('hydrates mobile dashboard to the content surface', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/dashboard');
const setMobileSurface = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
isMobile: true,
mobileSurface: null,
setMobileSurface,
}),
},
);
});
expect(setMobileSurface).toHaveBeenCalledWith('content');
});
it('sets pending detail stack on mobile stack URL hydrate', () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const setPendingDetailStack = vi.fn();
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
isMobile: true,
setPendingDetailStack,
}),
},
);
});
expect(setPendingDetailStack).toHaveBeenCalledWith('radarr');
});
it('freezes route and sets routeDetailError when compose load fails', async () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const loadFileForRoute = vi.fn().mockResolvedValue(false);
const setPendingDetailStack = vi.fn();
const { result } = renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
isMobile: true,
setPendingDetailStack,
loadFileForRoute,
activeView: 'editor',
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
expect(result.current.routeDetailError).toContain('Could not open');
expect(window.location.pathname).toBe('/nodes/local/stacks/radarr/compose');
});
it('clears routeDetailError after a successful retry', async () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const loadFileForRoute = vi.fn().mockResolvedValue(false);
const { result, rerender } = renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
isMobile: true,
loadFileForRoute,
activeView: 'editor',
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.routeDetailError).not.toBeNull();
loadFileForRoute.mockResolvedValue(true);
rerender(makeOpts({
isMobile: true,
loadFileForRoute,
activeView: 'editor',
selectedFile: 'radarr',
}));
await act(async () => {
await result.current.retryFrozenRoute();
});
expect(result.current.routeDetailError).toBeNull();
});
});
@@ -0,0 +1,478 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { Node } from '@/context/NodeContext';
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView, EditorTab, MobileRouteSurface } from '@/lib/router/routeTypes';
import { buildPath, parsePath } from '@/lib/router/senchoRoute';
import { nodeIdToSlug, slugToNodeId } from '@/lib/nodeSlug';
import {
authzReady,
isFleetTabHidden,
isSettingsSectionHidden,
isViewHidden,
normalizeHiddenView,
type ReachabilityContext,
} from '@/lib/routing/reachability';
import type { StacksLoadStatus } from './useStackListState';
type RoutePhase = 'initial' | 'applying' | 'settled' | 'frozen';
type RouteIntent = 'push' | 'replace' | 'none';
interface HistoryState {
senchoIdx?: number;
}
interface PendingRoute {
view: ActiveView;
stackName: string | null;
editorTab: EditorTab;
envFile: string | null;
securityTab: SecurityTab;
fleetTab: FleetTab;
settingsSection: SectionId | null;
filterNodeId: number | null;
isStackList: boolean;
}
export interface UseUrlSyncOptions {
nodes: Node[];
nodesLoaded: boolean;
activeNode: Node | null;
setActiveNode: (node: Node) => void;
activeView: ActiveView;
setActiveView: (view: ActiveView) => void;
settingsSection: SectionId;
setSettingsSection: (section: SectionId) => void;
securityTab: SecurityTab;
setSecurityTab: (tab: SecurityTab) => void;
fleetActiveTab: FleetTab;
setFleetActiveTab: (tab: FleetTab) => void;
filterNodeId: number | null;
setFilterNodeId: (id: number | null) => void;
selectedFile: string | null;
files: string[];
filesNodeId: number | null;
stacksLoadStatus: StacksLoadStatus;
stacksLoadNodeId: number | null;
isFileLoading: boolean;
activeTab: EditorTab;
setActiveTab: (tab: EditorTab) => void;
selectedEnvFile: string;
envFiles: string[];
loadFileForRoute: (filename: string) => Promise<boolean>;
changeEnvFile: (file: string) => Promise<void>;
applyEditorRouteState: (tab: EditorTab) => void;
refreshStacks: (background?: boolean) => Promise<string[]>;
reachCtx: ReachabilityContext;
isMobile: boolean;
mobileSurface: MobileRouteSurface | null;
setMobileSurface: (surface: MobileRouteSurface) => void;
mobileSettingsSection: SectionId | null;
setMobileSettingsSection: (section: SectionId | null) => void;
setPendingDetailStack: (stack: string | null) => void;
attemptPopstateNavigation: (apply: () => void, onCancel: () => void) => void;
}
function currentPath(): string {
return window.location.pathname + window.location.search;
}
function readIdx(state: unknown): number | null {
if (state && typeof state === 'object' && 'senchoIdx' in state) {
const v = (state as HistoryState).senchoIdx;
return typeof v === 'number' ? v : null;
}
return null;
}
export function useUrlSync(options: UseUrlSyncOptions) {
const optsRef = useRef(options);
optsRef.current = options;
const [routeDetailError, setRouteDetailError] = useState<string | null>(null);
const [urlHydratingStack, setUrlHydratingStack] = useState<string | null>(null);
const phaseRef = useRef<RoutePhase>('initial');
const historyIdxRef = useRef(0);
const suppressNextPopstateRef = useRef(false);
const pendingRouteRef = useRef<PendingRoute | null>(null);
const pendingStackRef = useRef<string | null>(null);
const pendingEnvRef = useRef<string | null>(null);
const pendingTabRef = useRef<EditorTab | null>(null);
const initialHydratedRef = useRef(false);
const routeReplaceRef = useRef(false);
const appliedViewRef = useRef<ActiveView | null>(null);
const resolvingRef = useRef(false);
const writeHistory = useCallback((path: string, intent: RouteIntent) => {
const prev = window.history.state as HistoryState | null;
const base: HistoryState = prev && typeof prev === 'object' ? { ...prev } : {};
if (intent === 'push') {
historyIdxRef.current += 1;
base.senchoIdx = historyIdxRef.current;
window.history.pushState(base, '', path);
} else {
base.senchoIdx = historyIdxRef.current;
window.history.replaceState(base, '', path);
}
}, []);
const buildCurrentPath = useCallback(() => {
const o = optsRef.current;
const slug = o.activeNode ? nodeIdToSlug(o.activeNode.id, o.nodes) : null;
if (!slug) return null;
const view = authzReady(o.reachCtx) ? normalizeHiddenView(o.activeView, o.reachCtx) : o.activeView;
let mobileSurface: MobileRouteSurface | null = o.mobileSurface;
if (!o.isMobile) mobileSurface = null;
return buildPath({
nodeSlug: slug,
activeView: view,
stackName: o.selectedFile,
editorTab: o.activeTab,
envFile: o.selectedEnvFile || null,
securityTab: o.securityTab,
fleetActiveTab: o.fleetActiveTab,
settingsSection: o.isMobile ? o.mobileSettingsSection : o.settingsSection,
filterNodeId: o.filterNodeId,
mobileSurface,
isMobile: o.isMobile,
});
}, []);
const applyPendingRoute = useCallback(async () => {
const o = optsRef.current;
const pending = pendingRouteRef.current;
if (!pending) return;
let view = pending.view;
if (authzReady(o.reachCtx)) {
view = normalizeHiddenView(view, o.reachCtx);
}
if (pending.isStackList && o.isMobile) {
o.setMobileSurface('list');
o.setActiveView('dashboard');
} else {
o.setActiveView(view);
if (o.isMobile && view !== 'editor') {
o.setMobileSurface('content');
}
}
if (pending.securityTab) o.setSecurityTab(pending.securityTab);
if (pending.fleetTab) o.setFleetActiveTab(pending.fleetTab);
if (pending.settingsSection) {
if (o.isMobile) o.setMobileSettingsSection(pending.settingsSection);
else o.setSettingsSection(pending.settingsSection);
}
if (pending.filterNodeId != null) o.setFilterNodeId(pending.filterNodeId);
if (pending.stackName) {
pendingStackRef.current = pending.stackName;
pendingEnvRef.current = pending.envFile;
pendingTabRef.current = pending.editorTab;
setUrlHydratingStack(pending.stackName);
if (o.isMobile) {
o.setPendingDetailStack(pending.stackName);
}
} else {
setUrlHydratingStack(null);
setRouteDetailError(null);
pendingRouteRef.current = null;
if (o.activeView === view) {
appliedViewRef.current = null;
phaseRef.current = 'settled';
} else {
appliedViewRef.current = view;
phaseRef.current = 'applying';
}
}
}, []);
const resolvePendingStack = useCallback(async () => {
if (resolvingRef.current) return;
const o = optsRef.current;
const stack = pendingStackRef.current;
if (!stack || !o.activeNode) return;
if (o.filesNodeId !== o.activeNode.id) return;
if (o.stacksLoadStatus === 'loading' || o.stacksLoadStatus === 'idle') return;
if (o.stacksLoadStatus === 'error' && o.stacksLoadNodeId === o.activeNode.id) {
phaseRef.current = 'frozen';
pendingRouteRef.current = null;
return;
}
const match = o.files.find(f => f === stack)
?? o.files.find(f => f.toLowerCase() === stack.toLowerCase());
if (!match) {
routeReplaceRef.current = true;
pendingStackRef.current = null;
pendingRouteRef.current = null;
setUrlHydratingStack(null);
setRouteDetailError(null);
if (o.isMobile) o.setPendingDetailStack(null);
o.setActiveView('dashboard');
phaseRef.current = 'settled';
return;
}
// If the file is already loaded, apply route state without reloading
// to avoid unmounting the editor (loadFileCore clears selectedFile on
// failure, which would hide the recovery chip during a deploy).
if (o.selectedFile === match) {
const env = pendingEnvRef.current;
const tab = pendingTabRef.current ?? 'compose';
if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) {
await o.changeEnvFile(env);
}
o.setActiveTab(tab);
o.applyEditorRouteState(tab);
pendingStackRef.current = null;
pendingEnvRef.current = null;
pendingTabRef.current = null;
pendingRouteRef.current = null;
setUrlHydratingStack(null);
setRouteDetailError(null);
phaseRef.current = 'settled';
return;
}
resolvingRef.current = true;
const attempted = match;
const loaded = await o.loadFileForRoute(match);
if (pendingStackRef.current !== attempted) { resolvingRef.current = false; return; }
if (!loaded) {
phaseRef.current = 'frozen';
pendingRouteRef.current = null;
setRouteDetailError(`Could not open "${attempted.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
resolvingRef.current = false;
return;
}
const env = pendingEnvRef.current;
const tab = pendingTabRef.current ?? 'compose';
if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) {
await o.changeEnvFile(env);
}
o.setActiveTab(tab);
o.applyEditorRouteState(tab);
pendingStackRef.current = null;
pendingEnvRef.current = null;
pendingTabRef.current = null;
pendingRouteRef.current = null;
setUrlHydratingStack(null);
setRouteDetailError(null);
phaseRef.current = 'settled';
resolvingRef.current = false;
}, []);
const hydrateFromUrl = useCallback((pathname: string, search: string) => {
const o = optsRef.current;
if (!o.nodesLoaded || o.nodes.length === 0) return;
const parsed = parsePath(pathname, search);
if (!parsed.nodeSlug) {
const slug = o.activeNode ? nodeIdToSlug(o.activeNode.id, o.nodes) : null;
if (slug) {
routeReplaceRef.current = true;
writeHistory(`/nodes/${slug}/dashboard`, 'replace');
}
phaseRef.current = 'settled';
return;
}
const nodeId = slugToNodeId(parsed.nodeSlug, o.nodes);
if (nodeId == null) {
const fallback = o.activeNode ?? o.nodes[0];
if (fallback) {
routeReplaceRef.current = true;
const slug = nodeIdToSlug(fallback.id, o.nodes);
if (slug) writeHistory(`/nodes/${slug}/dashboard`, 'replace');
}
phaseRef.current = 'settled';
return;
}
const node = o.nodes.find(n => n.id === nodeId);
if (!node) return;
if (parsed.nodeSlug && parsed.view == null && !parsed.isStackList) {
routeReplaceRef.current = true;
const slug = nodeIdToSlug(node.id, o.nodes);
if (slug) writeHistory(`/nodes/${slug}/dashboard`, 'replace');
phaseRef.current = 'settled';
if (o.activeView !== 'dashboard') o.setActiveView('dashboard');
return;
}
let view = parsed.view ?? 'dashboard';
let fleetTab = parsed.fleetTab ?? 'overview';
if (parsed.fleetTab && isFleetTabHidden(parsed.fleetTab, o.reachCtx)) {
fleetTab = 'overview';
routeReplaceRef.current = true;
}
let settingsSection = parsed.settingsSection as SectionId | null;
if (settingsSection && isSettingsSectionHidden(settingsSection, o.reachCtx)) {
settingsSection = null;
routeReplaceRef.current = true;
}
if (view && isViewHidden(view, o.reachCtx)) {
view = 'dashboard';
routeReplaceRef.current = true;
}
if (o.isMobile && view === 'fleet' && parsed.fleetTab && parsed.fleetTab !== 'overview') {
fleetTab = 'overview';
routeReplaceRef.current = true;
}
pendingRouteRef.current = {
view,
stackName: parsed.stackName,
editorTab: parsed.editorTab ?? 'compose',
envFile: parsed.envFile,
securityTab: parsed.securityTab ?? 'overview',
fleetTab,
settingsSection,
filterNodeId: parsed.filterNodeId,
isStackList: parsed.isStackList,
};
phaseRef.current = 'applying';
if (o.activeNode?.id !== node.id) {
o.setActiveNode(node);
} else {
void applyPendingRoute();
}
}, [applyPendingRoute, writeHistory]);
useEffect(() => {
const base: HistoryState = readIdx(window.history.state) != null
? (window.history.state as HistoryState)
: { senchoIdx: 0 };
if (readIdx(base) == null) {
base.senchoIdx = 0;
window.history.replaceState(base, '', window.location.pathname + window.location.search);
}
historyIdxRef.current = base.senchoIdx ?? 0;
}, []);
useEffect(() => {
if (!options.nodesLoaded || options.nodes.length === 0) return;
if (initialHydratedRef.current) return;
initialHydratedRef.current = true;
hydrateFromUrl(window.location.pathname, window.location.search);
}, [hydrateFromUrl, options.nodesLoaded, options.nodes.length]);
useEffect(() => {
if (pendingRouteRef.current && optsRef.current.activeNode) {
void applyPendingRoute();
}
}, [applyPendingRoute, options.activeNode?.id]);
useEffect(() => {
void resolvePendingStack();
}, [
resolvePendingStack,
options.files,
options.filesNodeId,
options.stacksLoadStatus,
options.stacksLoadNodeId,
options.selectedFile,
options.isFileLoading,
options.envFiles,
urlHydratingStack,
]);
useEffect(() => {
if (phaseRef.current !== 'applying') return;
if (pendingStackRef.current) return;
const applied = appliedViewRef.current;
if (applied == null) return;
if (options.activeView !== applied) return;
appliedViewRef.current = null;
phaseRef.current = 'settled';
}, [options.activeView]);
useEffect(() => {
if (phaseRef.current !== 'settled') return;
if (options.isFileLoading) return;
if (pendingStackRef.current) return;
if (options.activeView === 'editor' && !options.selectedFile) return;
const target = buildCurrentPath();
if (!target || target === currentPath()) return;
const intent: RouteIntent = routeReplaceRef.current ? 'replace' : 'push';
routeReplaceRef.current = false;
writeHistory(target, intent);
}, [
buildCurrentPath,
writeHistory,
options.activeView,
options.selectedFile,
options.activeTab,
options.selectedEnvFile,
options.securityTab,
options.fleetActiveTab,
options.settingsSection,
options.filterNodeId,
options.activeNode?.id,
options.isMobile,
options.mobileSurface,
options.mobileSettingsSection,
options.isFileLoading,
options.reachCtx,
]);
useEffect(() => {
const onPopstate = (event: PopStateEvent) => {
if (suppressNextPopstateRef.current) {
suppressNextPopstateRef.current = false;
return;
}
const newIdx = readIdx(event.state) ?? readIdx(window.history.state);
const oldIdx = historyIdxRef.current;
const delta = newIdx != null ? newIdx - oldIdx : -1;
if (newIdx != null) historyIdxRef.current = newIdx;
const apply = () => {
phaseRef.current = 'applying';
hydrateFromUrl(window.location.pathname, window.location.search);
};
const cancel = () => {
if (delta === 0) return;
suppressNextPopstateRef.current = true;
window.history.go(-delta);
};
optsRef.current.attemptPopstateNavigation(apply, cancel);
};
window.addEventListener('popstate', onPopstate);
return () => window.removeEventListener('popstate', onPopstate);
}, [hydrateFromUrl]);
const prevIsMobileRef = useRef(options.isMobile);
useEffect(() => {
if (prevIsMobileRef.current !== options.isMobile) {
prevIsMobileRef.current = options.isMobile;
routeReplaceRef.current = true;
}
}, [options.isMobile]);
const retryFrozenRoute = useCallback(() => {
setRouteDetailError(null);
phaseRef.current = 'applying';
void optsRef.current.refreshStacks(false).then(() => {
void resolvePendingStack();
});
}, [resolvePendingStack]);
return { retryFrozenRoute, urlHydratingStack, routeDetailError };
}
@@ -13,34 +13,18 @@ import type { SecurityTab, FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from '@/lib/router/routeTypes';
import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
import { readUrlRouteState } from '@/lib/router/readUrlRouteState';
import {
authzReady,
isViewHidden,
normalizeHiddenView,
type ReachabilityContext,
} from '@/lib/routing/reachability';
export type ActiveView =
| 'dashboard'
| 'editor'
| 'host-console'
| 'resources'
| 'templates'
| 'global-observability'
| 'fleet'
| 'security'
| 'audit-log'
| 'scheduled-ops'
| '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 type { ActiveView };
export { HUB_ONLY_VIEWS };
export interface NavItem {
value: ActiveView;
@@ -50,24 +34,39 @@ export interface NavItem {
interface UseViewNavigationStateOptions {
onNavigateToDashboard?: () => void;
hasFleetCapability?: boolean;
containerLabelsEnabled?: boolean;
}
export function useViewNavigationState(options?: UseViewNavigationStateOptions) {
const { onNavigateToDashboard } = options ?? {};
const { isAdmin, can } = useAuth();
const { isPaid } = useLicense();
const { onNavigateToDashboard, hasFleetCapability = false, containerLabelsEnabled = false } = options ?? {};
const { isAdmin, can, permissionsStatus } = useAuth();
const { isPaid, licenseStatus } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const initialRoute = readUrlRouteState();
const [activeView, setActiveView] = useState<ActiveView>(initialRoute.activeView);
const [settingsSection, setSettingsSection] = useState<SectionId>(initialRoute.settingsSection);
const [securityTab, setSecurityTab] = useState<SecurityTab>(initialRoute.securityTab);
const [fleetActiveTab, setFleetActiveTab] = useState<FleetTab>(initialRoute.fleetActiveTab);
const [filterNodeId, setFilterNodeId] = useState<number | null>(initialRoute.filterNodeId);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const reachCtx: ReachabilityContext = useMemo(() => ({
isAdmin,
isPaid,
can: (action: string) => can(action as Parameters<typeof can>[0]),
isRemote,
hasFleetCapability,
containerLabelsEnabled,
permissionsStatus,
licenseStatus,
}), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus]);
const handleOpenSettings = useCallback((section?: SectionId) => {
if (section) setSettingsSection(section);
setActiveView('settings');
@@ -86,6 +85,9 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const handleNavigate = useCallback((value: string) => {
if (value === activeView) return;
if (value === 'fleet') {
setFleetActiveTab('overview');
}
if (value === 'dashboard') {
onNavigateToDashboard?.();
setActiveView('dashboard');
@@ -100,17 +102,13 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const detail = (e as CustomEvent<SenchoNavigateDetail & { view: string }>).detail;
if (!detail?.view) return;
if (detail.view === 'security') {
// Set the target tab before switching the view so the controlled
// SecurityView lands on it deterministically (no mount race).
setSecurityTab(detail.tab ?? 'overview');
setActiveView('security');
setFilterNodeId(detail.nodeId ?? null);
return;
}
if (detail.view === 'fleet') {
// Set the target sub-tab before switching so the controlled FleetView
// lands on it (e.g. Snapshots from the stack storage warning).
if (detail.fleetTab) setFleetTab(detail.fleetTab);
if (detail.fleetTab) setFleetActiveTab(detail.fleetTab);
setActiveView('fleet');
setFilterNodeId(detail.nodeId ?? null);
return;
@@ -126,54 +124,47 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const items: NavItem[] = [
{ value: 'dashboard', label: 'Home', icon: Home },
];
// Fleet surfaces node topology and host stats, so it is gated on node:read
// (held by every role except deployer), matching the backend guard on the
// fleet overview / configuration / dependency / networking reads.
if (can('node:read')) items.push({ value: 'fleet', label: 'Fleet', icon: Radar });
if (!isViewHidden('fleet', reachCtx)) {
items.push({ value: 'fleet', label: 'Fleet', icon: Radar });
}
items.push(
{ value: 'resources', label: 'Resources', icon: HardDrive },
// Security is a Community, node-scoped review surface (not hub-only), so
// it shows for every authenticated user and on remote nodes too.
{ value: 'security', label: 'Security', icon: ShieldCheck },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
);
// The aggregated Logs feed crosses every managed stack, so it is an
// admin-only operator view (the backend gates the same routes on admin).
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
if (isAdmin) {
if (!isViewHidden('global-observability', reachCtx)) {
items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
}
if (!isViewHidden('auto-updates', reachCtx)) {
items.push({ value: 'auto-updates', label: 'Update', icon: RefreshCw });
}
if (!isViewHidden('scheduled-ops', reachCtx)) {
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
if (isPaid) {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (!isViewHidden('host-console', reachCtx)) {
items.push({ value: 'host-console', label: 'Console', icon: Terminal });
}
return isRemote
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))
: items;
}, [isAdmin, isPaid, can, isRemote]);
if (!isViewHidden('audit-log', reachCtx)) {
items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
}
return items;
}, [reachCtx]);
useEffect(() => {
// Redirect off a view the active context can't reach: a hub-only view while
// a remote node is active, the admin-only Logs view as a non-admin, or the
// Fleet view without node:read (e.g. arrived via a deep-link event rather
// than the now-hidden nav item).
const blockedByRemote = isRemote && HUB_ONLY_VIEWS.has(activeView);
const blockedByRole =
(!isAdmin && activeView === 'global-observability')
|| (!can('node:read') && activeView === 'fleet');
if (blockedByRemote || blockedByRole) {
if (!authzReady(reachCtx)) return;
const normalized = normalizeHiddenView(activeView, reachCtx);
if (normalized !== activeView) {
onNavigateToDashboard?.();
setActiveView('dashboard');
setActiveView(normalized);
setFilterNodeId(null);
}
}, [isRemote, isAdmin, can, activeView, onNavigateToDashboard]);
}, [reachCtx, activeView, onNavigateToDashboard]);
return {
activeView, setActiveView,
settingsSection, setSettingsSection,
securityTab, setSecurityTab,
fleetTab, setFleetTab,
fleetActiveTab, setFleetActiveTab,
filterNodeId, setFilterNodeId,
schedulePrefill, setSchedulePrefill,
muteRulePrefill, setMuteRulePrefill,
@@ -184,5 +175,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
openMuteRulesWithPrefill,
handleNavigate,
navItems,
reachCtx,
} as const;
}
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { shouldClearPendingDetailStack } from './mobile-pending-detail';
describe('shouldClearPendingDetailStack', () => {
const base = {
pendingDetailStack: 'radarr',
detailReady: false,
isFileLoading: false,
stacksLoadStatus: 'success' as const,
urlHydratingStack: null,
routeDetailError: null,
};
it('returns false when there is no pending stack', () => {
expect(shouldClearPendingDetailStack({ ...base, pendingDetailStack: null })).toBe(false);
});
it('does not clear while stacks are loading during URL hydration', () => {
expect(shouldClearPendingDetailStack({
...base,
stacksLoadStatus: 'loading',
urlHydratingStack: 'radarr',
})).toBe(false);
});
it('does not clear while a route detail error is shown', () => {
expect(shouldClearPendingDetailStack({
...base,
routeDetailError: 'Could not open stack',
})).toBe(false);
});
it('clears when the detail surface is ready', () => {
expect(shouldClearPendingDetailStack({ ...base, detailReady: true })).toBe(true);
});
it('does not clear while compose is still loading', () => {
expect(shouldClearPendingDetailStack({ ...base, isFileLoading: true })).toBe(false);
});
});
@@ -0,0 +1,21 @@
import type { StacksLoadStatus } from './hooks/useStackListState';
export interface PendingDetailClearInput {
pendingDetailStack: string | null;
detailReady: boolean;
isFileLoading: boolean;
stacksLoadStatus: StacksLoadStatus;
urlHydratingStack: string | null;
routeDetailError: string | null;
}
/** Whether the optimistic mobile detail placeholder can be cleared. */
export function shouldClearPendingDetailStack(input: PendingDetailClearInput): boolean {
if (!input.pendingDetailStack) return false;
if (input.routeDetailError) return false;
if (input.urlHydratingStack) return false;
if (input.detailReady) return true;
if (input.isFileLoading) return false;
if (input.stacksLoadStatus === 'loading' || input.stacksLoadStatus === 'idle') return false;
return false;
}
+18 -14
View File
@@ -44,12 +44,20 @@ interface FleetViewProps {
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
fleetTab?: FleetTab | null;
onFleetTabConsumed?: () => void;
/** Controlled fleet sub-tab (shell-owned for URL sync). */
fleetActiveTab?: FleetTab;
onFleetActiveTabChange?: (tab: FleetTab) => void;
}
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
export function FleetView({
onNavigateToNode,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
fleetUpdatesIntent,
onFleetUpdatesIntentConsumed,
fleetActiveTab: controlledTab,
onFleetActiveTabChange,
}: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
@@ -73,9 +81,12 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
// Controlled tab value so a deep-link (e.g. Snapshots from the stack storage
// warning) can land on the right tab.
const [activeTab, setActiveTab] = useState<FleetTab>('overview');
const [internalTab, setInternalTab] = useState<FleetTab>('overview');
const activeTab = controlledTab ?? internalTab;
const setActiveTab = (tab: FleetTab) => {
onFleetActiveTabChange?.(tab);
if (controlledTab === undefined) setInternalTab(tab);
};
useEffect(() => {
if (fleetUpdatesIntent) {
@@ -86,13 +97,6 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
}
}, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]);
useEffect(() => {
if (fleetTab) {
setActiveTab(fleetTab);
onFleetTabConsumed?.();
}
}, [fleetTab, onFleetTabConsumed]);
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
const { openEdit, openDelete, NodeActionModals } = useNodeActions({
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import type { ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
@@ -17,11 +17,17 @@ import { BackChip, Kicker, Masthead } from './mobile-ui';
interface MobileSettingsProps {
headerActions: ReactNode;
selectedSection: SectionId | null;
onSelectedSectionChange: (section: SectionId | null) => void;
}
const NOOP = () => {};
export function MobileSettings({ headerActions }: MobileSettingsProps) {
export function MobileSettings({
headerActions,
selectedSection,
onSelectedSectionChange,
}: MobileSettingsProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
@@ -36,9 +42,9 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
.map(group => ({ ...group, items: visibleItems.filter(item => item.group === group.id) }))
.filter(group => group.items.length > 0);
const [selected, setSelected] = useState<SectionId | null>(null);
// If the active node changes and hides the open section, fall back to the list.
const activeSection = selected && visibleItems.some(i => i.id === selected) ? selected : null;
const activeSection = selectedSection && visibleItems.some(i => i.id === selectedSection)
? selectedSection
: null;
const item = activeSection ? getSettingsItem(activeSection) : null;
if (activeSection && item) {
@@ -46,7 +52,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="px-2 pt-1">
<BackChip label="Settings" onClick={() => setSelected(null)} />
<BackChip label="Settings" onClick={() => onSelectedSectionChange(null)} />
</div>
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
<span aria-hidden className="absolute left-0 top-1 bottom-[15px] w-[3px] bg-brand" />
@@ -82,7 +88,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
<button
key={it.id}
type="button"
onClick={() => setSelected(it.id)}
onClick={() => onSelectedSectionChange(it.id)}
className={`flex min-h-11 w-full items-center gap-3 px-[13px] py-3 text-left ${idx > 0 ? 'border-t border-hairline' : ''}`}
>
<span className="min-w-0 flex-1">
+27 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { ArrowUpRight, Loader2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
import { ArrowUpRight, Loader2, AlertCircle, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
import { useStackKeyboardShortcuts } from '@/hooks/useStackKeyboardShortcuts';
import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton';
@@ -14,8 +14,10 @@ import { StackKebabMenu } from './StackKebabMenu';
import { EmptyStackState } from './EmptyStackState';
import type { StackMenuCtx, FilterChip } from './sidebar-types';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackListState';
import { Button } from '@/components/ui/button';
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
interface RemoteNodeResult {
nodeId: number;
@@ -58,6 +60,9 @@ export interface StackListProps {
// create stacks; drives the zero-stacks empty state.
onOpenCreate?: (mode: 'import' | 'empty') => void;
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
stacksLoadStatus?: StacksLoadStatus;
stacksLoadError?: string | null;
onRetryStacksLoad?: () => void;
}
interface BuiltGroup {
@@ -147,6 +152,9 @@ export function StackList(props: StackListProps & StackListBulkProps) {
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
filterChip, onOpenCreate,
openMuteRulesWithPrefill,
stacksLoadStatus,
stacksLoadError,
onRetryStacksLoad,
} = props;
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
@@ -168,6 +176,23 @@ export function StackList(props: StackListProps & StackListBulkProps) {
);
}
if (stacksLoadStatus === 'error' && files.length === 0) {
return (
<div className="mx-2 mt-4 rounded-lg border border-card-border bg-card/40 p-4 text-center">
<AlertCircle className="mx-auto mb-2 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground mb-3">
{stacksLoadError ?? 'Could not load stacks for this node.'}
</p>
{onRetryStacksLoad && (
<Button type="button" variant="outline" size="sm" onClick={onRetryStacksLoad}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
)}
</div>
);
}
// First-run prompt only when the node has no stacks at all: no search text and
// no active filter chip, so a filter that happens to match nothing does not
// masquerade as an empty fleet.
+35 -33
View File
@@ -4,6 +4,8 @@ type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge'
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type PermissionsStatus = 'loading' | 'ready' | 'error';
export type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
@@ -28,6 +30,8 @@ interface AuthContextType {
user: UserInfo | null;
isAdmin: boolean;
permissions: PermissionsData | null;
permissionsStatus: PermissionsStatus;
permissionsReady: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
@@ -44,10 +48,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [appStatus, setAppStatus] = useState<AppStatus>('loading');
const [user, setUser] = useState<UserInfo | null>(null);
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('loading');
const resetPermissions = useCallback(() => {
setPermissions(null);
setPermissionsStatus('loading');
}, []);
const checkAuth = async () => {
resetPermissions();
try {
// First check if setup is needed
const statusResponse = await fetch('/api/auth/status', {
credentials: 'include',
});
@@ -56,28 +66,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (statusData.needsSetup) {
setAppStatus('needsSetup');
setUser(null);
setPermissions(null);
resetPermissions();
return;
}
// If a partial-auth (mfa_pending) cookie is active, route to the
// challenge screen. This handles reloads in the middle of the flow,
// including post-OIDC redirects.
if (statusData.mfaPending) {
setUser(null);
setPermissions(null);
resetPermissions();
setAppStatus('mfaChallenge');
return;
}
// Auth check and permissions fetch are independent for an authenticated
// session, so fire both on the wire at the same time. Await only the
// auth check before committing app state — otherwise a slow
// /permissions/me delays setAppStatus('authenticated') and races
// post-reload UI that expects the dashboard to commit promptly. The
// permissions promise updates state in the background when it resolves.
const authPromise = fetch('/api/auth/check', { credentials: 'include' });
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' }).catch(() => null);
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' });
const authResponse = await authPromise;
if (authResponse.ok) {
@@ -85,30 +86,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user ?? null);
setAppStatus('authenticated');
void permsPromise.then(async (res) => {
if (res?.ok) {
try {
setPermissions(await res.json());
} catch {
// Permissions fetch is non-critical — fallback to global role only
}
try {
const res = await permsPromise;
if (res.ok) {
setPermissions(await res.json());
setPermissionsStatus('ready');
} else {
setPermissionsStatus('error');
}
});
} catch {
setPermissionsStatus('error');
}
} else {
setUser(null);
setPermissions(null);
resetPermissions();
setAppStatus('notAuthenticated');
}
} catch {
setUser(null);
setPermissions(null);
resetPermissions();
setAppStatus('notAuthenticated');
}
};
useEffect(() => {
checkAuth();
const handleUnauthorized = () => setAppStatus('notAuthenticated');
const handleUnauthorized = () => {
setUser(null);
resetPermissions();
setAppStatus('notAuthenticated');
};
window.addEventListener('sencho-unauthorized', handleUnauthorized);
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
@@ -116,13 +123,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => {
if (!permissions) return false;
// Admins always have full access
if (permissions.globalRole === 'admin') return true;
// Check global role permissions
if (permissions.globalPermissions.includes(action)) return true;
// Check scoped permissions
if (resourceType && resourceId) {
const key = `${resourceType}:${resourceId}`;
return permissions.scopedPermissions[key]?.includes(action) ?? false;
@@ -146,13 +150,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (response.ok && data.success) {
if (data.mfaRequired) {
// Password was accepted but a second factor is required. Pull the
// updated /auth/status so the app routes to the challenge screen.
await checkAuth();
return { success: true, mfaRequired: true };
}
setAppStatus('authenticated');
// Fetch user info (role, username) so isAdmin is correct immediately
await checkAuth();
return { success: true };
} else {
@@ -220,7 +221,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('Cancel MFA error:', error);
} finally {
setUser(null);
setPermissions(null);
resetPermissions();
setAppStatus('notAuthenticated');
}
};
@@ -235,13 +236,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('Logout error:', error);
} finally {
setUser(null);
setPermissions(null);
resetPermissions();
setAppStatus('notAuthenticated');
}
};
const completeSetup = () => {
// Fetch user info so isAdmin is correct after setup
checkAuth();
};
@@ -253,6 +253,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
isAdmin: user?.role === 'admin',
permissions,
permissionsStatus,
permissionsReady: permissionsStatus !== 'loading',
can,
login,
ssoLdapLogin,
+23 -2
View File
@@ -4,6 +4,8 @@ import { apiFetch } from '@/lib/api';
export type LicenseTier = 'community' | 'paid';
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
export type LicenseFetchStatus = 'loading' | 'ready' | 'error';
export interface LicenseInfo {
tier: LicenseTier;
status: LicenseStatus;
@@ -21,6 +23,8 @@ interface LicenseContextType {
license: LicenseInfo | null;
isPaid: boolean;
loading: boolean;
licenseStatus: LicenseFetchStatus;
licenseReady: boolean;
refresh: () => Promise<void>;
activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>;
deactivate: () => Promise<{ success: boolean; error?: string }>;
@@ -31,16 +35,22 @@ const LicenseContext = createContext<LicenseContextType | undefined>(undefined);
export function LicenseProvider({ children }: { children: ReactNode }) {
const [license, setLicense] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState(true);
const [licenseStatus, setLicenseStatus] = useState<LicenseFetchStatus>('loading');
const refresh = useCallback(async () => {
setLoading(true);
setLicenseStatus('loading');
try {
const res = await apiFetch('/license', { localOnly: true });
if (res.ok) {
const data = await res.json();
setLicense(data);
setLicenseStatus('ready');
} else {
setLicenseStatus('error');
}
} catch {
// Silently fail - license info is non-critical
setLicenseStatus('error');
} finally {
setLoading(false);
}
@@ -60,6 +70,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
const data = await res.json();
if (res.ok && data.success) {
setLicense(data.license);
setLicenseStatus('ready');
return { success: true };
}
return { success: false, error: data.error || 'Activation failed' };
@@ -77,6 +88,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
const data = await res.json();
if (res.ok && data.success) {
setLicense(data.license);
setLicenseStatus('ready');
return { success: true };
}
return { success: false, error: data.error || 'Deactivation failed' };
@@ -88,7 +100,16 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
const isPaid = license?.tier === 'paid';
return (
<LicenseContext.Provider value={{ license, isPaid, loading, refresh, activate, deactivate }}>
<LicenseContext.Provider value={{
license,
isPaid,
loading,
licenseStatus,
licenseReady: licenseStatus !== 'loading',
refresh,
activate,
deactivate,
}}>
{children}
</LicenseContext.Provider>
);
+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';
}