mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37:05 +00:00
7517a4f49c
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.
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import type { FleetTab, SecurityTab } from '@/lib/events';
|
|
import type { SectionId } from '@/components/settings/types';
|
|
import type { ActiveView, EditorTab, ParsedRoute, RouteState } from './routeTypes';
|
|
import { normalizeEnvFileQuery } from './envRoute';
|
|
|
|
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 envRaw = safeDecodeQueryValue(params.get('env') ?? '');
|
|
const envFile = normalizeEnvFileQuery(envRaw);
|
|
|
|
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 && !/[\\/]/.test(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 };
|