From 5fe0843eb275a31726a17ffa9f6dbd3c8adf9cce Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 8 Jul 2026 13:07:16 -0400 Subject: [PATCH] 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//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 --- .github/workflows/ci.yml | 2 +- .pr-body-tmp.md | 27 + docs/docs.json | 5 +- docs/features/deep-links.mdx | 56 ++ e2e/deploy-log-panel.spec.ts | 9 +- e2e/routing.spec.ts | 195 +++++++ e2e/sidebar-stack-truncate.spec.ts | 12 +- e2e/stack-file-explorer.spec.ts | 46 +- frontend/src/components/EditorLayout.tsx | 148 +++++- .../components/EditorLayout/ViewRouter.tsx | 12 +- .../__tests__/useViewNavigationState.test.tsx | 10 +- .../EditorLayout/hooks/useOverlayState.ts | 5 +- .../EditorLayout/hooks/useStackActions.ts | 78 ++- .../EditorLayout/hooks/useStackListState.ts | 44 +- .../EditorLayout/hooks/useUrlSync.test.ts | 340 +++++++++++++ .../EditorLayout/hooks/useUrlSync.ts | 478 ++++++++++++++++++ .../hooks/useViewNavigationState.ts | 130 +++-- .../mobile-pending-detail.test.ts | 40 ++ .../EditorLayout/mobile-pending-detail.ts | 21 + frontend/src/components/FleetView.tsx | 32 +- .../src/components/mobile/MobileSettings.tsx | 20 +- frontend/src/components/sidebar/StackList.tsx | 29 +- frontend/src/context/AuthContext.tsx | 68 +-- frontend/src/context/LicenseContext.tsx | 25 +- frontend/src/lib/fleetDossier.ts | 9 +- frontend/src/lib/nodeSlug.test.ts | 42 ++ frontend/src/lib/nodeSlug.ts | 43 ++ .../src/lib/router/readUrlRouteState.test.ts | 25 + frontend/src/lib/router/readUrlRouteState.ts | 33 ++ frontend/src/lib/router/routeTypes.ts | 59 +++ frontend/src/lib/router/senchoRoute.test.ts | 104 ++++ frontend/src/lib/router/senchoRoute.ts | 189 +++++++ frontend/src/lib/routing/reachability.test.ts | 52 ++ frontend/src/lib/routing/reachability.ts | 67 +++ frontend/src/lib/slugify.ts | 7 + 35 files changed, 2263 insertions(+), 199 deletions(-) create mode 100644 .pr-body-tmp.md create mode 100644 docs/features/deep-links.mdx create mode 100644 e2e/routing.spec.ts create mode 100644 frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts create mode 100644 frontend/src/components/EditorLayout/hooks/useUrlSync.ts create mode 100644 frontend/src/components/EditorLayout/mobile-pending-detail.test.ts create mode 100644 frontend/src/components/EditorLayout/mobile-pending-detail.ts create mode 100644 frontend/src/lib/nodeSlug.test.ts create mode 100644 frontend/src/lib/nodeSlug.ts create mode 100644 frontend/src/lib/router/readUrlRouteState.test.ts create mode 100644 frontend/src/lib/router/readUrlRouteState.ts create mode 100644 frontend/src/lib/router/routeTypes.ts create mode 100644 frontend/src/lib/router/senchoRoute.test.ts create mode 100644 frontend/src/lib/router/senchoRoute.ts create mode 100644 frontend/src/lib/routing/reachability.test.ts create mode 100644 frontend/src/lib/routing/reachability.ts create mode 100644 frontend/src/lib/slugify.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d10862c..7bbeafb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,7 +175,7 @@ jobs: e2e: name: E2E Tests (Playwright) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 needs: [backend, frontend] # See backend job rationale. if: >- diff --git a/.pr-body-tmp.md b/.pr-body-tmp.md new file mode 100644 index 00000000..1fbbefc9 --- /dev/null +++ b/.pr-body-tmp.md @@ -0,0 +1,27 @@ +## Summary + +- Add a History API sync layer (`useUrlSync`) that maps shell state to `/nodes//...` paths without react-router. +- Gate URL normalization on permissions and license readiness; preserve the current URL on authz read failures (fail closed). +- Add stack-list frozen error UI with retry, transition-specific dirty guards for popstate, and user docs at `docs/features/deep-links.mdx`. +- Fix cold-load/refresh regressions: skip dashboard reset on initial node mount; defer URL writer until hydrated `activeView` matches the route. +- Mobile URL contract: `/dashboard` opens Home (content surface); `/stacks` opens the stack list. List surface always writes `/stacks` even when `activeView` is Fleet or Resources. +- Mobile stack deep links: hydrate `pendingDetailStack`, use `loadFileForRoute` + `routeDetailError` for compose failures (frozen URL, `MobileDetailError` + Retry). +- **QA follow-up (`20b35df7`):** Bootstrap `activeView`/tabs from URL via `readUrlRouteState` so Fleet/Security/Resources cold-load correctly; settle route phase when state already matches (fixes in-app URL writes); normalize unknown view segments; open Monaco editor tabs from `/compose`/`/env`/`/files` deep links. + +## Test plan + +- [x] `cd frontend && npx tsc -b --noEmit` +- [x] Unit: `readUrlRouteState`, `useUrlSync`, `senchoRoute`, `useViewNavigationState` (52+ tests) +- [x] E2E routing spec expanded (shell view content assertions, mobile cases) — re-run on CI / fresh preview +- [ ] Manual QA on rebuilt preview image: + - [ ] Cold load `/nodes/local/fleet`, `/security/images`, `/resources` (correct view, not Dashboard) + - [ ] Dashboard → Fleet → Security updates address bar + - [ ] `/stacks//env` opens Monaco env tab (not Anatomy-only) + - [ ] `/nodes/local/not-a-view` normalizes to dashboard + - [ ] Mobile stack deep link + compose failure retry (from prior commit) + +## Notes + +- Internal architecture docs are local-only (gitignored), not in this PR. +- Anatomy panel sub-tabs remain URL-opaque; only Monaco editor tabs update the path. +- Remote node slug suffix (`name-id`) is existing `nodeSlug` behavior; not changed in this PR. diff --git a/docs/docs.json b/docs/docs.json index b1ef8d73..e6d4ecb2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -132,8 +132,9 @@ "group": "Observability", "expanded": true, "pages": [ - "features/dashboard", - "features/global-search", + "features/dashboard", + "features/deep-links", + "features/global-search", "features/global-observability", "features/alerts-notifications", "features/audit-log" diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx new file mode 100644 index 00000000..070af12e --- /dev/null +++ b/docs/features/deep-links.mdx @@ -0,0 +1,56 @@ +--- +title: Deep links and URLs +description: Bookmark, share, and refresh Sencho views with real browser URLs that survive Back and Forward. +--- + +Every major screen in Sencho now has a stable address in the browser bar. Refresh the page, paste a link to a teammate, or use Back and Forward without losing your place. + +## What gets its own URL + +Sencho encodes the active node, the view you are on, and the deep state that view cares about. + +| Destination | Example path | What it opens | +|-------------|--------------|---------------| +| Home dashboard | `/nodes/local/dashboard` | The home dashboard for the `local` node | +| Stack list (phone) | `/nodes/local/stacks` | The full-width stack list on a phone | +| Stack editor | `/nodes/local/stacks/radarr/compose` | Radarr's compose tab | +| Env tab + file | `/nodes/local/stacks/radarr/env?env=.env.prod` | Radarr's env tab with a specific env file selected | +| Resources | `/nodes/local/resources` | Resources for the active node | +| Security tab | `/nodes/local/security/images` | Security view on the Images tab | +| Settings section | `/nodes/local/settings/nodes` | Settings on the Nodes section | +| Fleet tab | `/nodes/local/fleet/snapshots` | Fleet on the Snapshots tab (desktop) | +| Schedules filter | `/nodes/local/schedules?node=3` | Scheduled operations filtered to node `3` | + +Remote nodes use a slug derived from the node name and id (for example `/nodes/nas-box-42/dashboard`). The default local node keeps the short `local` slug. + +## Node slugs + +- The primary local node is always `/nodes/local/...`. +- Every other node uses `/nodes/-/...`, which stays stable when you rename the node (the id suffix is what Sencho resolves). + +## Bookmarks and sharing + +Copy the address bar after you land on a screen. Anyone with access to that Sencho instance and the right role can open the same view from the link. Sencho waits for permissions and license metadata to finish loading before it redirects a link you are allowed to see, so a valid paid or admin-only URL is not rewritten while metadata is still in flight. + +If a link points at a stack that cannot be loaded (for example the node is offline), Sencho keeps the URL and shows a retryable error in the sidebar or on the phone stack detail screen instead of silently sending you home. + +## Back, Forward, and refresh + +- **Back / Forward** walk through the views you opened in order, including stack editor tabs where applicable. +- **Refresh** reloads the current URL and restores the same node, view, stack, and tab when the underlying data is available. +- **Unsaved edits** still block navigation. Sencho prompts before you leave a dirty compose or env buffer via Back, a sidebar link, or another stack. + +## Phone layout + +On a phone, Home and the stack list are distinct URLs even though both relate to stacks on desktop: + +- `/nodes/local/dashboard` opens the home dashboard. +- `/nodes/local/stacks` opens the stack list. + +Settings follows the same list/detail split: `/nodes/local/settings` is the section list; `/nodes/local/settings/
` opens a section directly. + +## Tips + +- Use the global search palette (Ctrl+K) as today; navigation still updates the URL when you pick a page, node, or stack. +- Deep links use stack directory names (the name you see in the sidebar), not internal compose filenames. +- Opening Sencho at `/` sends you to the active node's dashboard once you are signed in. diff --git a/e2e/deploy-log-panel.spec.ts b/e2e/deploy-log-panel.spec.ts index c6a2511a..251e6519 100644 --- a/e2e/deploy-log-panel.spec.ts +++ b/e2e/deploy-log-panel.spec.ts @@ -382,12 +382,17 @@ test.describe('Deploy feedback modal', () => { await expect.poll(() => opCalls, { timeout: 15_000 }).toBeGreaterThanOrEqual(2); // Mobile shell (below the md break) renders the recovery card inline. + // Open from the stack list so URL hydration does not drop us into the + // full-screen compose editor surface (the /compose deep link does that). await page.setViewportSize({ width: 390, height: 844 }); - await page.reload(); + await page.goto('/nodes/local/stacks'); await waitForStacksLoaded(page); await page.getByText(HAPPY_STACK, { exact: true }).first().click(); + await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('stack-deploy-button')).toBeEnabled({ timeout: 10_000 }); await page.getByTestId('stack-deploy-button').click(); - await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Deploy failed')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 10_000 }); }); test.afterEach(async ({ page }) => { diff --git a/e2e/routing.spec.ts b/e2e/routing.spec.ts new file mode 100644 index 00000000..7d140a4c --- /dev/null +++ b/e2e/routing.spec.ts @@ -0,0 +1,195 @@ +/** + * Browser URL routing E2E tests. + * Verifies deep links, navigation sync, refresh persistence, and Back/Forward. + */ +import { test, expect } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +async function firstStackName(page: import('@playwright/test').Page): Promise { + const row = page.locator('[role="listbox"] [role="option"]').first(); + if (!(await row.isVisible().catch(() => false))) return null; + const text = await row.textContent(); + return text?.trim() ?? null; +} + +test.describe('URL routing', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test('cold load of /nodes/local/dashboard lands on home', async ({ page }) => { + await page.goto('/nodes/local/dashboard'); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/local\/dashboard/); + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible(); + }); + + test('cold load of shell views preserves the URL and mounts the view', async ({ page }) => { + await page.goto('/nodes/local/fleet'); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/local\/fleet/); + await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible({ timeout: 15_000 }); + + await page.goto('/nodes/local/security/images'); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/local\/security\/images/); + await expect(page.getByRole('tab', { name: 'Images' })).toBeVisible(); + + await page.goto('/nodes/local/resources'); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/local\/resources/); + await expect(page.getByRole('tab', { name: 'Images' })).toBeVisible(); + }); + + test('top-level navigation updates the address bar', async ({ page }) => { + await page.getByRole('button', { name: 'Resources', exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\/local\/resources/); + await page.getByRole('button', { name: 'Home', exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\/local\/dashboard/); + }); + + test('opening a stack writes a stack editor URL', async ({ page }) => { + const stackName = await firstStackName(page); + test.skip(!stackName, 'No stacks available to open'); + + await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click(); + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`)); + }); + + test('refresh preserves a stack editor deep link', async ({ page }) => { + const stackName = await firstStackName(page); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug}|${stackName}`)); + await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible(); + await page.reload(); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/local\/stacks\//); + await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible(); + }); + + test('compose editor env tab updates the URL', async ({ page }) => { + const stackName = await firstStackName(page); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click(); + await expect(page).toHaveURL(/\/compose/); + + await page.getByRole('button', { name: 'edit', exact: true }).click(); + const envTab = page.getByRole('tab', { name: '.env' }); + test.skip(!(await envTab.isEnabled()), 'Stack has no .env file'); + await envTab.click(); + await expect(page).toHaveURL(/\/env/); + }); + + test('browser Back returns to the prior view', async ({ page }) => { + await page.getByRole('button', { name: 'Resources', exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\/local\/resources/); + await page.getByRole('button', { name: 'Security', exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\/local\/security/); + await page.goBack(); + await expect(page).toHaveURL(/\/nodes\/local\/resources/); + }); + + test('root path redirects into a node dashboard', async ({ page }) => { + await page.goto('/'); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(/\/nodes\/[^/]+\/dashboard/); + }); +}); + +test.describe('URL routing (mobile)', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await page.setViewportSize({ width: 390, height: 844 }); + }); + + test('cold load of stack deep link opens mobile detail', async ({ page }) => { + const stackName = await page.evaluate(async () => { + const res = await fetch('/api/stacks', { credentials: 'include' }); + const stacks = await res.json() as string[]; + return stacks[0] ?? null; + }); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`)); + await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('tab', { name: 'Health' })).toBeVisible(); + }); + + test('reload preserves mobile stack deep link', async ({ page }) => { + const stackName = await page.evaluate(async () => { + const res = await fetch('/api/stacks', { credentials: 'include' }); + const stacks = await res.json() as string[]; + return stacks[0] ?? null; + }); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 }); + await page.reload(); + await expect(page).toHaveURL(/\/nodes\/local\/stacks\//); + await expect(page.getByRole('tab', { name: 'Health' })).toBeVisible({ timeout: 15_000 }); + }); + + test('stack list API failure shows retryable mobile error and preserves URL', async ({ page }) => { + const stackName = await page.evaluate(async () => { + const res = await fetch('/api/stacks', { credentials: 'include' }); + const stacks = await res.json() as string[]; + return stacks[0] ?? null; + }); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.route('**/api/stacks', async (route) => { + if (route.request().method() === 'GET' && !route.request().url().includes(`/api/stacks/${encodeURIComponent(stackName!)}`)) { + await route.fulfill({ status: 500, body: 'list failure' }); + return; + } + await route.continue(); + }); + + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({ timeout: 15_000 }); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`)); + }); + + test('compose API failure shows retryable mobile error and preserves URL', async ({ page }) => { + const stackName = await page.evaluate(async () => { + const res = await fetch('/api/stacks', { credentials: 'include' }); + const stacks = await res.json() as string[]; + return stacks[0] ?? null; + }); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await page.route(`**/api/stacks/${encodeURIComponent(stackName!)}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 500, body: 'compose failure' }); + return; + } + await route.continue(); + }); + + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({ timeout: 15_000 }); + await expect(page).not.toHaveURL(/\/dashboard/); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`)); + }); + + test('Stacks tab from Fleet updates URL to stack list', async ({ page }) => { + await page.goto('/nodes/local/fleet'); + await expect(page).toHaveURL(/\/nodes\/local\/fleet/); + await page.getByRole('button', { name: 'Stacks', exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\/local\/stacks$/); + }); +}); diff --git a/e2e/sidebar-stack-truncate.spec.ts b/e2e/sidebar-stack-truncate.spec.ts index ce5c799e..55a824f7 100644 --- a/e2e/sidebar-stack-truncate.spec.ts +++ b/e2e/sidebar-stack-truncate.spec.ts @@ -263,8 +263,12 @@ test.describe('Sidebar stack name truncation', () => { test('active row with a long name still truncates', async ({ page }) => { const row = page.locator('[data-testid="stack-row"]').filter({ hasText: LONG_STACK }); - await row.click(); - await expect(row).toHaveClass(/bg-accent/); + const slug = LONG_STACK.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + await Promise.all([ + page.waitForURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)), + row.click(), + ]); + await expect(row).toHaveClass(/bg-accent/, { timeout: 10_000 }); await assertRowLayout(page, LONG_STACK, { expectTruncated: true }); }); @@ -312,13 +316,11 @@ test.describe('Sidebar stack name truncation', () => { test('mobile viewport keeps long names truncated', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.reload(); + await page.goto('/nodes/local/stacks'); await waitForStacksLoaded(page); const metrics = await measureRow(page, LONG_STACK); expect(metrics.nameTruncated).toBe(true); expect(metrics.rowWithinSidebar).toBe(true); - - await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible(); }); }); diff --git a/e2e/stack-file-explorer.spec.ts b/e2e/stack-file-explorer.spec.ts index 80ac7b74..74ed8b83 100644 --- a/e2e/stack-file-explorer.spec.ts +++ b/e2e/stack-file-explorer.spec.ts @@ -366,19 +366,16 @@ test.describe('Stack file explorer: symlink semantics', () => { test.describe('Stack file explorer: UI lifecycle', () => { test.setTimeout(90_000); - /** Open the Files tab on the seeded stack. Mirrors openFilesTab in stack-files.spec.ts. */ + function stackSlug(stackName: string): string { + return stackName.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + } + + /** Open the Files tab via the routable stack editor URL. */ async function openFilesTab(page: Page): Promise { + const slug = stackSlug(STACK); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/files`); await waitForStacksLoaded(page); - const stackInSidebar = page.getByText(STACK, { exact: true }).first(); - if (!await stackInSidebar.isVisible().catch(() => false)) { - await page.reload(); - await loginAs(page); - await waitForStacksLoaded(page); - } - await page.getByText(STACK, { exact: true }).first().click(); - const filesBtn = page.getByTestId('anatomy-files-btn'); - await expect(filesBtn).toBeVisible({ timeout: 8_000 }); - await filesBtn.click(); + await expect(page.getByRole('button', { name: 'New file' })).toBeVisible({ timeout: 10_000 }); await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 }); } @@ -409,7 +406,6 @@ test.describe('Stack file explorer: UI lifecycle', () => { ); expect(renameRes.status()).toBe(204); - await page.reload(); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: /^lifecycle-rename-dst\.txt$/ }).first(), @@ -424,7 +420,6 @@ test.describe('Stack file explorer: UI lifecycle', () => { ); expect(res.status()).toBe(204); - await page.reload(); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(), @@ -442,10 +437,21 @@ test.describe('Stack file explorer: UI lifecycle', () => { ).toBeVisible({ timeout: 8_000 }); }); - test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page }) => { - await fs.writeFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'original\n'); - await page.reload(); + test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page, request }) => { + const cookie = await cookieHeader(page); + const seedRes = await request.put( + `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`, + { + headers: { cookie, 'Content-Type': 'application/json' }, + data: { content: 'original\n' }, + }, + ); + expect(seedRes.ok()).toBeTruthy(); + await openFilesTab(page); + await expect( + page.locator('span.font-mono').filter({ hasText: /^dup-guard\.txt$/ }).first(), + ).toBeVisible({ timeout: 8_000 }); await page.getByRole('button', { name: 'New file' }).click(); await page.getByLabel('File name').fill('dup-guard.txt'); @@ -453,7 +459,13 @@ test.describe('Stack file explorer: UI lifecycle', () => { await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 }); // The server rejected the create, so the existing contents are intact. - expect(await fs.readFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'utf-8')).toBe('original\n'); + const verifyRes = await request.get( + `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`, + { headers: { cookie } }, + ); + expect(verifyRes.ok()).toBeTruthy(); + const payload = await verifyRes.json() as { content?: string }; + expect(payload.content).toBe('original\n'); }); test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1455121f..291b18cc 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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('list'); + const [mobileSettingsSection, setMobileSettingsSection] = useState(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(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 ; case 'settings': - return ; + return ( + + ); case 'security': return ( {renderEditor(mobileMastheadActions)} + ) : ( + (stacksLoadStatus === 'error' && stacksLoadNodeId === activeNode?.id && pendingDetailStack) + || (routeDetailError && pendingDetailStack) + ) ? ( + { void retryFrozenRoute(); }} + headerActions={mobileMastheadActions} + /> ) : ( ) @@ -1026,3 +1108,45 @@ function MobileDetailLoading({ name, onBack, headerActions }: { name: string; on ); } + +function MobileDetailError({ + name, + message, + onBack, + onRetry, + headerActions, +}: { + name: string; + message: string; + onBack: () => void; + onRetry: () => void; + headerActions?: ReactNode; +}) { + return ( +
+
+ + + {name.replace(/\.(ya?ml)$/, '')} + + {headerActions ?
{headerActions}
: null} +
+
+ +

{message}

+ +
+
+ ); +} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 7979ab86..450e773c 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -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} /> diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index 1e9684fb..b4127414 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -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); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: false, + licenseStatus: 'ready', } as unknown as ReturnType); } @@ -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); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: false, + licenseStatus: 'ready', } as unknown as ReturnType); } @@ -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); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: true, + licenseStatus: 'ready', } as unknown as ReturnType); } @@ -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); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: false, + licenseStatus: 'ready', } as unknown as ReturnType); } @@ -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', () => { diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index cae23d9c..81305644 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -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(null); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 0a65658c..d1898dd4 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -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 => { + 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 => { + 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, diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 7befa7c6..3b4b04d0 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -64,6 +64,8 @@ export interface RemoteResult { const EMPTY_UPDATES: Record = {}; +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('all'); const [bulkMode, setBulkMode] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [stacksLoadStatus, setStacksLoadStatus] = useState('idle'); + const [stacksLoadError, setStacksLoadError] = useState(null); + const [stacksLoadNodeId, setStacksLoadNodeId] = useState(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 => { - 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; } diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts new file mode 100644 index 00000000..d9f59451 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -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 { + 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 { + return { + isAdmin: true, + isPaid: true, + can: () => true, + isRemote: false, + hasFleetCapability: true, + containerLabelsEnabled: true, + permissionsStatus: 'ready', + licenseStatus: 'ready', + ...over, + }; +} + +function makeOpts(over: Partial = {}): 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(); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts new file mode 100644 index 00000000..a8a9fed2 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts @@ -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; + changeEnvFile: (file: string) => Promise; + applyEditorRouteState: (tab: EditorTab) => void; + refreshStacks: (background?: boolean) => Promise; + 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(null); + const [urlHydratingStack, setUrlHydratingStack] = useState(null); + + const phaseRef = useRef('initial'); + const historyIdxRef = useRef(0); + const suppressNextPopstateRef = useRef(false); + const pendingRouteRef = useRef(null); + const pendingStackRef = useRef(null); + const pendingEnvRef = useRef(null); + const pendingTabRef = useRef(null); + const initialHydratedRef = useRef(false); + const routeReplaceRef = useRef(false); + const appliedViewRef = useRef(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 }; +} diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 41c195ff..a5eb0db1 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -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 = 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('dashboard'); - const [settingsSection, setSettingsSection] = useState('appearance'); - const [securityTab, setSecurityTab] = useState('overview'); - const [fleetTab, setFleetTab] = useState(null); - const [filterNodeId, setFilterNodeId] = useState(null); + const initialRoute = readUrlRouteState(); + + const [activeView, setActiveView] = useState(initialRoute.activeView); + const [settingsSection, setSettingsSection] = useState(initialRoute.settingsSection); + const [securityTab, setSecurityTab] = useState(initialRoute.securityTab); + const [fleetActiveTab, setFleetActiveTab] = useState(initialRoute.fleetActiveTab); + const [filterNodeId, setFilterNodeId] = useState(initialRoute.filterNodeId); const [schedulePrefill, setSchedulePrefill] = useState(null); const [muteRulePrefill, setMuteRulePrefill] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const reachCtx: ReachabilityContext = useMemo(() => ({ + isAdmin, + isPaid, + can: (action: string) => can(action as Parameters[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).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; } diff --git a/frontend/src/components/EditorLayout/mobile-pending-detail.test.ts b/frontend/src/components/EditorLayout/mobile-pending-detail.test.ts new file mode 100644 index 00000000..503441d3 --- /dev/null +++ b/frontend/src/components/EditorLayout/mobile-pending-detail.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/EditorLayout/mobile-pending-detail.ts b/frontend/src/components/EditorLayout/mobile-pending-detail.ts new file mode 100644 index 00000000..d0c803df --- /dev/null +++ b/frontend/src/components/EditorLayout/mobile-pending-detail.ts @@ -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; +} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d47abcf6..0b834f15 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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('overview'); + const [internalTab, setInternalTab] = useState('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({ diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx index c2417694..0106475c 100644 --- a/frontend/src/components/mobile/MobileSettings.tsx +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -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(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 (
- setSelected(null)} /> + onSelectedSectionChange(null)} />
@@ -82,7 +88,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) { + )} +
+ ); + } + // 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. diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index c3cbb345..c04cab65 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -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('loading'); const [user, setUser] = useState(null); const [permissions, setPermissions] = useState(null); + const [permissionsStatus, setPermissionsStatus] = useState('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, diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx index 4cd134a5..1fed64c7 100644 --- a/frontend/src/context/LicenseContext.tsx +++ b/frontend/src/context/LicenseContext.tsx @@ -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; activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>; deactivate: () => Promise<{ success: boolean; error?: string }>; @@ -31,16 +35,22 @@ const LicenseContext = createContext(undefined); export function LicenseProvider({ children }: { children: ReactNode }) { const [license, setLicense] = useState(null); const [loading, setLoading] = useState(true); + const [licenseStatus, setLicenseStatus] = useState('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 ( - + {children} ); diff --git a/frontend/src/lib/fleetDossier.ts b/frontend/src/lib/fleetDossier.ts index fc9a1b17..85eeb511 100644 --- a/frontend/src/lib/fleetDossier.ts +++ b/frontend/src/lib/fleetDossier.ts @@ -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 { diff --git a/frontend/src/lib/nodeSlug.test.ts b/frontend/src/lib/nodeSlug.test.ts new file mode 100644 index 00000000..642e7016 --- /dev/null +++ b/frontend/src/lib/nodeSlug.test.ts @@ -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 & Pick): 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'); + }); +}); diff --git a/frontend/src/lib/nodeSlug.ts b/frontend/src/lib/nodeSlug.ts new file mode 100644 index 00000000..aecb1427 --- /dev/null +++ b/frontend/src/lib/nodeSlug.ts @@ -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 -> `-`. */ +export function nodeSlugMap(nodes: Node[]): Map { + const reservedLocal = pickReservedLocalNode(nodes); + const map = new Map(); + 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 `-` 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; +} diff --git a/frontend/src/lib/router/readUrlRouteState.test.ts b/frontend/src/lib/router/readUrlRouteState.test.ts new file mode 100644 index 00000000..aa4bbb87 --- /dev/null +++ b/frontend/src/lib/router/readUrlRouteState.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/router/readUrlRouteState.ts b/frontend/src/lib/router/readUrlRouteState.ts new file mode 100644 index 00000000..9cc2cdaa --- /dev/null +++ b/frontend/src/lib/router/readUrlRouteState.ts @@ -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, + }; +} diff --git a/frontend/src/lib/router/routeTypes.ts b/frontend/src/lib/router/routeTypes.ts new file mode 100644 index 00000000..d9ebe484 --- /dev/null +++ b/frontend/src/lib/router/routeTypes.ts @@ -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 = 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; +} diff --git a/frontend/src/lib/router/senchoRoute.test.ts b/frontend/src/lib/router/senchoRoute.test.ts new file mode 100644 index 00000000..f8a5ad18 --- /dev/null +++ b/frontend/src/lib/router/senchoRoute.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/router/senchoRoute.ts b/frontend/src/lib/router/senchoRoute.ts new file mode 100644 index 00000000..53a0afca --- /dev/null +++ b/frontend/src/lib/router/senchoRoute.ts @@ -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; + +const SEGMENT_TO_VIEW: Record = Object.fromEntries( + Object.entries(VIEW_SEGMENTS).map(([view, seg]) => [seg, view as ActiveView]), +) as Record; + +const EDITOR_TABS = new Set(['compose', 'env', 'files']); +const SECURITY_TABS = new Set([ + 'overview', 'images', 'compose', 'secrets', 'policies', 'suppressions', 'history', 'scanner', +]); +const FLEET_TABS = new Set([ + '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 }; diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts new file mode 100644 index 00000000..a4f363af --- /dev/null +++ b/frontend/src/lib/routing/reachability.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { + authzReady, + isViewHidden, + normalizeHiddenView, + type ReachabilityContext, +} from './reachability'; + +function ctx(over: Partial = {}): 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); + }); +}); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts new file mode 100644 index 00000000..8c766356 --- /dev/null +++ b/frontend/src/lib/routing/reachability.ts @@ -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; +} diff --git a/frontend/src/lib/slugify.ts b/frontend/src/lib/slugify.ts new file mode 100644 index 00000000..c08e1ceb --- /dev/null +++ b/frontend/src/lib/slugify.ts @@ -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'; +}