diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 0cacfa91..9cc07095 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -10,7 +10,7 @@ import type { Node } from '@/context/NodeContext'; import type { RunWithLogParams } from '@/context/DeployFeedbackContext'; import { parsePath } from '@/lib/router/senchoRoute'; import { resolveEnvFilePath } from '@/lib/router/envRoute'; -import type { EditorTab } from '@/lib/router/routeTypes'; +import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes'; import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView'; import type { NotificationItem } from '../../dashboard/types'; import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; @@ -423,16 +423,16 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setEnvEtag(null); }; - const loadEnvState = async (filename: string, signal?: AbortSignal) => { + const loadEnvState = async (filename: string, signal?: AbortSignal): Promise => { try { const envsRes = await apiFetch(`/stacks/${filename}/envs`, { signal }); - if (signal?.aborted) return; + if (signal?.aborted) return []; if (!envsRes.ok) { clearEnvState(); - return; + return []; } const { envFiles } = await envsRes.json(); - if (signal?.aborted) return; + if (signal?.aborted) return []; if (envFiles && envFiles.length > 0) { editorState.setEnvFiles(envFiles); const firstFile = envFiles[0]; @@ -442,7 +442,7 @@ export function useStackActions(options: UseStackActionsOptions) { `/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`, { signal }, ); - if (signal?.aborted) return; + if (signal?.aborted) return envFiles; if (envContentRes.ok) { const envText = await envContentRes.text(); editorState.setEnvContent(envText || ''); @@ -453,12 +453,14 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setOriginalEnvContent(''); editorState.setEnvEtag(null); } - } else { - clearEnvState(); + return envFiles; } - } catch (err) { - if (isAbortError(err)) return; clearEnvState(); + return []; + } catch (err) { + if (isAbortError(err)) return []; + clearEnvState(); + return []; } }; @@ -493,15 +495,15 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setIsEditing(false); }; - const loadFileCore = async (filename: string): Promise => { - if (!filename) return false; + const loadFileCore = async (filename: string): Promise => { + if (!filename) return { ok: false }; if ( stackListState.selectedFile && filename !== stackListState.selectedFile && hasUnsavedChanges() ) { overlayState.setPendingUnsavedLoad(filename); - return false; + return { ok: false }; } loadFileAbortRef.current?.abort(); const controller = new AbortController(); @@ -514,9 +516,9 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setActiveTab('compose'); try { const res = await apiFetch(`/stacks/${filename}`, { signal }); - if (signal.aborted) return false; + if (signal.aborted) return { ok: false }; const text = await res.text(); - if (signal.aborted) return false; + if (signal.aborted) return { ok: false }; if (!res.ok) { throw new Error(`Failed to load stack: ${res.status}`); } @@ -525,12 +527,12 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setContent(text || ''); editorState.setOriginalContent(text || ''); editorState.setComposeEtag(res.headers.get('etag')); - await loadEnvState(filename, signal); + const envFiles = await loadEnvState(filename, signal); await loadContainerState(filename, signal); await loadBackupState(filename, signal); - return true; + return { ok: true, envFiles }; } catch (error) { - if (isAbortError(error) || signal.aborted) return false; + if (isAbortError(error) || signal.aborted) return { ok: false }; console.error('Failed to load file:', error); toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`); stackListState.setSelectedFile(null); @@ -541,7 +543,7 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setOriginalEnvContent(''); editorState.setEnvEtag(null); editorState.setContainers([]); - return false; + return { ok: false }; } finally { if (!signal.aborted) { editorState.setIsFileLoading(false); @@ -553,7 +555,7 @@ export function useStackActions(options: UseStackActionsOptions) { await loadFileCore(filename); }; - const loadFileForRoute = async (filename: string): Promise => { + const loadFileForRoute = async (filename: string): Promise => { return loadFileCore(filename); }; diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index 36606af4..d0eca702 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -57,7 +57,7 @@ function makeOpts(over: Partial = {}): UseUrlSyncOptions { setActiveTab: vi.fn(), selectedEnvFile: '', envFiles: [], - loadFileForRoute: vi.fn().mockResolvedValue(true), + loadFileForRoute: vi.fn().mockResolvedValue({ ok: true, envFiles: [] }), changeEnvFile: vi.fn().mockResolvedValue(undefined), applyEditorRouteState: vi.fn(), refreshStacks: vi.fn().mockResolvedValue(['radarr']), @@ -127,10 +127,94 @@ describe('useUrlSync', () => { pushSpy.mockRestore(); }); + it('does not resolve remote stack against stale local node files', async () => { + const remote = makeNode({ id: 2, name: 'nas', type: 'remote', is_default: false }); + const local = makeNode(); + const setActiveNode = vi.fn(); + const setActiveView = vi.fn(); + const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] }); + + window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/nas-2/stacks/remote-stack/compose'); + + const { rerender } = renderHook( + (props) => useUrlSync(props), + { + initialProps: makeOpts({ + nodes: [local, remote], + activeNode: local, + activeView: 'dashboard', + files: ['local-only'], + filesNodeId: 1, + stacksLoadStatus: 'success', + stacksLoadNodeId: 1, + setActiveNode, + setActiveView, + loadFileForRoute, + }), + }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(setActiveNode).toHaveBeenCalledWith(remote); + expect(setActiveView).not.toHaveBeenCalledWith('dashboard'); + expect(loadFileForRoute).not.toHaveBeenCalled(); + + rerender(makeOpts({ + nodes: [local, remote], + activeNode: remote, + activeView: 'dashboard', + files: ['remote-stack'], + filesNodeId: 2, + stacksLoadStatus: 'success', + stacksLoadNodeId: 2, + setActiveNode, + setActiveView, + loadFileForRoute, + })); + + await act(async () => { + await Promise.resolve(); + }); + + expect(loadFileForRoute).toHaveBeenCalledWith('remote-stack'); + expect(setActiveView).not.toHaveBeenCalledWith('dashboard'); + }); + + it('settles env tab route when stack has no env files', async () => { + const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] }); + const applyEditorRouteState = vi.fn(); + + window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env'); + + renderHook( + (props) => useUrlSync(props), + { + initialProps: makeOpts({ + activeView: 'editor', + files: ['radarr'], + selectedFile: null, + envFiles: [], + loadFileForRoute, + applyEditorRouteState, + }), + }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(loadFileForRoute).toHaveBeenCalledWith('radarr'); + expect(applyEditorRouteState).toHaveBeenCalledWith('compose'); + }); + it('restores non-default env selection after stack load populates file list', async () => { const prodPath = '/compose/radarr/.env.prod'; const fileList = ['/compose/radarr/.env', prodPath]; - const loadFileForRoute = vi.fn().mockResolvedValue(true); + const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: fileList }); const changeEnvFile = vi.fn().mockResolvedValue(undefined); window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env?env=.env.prod'); @@ -351,7 +435,7 @@ describe('useUrlSync', () => { 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 loadFileForRoute = vi.fn().mockResolvedValue({ ok: false }); const setPendingDetailStack = vi.fn(); const { result } = renderHook( @@ -377,7 +461,7 @@ describe('useUrlSync', () => { 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 loadFileForRoute = vi.fn().mockResolvedValue({ ok: false }); const { result, rerender } = renderHook( (props) => useUrlSync(props), @@ -395,7 +479,7 @@ describe('useUrlSync', () => { }); expect(result.current.routeDetailError).not.toBeNull(); - loadFileForRoute.mockResolvedValue(true); + loadFileForRoute.mockResolvedValue({ ok: true, envFiles: [] }); rerender(makeOpts({ isMobile: true, diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts index 54e8df57..512157af 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState, type MutableRefObject } from 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 type { ActiveView, EditorTab, MobileRouteSurface, RouteStackLoadResult } from '@/lib/router/routeTypes'; import { buildPath, parsePath } from '@/lib/router/senchoRoute'; import { envFileForRouteUrl, resolveEnvRouteTarget } from '@/lib/router/envRoute'; import { nodeIdToSlug, slugToNodeId } from '@/lib/nodeSlug'; @@ -60,7 +60,7 @@ export interface UseUrlSyncOptions { setActiveTab: (tab: EditorTab) => void; selectedEnvFile: string; envFiles: string[]; - loadFileForRoute: (filename: string) => Promise; + loadFileForRoute: (filename: string) => Promise; changeEnvFile: (file: string) => Promise; applyEditorRouteState: (tab: EditorTab) => void; refreshStacks: (background?: boolean) => Promise; @@ -86,10 +86,20 @@ function readIdx(state: unknown): number | null { return null; } +function pendingNodeMatchesActive(pendingNodeId: number | null, activeNodeId: number | undefined): boolean { + return pendingNodeId == null || activeNodeId === pendingNodeId; +} + +interface PendingEditorRouteOpts { + envFiles: string[]; + inventoryReady: boolean; +} + async function applyPendingEditorRoute( optsRef: MutableRefObject, pendingEnvRef: MutableRefObject, tab: EditorTab, + routeOpts?: PendingEditorRouteOpts, ): Promise { const live = optsRef.current; if (tab !== 'env') { @@ -98,14 +108,26 @@ async function applyPendingEditorRoute( live.applyEditorRouteState(tab); return true; } - const outcome = resolveEnvRouteTarget(pendingEnvRef.current, live.envFiles); + const envFiles = routeOpts?.envFiles ?? live.envFiles; + const inventoryReady = routeOpts?.inventoryReady + ?? (!live.isFileLoading && live.selectedFile != null); + const stackLoading = live.isFileLoading && routeOpts?.envFiles == null; + const outcome = resolveEnvRouteTarget( + pendingEnvRef.current, + envFiles, + stackLoading, + inventoryReady, + ); if (!outcome.ready) return false; pendingEnvRef.current = null; - if (outcome.target && outcome.target !== live.selectedEnvFile) { + let effectiveTab: EditorTab = tab; + if (outcome.target == null && envFiles.length === 0) { + effectiveTab = 'compose'; + } else if (outcome.target && outcome.target !== live.selectedEnvFile) { await live.changeEnvFile(outcome.target); } - live.setActiveTab(tab); - live.applyEditorRouteState(tab); + live.setActiveTab(effectiveTab); + live.applyEditorRouteState(effectiveTab); return true; } @@ -168,6 +190,7 @@ export function useUrlSync(options: UseUrlSyncOptions) { const o = optsRef.current; const pending = pendingRouteRef.current; if (!pending) return; + if (!pendingNodeMatchesActive(pendingNodeIdRef.current, o.activeNode?.id)) return; let view = pending.view; if (authzReady(o.reachCtx)) { @@ -221,6 +244,7 @@ export function useUrlSync(options: UseUrlSyncOptions) { const o = optsRef.current; const stack = pendingStackRef.current; if (!stack || !o.activeNode) return; + if (!pendingNodeMatchesActive(pendingNodeIdRef.current, o.activeNode.id)) return; if (o.filesNodeId !== o.activeNode.id) return; if (o.stacksLoadStatus === 'loading' || o.stacksLoadStatus === 'idle') return; @@ -250,7 +274,10 @@ export function useUrlSync(options: UseUrlSyncOptions) { // failure, which would hide the recovery chip during a deploy). if (o.selectedFile === match) { const tab = pendingTabRef.current ?? 'compose'; - const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab); + const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, { + envFiles: o.envFiles, + inventoryReady: !o.isFileLoading, + }); if (!applied) return; pendingStackRef.current = null; pendingTabRef.current = null; @@ -264,10 +291,10 @@ export function useUrlSync(options: UseUrlSyncOptions) { resolvingRef.current = true; const attempted = match; - const loaded = await o.loadFileForRoute(match); + const loadResult = await o.loadFileForRoute(match); if (pendingStackRef.current !== attempted) { resolvingRef.current = false; return; } - if (!loaded) { + if (!loadResult.ok) { phaseRef.current = 'frozen'; pendingRouteRef.current = null; setRouteDetailError(`Could not open "${attempted.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`); @@ -276,7 +303,10 @@ export function useUrlSync(options: UseUrlSyncOptions) { } const tab = pendingTabRef.current ?? 'compose'; - const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab); + const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, { + envFiles: loadResult.envFiles, + inventoryReady: true, + }); if (!applied) { resolvingRef.current = false; return; @@ -393,9 +423,9 @@ export function useUrlSync(options: UseUrlSyncOptions) { }, [hydrateFromUrl, options.nodesLoaded, options.nodes.length]); useEffect(() => { - if (pendingRouteRef.current && optsRef.current.activeNode) { - void applyPendingRoute(); - } + if (!pendingRouteRef.current || !optsRef.current.activeNode) return; + if (!pendingNodeMatchesActive(pendingNodeIdRef.current, optsRef.current.activeNode.id)) return; + void applyPendingRoute(); }, [applyPendingRoute, options.activeNode?.id]); useEffect(() => { diff --git a/frontend/src/lib/router/envRoute.test.ts b/frontend/src/lib/router/envRoute.test.ts index af849341..54286300 100644 --- a/frontend/src/lib/router/envRoute.test.ts +++ b/frontend/src/lib/router/envRoute.test.ts @@ -38,19 +38,31 @@ describe('envRoute', () => { expect(normalizeEnvFileQuery('')).toBeNull(); }); - it('resolveEnvRouteTarget waits for envFiles and falls back to default', () => { - expect(resolveEnvRouteTarget('.env.prod', [])).toEqual({ ready: false }); - expect(resolveEnvRouteTarget('.env.prod', envFiles)).toEqual({ + it('resolveEnvRouteTarget waits for inventory and falls back to default', () => { + expect(resolveEnvRouteTarget('.env.prod', [], true, false)).toEqual({ ready: false }); + expect(resolveEnvRouteTarget('.env.prod', [], false, false)).toEqual({ ready: false }); + expect(resolveEnvRouteTarget('.env.prod', envFiles, false, true)).toEqual({ ready: true, target: '/home/user/compose/radarr/.env.prod', }); - expect(resolveEnvRouteTarget(null, envFiles)).toEqual({ + expect(resolveEnvRouteTarget(null, envFiles, false, true)).toEqual({ ready: true, target: '/home/user/compose/radarr/.env', }); - expect(resolveEnvRouteTarget('.env.missing', envFiles)).toEqual({ + expect(resolveEnvRouteTarget('.env.missing', envFiles, false, true)).toEqual({ ready: true, target: '/home/user/compose/radarr/.env', }); }); + + it('resolveEnvRouteTarget settles when env inventory is empty', () => { + expect(resolveEnvRouteTarget('.env.prod', [], false, true)).toEqual({ + ready: true, + target: null, + }); + expect(resolveEnvRouteTarget(null, [], false, true)).toEqual({ + ready: true, + target: null, + }); + }); }); diff --git a/frontend/src/lib/router/envRoute.ts b/frontend/src/lib/router/envRoute.ts index fcaa7007..51531790 100644 --- a/frontend/src/lib/router/envRoute.ts +++ b/frontend/src/lib/router/envRoute.ts @@ -26,7 +26,7 @@ export function envFileForRouteUrl( const first = envFiles[0]; if (first && selectedEnvFile === first) return null; const basename = envFileBasename(selectedEnvFile); - if (!basename || basename.includes('/')) return null; + if (!basename || /[\\/]/.test(basename)) return null; return basename; } @@ -42,9 +42,15 @@ export type EnvRouteTarget = | { ready: false } | { ready: true; target: string | null }; -/** Pick the env file to open from a route token once envFiles are loaded. */ -export function resolveEnvRouteTarget(requested: string | null, envFiles: string[]): EnvRouteTarget { - if (envFiles.length === 0) return { ready: false }; +/** Pick the env file to open from a route token once env inventory is known. */ +export function resolveEnvRouteTarget( + requested: string | null, + envFiles: string[], + stackLoading = false, + inventoryReady = true, +): EnvRouteTarget { + if (stackLoading || !inventoryReady) return { ready: false }; + if (envFiles.length === 0) return { ready: true, target: null }; const defaultFile = envFiles[0]; if (!requested) return { ready: true, target: defaultFile }; const resolved = resolveEnvFilePath(requested, envFiles); diff --git a/frontend/src/lib/router/routeTypes.ts b/frontend/src/lib/router/routeTypes.ts index d9ebe484..8e5d05df 100644 --- a/frontend/src/lib/router/routeTypes.ts +++ b/frontend/src/lib/router/routeTypes.ts @@ -44,6 +44,11 @@ export interface RouteState { isMobile: boolean; } +/** Result of loading a stack for URL hydration (includes env inventory snapshot). */ +export type RouteStackLoadResult = + | { ok: false } + | { ok: true; envFiles: string[] }; + export interface ParsedRoute { nodeSlug: string | null; view: ActiveView | null; diff --git a/frontend/src/lib/router/senchoRoute.test.ts b/frontend/src/lib/router/senchoRoute.test.ts index 96e9a2b6..bfee0ae5 100644 --- a/frontend/src/lib/router/senchoRoute.test.ts +++ b/frontend/src/lib/router/senchoRoute.test.ts @@ -115,6 +115,17 @@ describe('senchoRoute', () => { expect(path).toBe('/nodes/local/stacks/radarr/env'); }); + it('rejects Windows paths in buildPath env query', () => { + const path = buildPath({ + ...base, + activeView: 'editor', + stackName: 'radarr', + editorTab: 'env', + envFile: 'C:\\compose\\stack\\.env.prod', + }); + expect(path).toBe('/nodes/local/stacks/radarr/env'); + }); + it('parses stack list path as mobile list surface', () => { const parsed = parsePath('/nodes/local/stacks', ''); expect(parsed.isStackList).toBe(true); diff --git a/frontend/src/lib/router/senchoRoute.ts b/frontend/src/lib/router/senchoRoute.ts index 343f3552..34fdbb89 100644 --- a/frontend/src/lib/router/senchoRoute.ts +++ b/frontend/src/lib/router/senchoRoute.ts @@ -140,7 +140,7 @@ export function buildPath(state: RouteState): string { if (state.activeView === 'editor' && state.stackName) { const tab = state.editorTab || 'compose'; url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`; - if (tab === 'env' && state.envFile && !state.envFile.includes('/')) { + if (tab === 'env' && state.envFile && !/[\\/]/.test(state.envFile)) { url.searchParams.set('env', state.envFile); } if (state.filterNodeId != null) {