mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
fix(routing): guard remote stack hydration and settle empty env routes (#1602)
Gate stack resolution until the active node matches a pending remote deep link. Return env inventory from loadFileForRoute so empty stacks settle env tab routes. Reject backslashes in buildPath env query tokens.
This commit is contained in:
@@ -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<string[]> => {
|
||||
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<boolean> => {
|
||||
if (!filename) return false;
|
||||
const loadFileCore = async (filename: string): Promise<RouteStackLoadResult> => {
|
||||
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<boolean> => {
|
||||
const loadFileForRoute = async (filename: string): Promise<RouteStackLoadResult> => {
|
||||
return loadFileCore(filename);
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ function makeOpts(over: Partial<UseUrlSyncOptions> = {}): 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,
|
||||
|
||||
@@ -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<boolean>;
|
||||
loadFileForRoute: (filename: string) => Promise<RouteStackLoadResult>;
|
||||
changeEnvFile: (file: string) => Promise<void>;
|
||||
applyEditorRouteState: (tab: EditorTab) => void;
|
||||
refreshStacks: (background?: boolean) => Promise<string[]>;
|
||||
@@ -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<UseUrlSyncOptions>,
|
||||
pendingEnvRef: MutableRefObject<string | null>,
|
||||
tab: EditorTab,
|
||||
routeOpts?: PendingEditorRouteOpts,
|
||||
): Promise<boolean> {
|
||||
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(() => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user