diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index f1d1a9cbe..b8e4e7ad4 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -1,5 +1,5 @@ -import { useLocation, useSearchParams } from '@solidjs/router'; -import { Show, createMemo, createSignal } from 'solid-js'; +import { useLocation, useNavigate, useSearchParams } from '@solidjs/router'; +import { Show, createEffect, createMemo, createSignal } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import { ButtonLink } from '@/components/shared/Button'; import { getPlatformIcon } from '@/features/platformPage/platformIcon'; @@ -48,7 +48,7 @@ import { buildInfrastructureAgentUpdatesPath, buildInfrastructureOnboardingPath, } from '@/components/Settings/infrastructureWorkspaceModel'; -import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks'; +import { buildDockerPath, DOCKER_PATH, DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks'; import { asTrimmedString } from '@/utils/stringUtils'; import type { Resource } from '@/types/resource'; @@ -60,6 +60,7 @@ const dockerIcon = () => ; export function DockerPageSurface() { const location = useLocation(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { resources, loading, error, refetch } = useUnifiedResources({ query: DOCKER_RESOURCE_QUERY, @@ -75,6 +76,14 @@ export function DockerPageSurface() { const activeTab = createMemo(() => tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', ); + + createEffect(() => { + const pathname = location.pathname.replace(/\/+$/, ''); + if (pathname === `${DOCKER_PATH}/workloads`) { + navigate(buildDockerPath(), { replace: true }); + } + }); + const hostFilter = createMemo(() => { const rawHost = searchParams[DOCKER_QUERY_PARAMS.host]; return typeof rawHost === 'string' ? rawHost.trim() : ''; diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx index 973eb0944..d1023d7a1 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx @@ -7,6 +7,7 @@ import { DockerPageSurface } from '../DockerPageSurface'; const mocks = vi.hoisted(() => ({ pathname: '/docker/overview', searchParams: {} as Record, + navigate: vi.fn(), useUnifiedResources: vi.fn(), DockerHostsTable: vi.fn( (props: { resources: Resource[]; showToolbar?: boolean; emptyTitle: string }) => ( @@ -88,6 +89,7 @@ vi.mock('@solidjs/router', () => ({ return mocks.pathname; }, }), + useNavigate: () => mocks.navigate, useSearchParams: () => [mocks.searchParams, vi.fn()], })); @@ -423,6 +425,16 @@ describe('DockerPageSurface', () => { ); }); + it('replaces the retired workloads route with the canonical Overview route', () => { + mocks.pathname = '/docker/workloads'; + + render(() => ); + + expect(mocks.navigate).toHaveBeenCalledWith('/docker/overview', { + replace: true, + }); + }); + it('shows the Swarm tab only when Docker hosts report Swarm evidence', () => { mocks.useUnifiedResources.mockReturnValue({ error: () => null, diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 7ac2374c6..108ba5bee 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -193,7 +193,15 @@ test.describe("Mobile viewport flows", () => { const table = page.locator("table.workload-table--mobile"); await expect(table).toBeVisible({ timeout: 30_000 }); - const geometry = await table.evaluate((element) => { + const geometry = await page.evaluate(() => { + const element = document.querySelector( + "table.workload-table--mobile", + ); + if (!element) { + throw new Error( + "Mobile workloads table disappeared before measurement", + ); + } const wrapper = element.parentElement; return { tableWidth: element.scrollWidth, diff --git a/tests/integration/tests/50-storage-physical-disk-io-history.spec.ts b/tests/integration/tests/50-storage-physical-disk-io-history.spec.ts index 4c49c5291..35f1dd69c 100644 --- a/tests/integration/tests/50-storage-physical-disk-io-history.spec.ts +++ b/tests/integration/tests/50-storage-physical-disk-io-history.spec.ts @@ -96,10 +96,13 @@ test.describe.serial('Storage physical disk drawer history', () => { // Physical disks moved onto the Proxmox platform page's storage section. await page.goto('/proxmox/storage', { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('storage-page')).toBeVisible({ timeout: 60_000 }); const disksTab = page.getByRole('tab', { name: 'Physical Disks' }); await expect(disksTab).toBeVisible({ timeout: 30_000 }); - await disksTab.click(); - await page.getByRole('textbox', { name: /Search Proxmox storage/ }).fill('nvme2'); + await disksTab.click({ timeout: 30_000 }); + const search = page.getByRole('textbox', { name: /Search Proxmox storage/ }); + await expect(search).toBeVisible({ timeout: 30_000 }); + await search.fill('nvme2'); const row = page .locator('table tbody tr') @@ -107,7 +110,7 @@ test.describe.serial('Storage physical disk drawer history', () => { .filter({ hasText: 'pve2' }) .first(); - await expect(row).toBeVisible(); + await expect(row).toBeVisible({ timeout: 30_000 }); await row.getByRole('button', { name: /^Expand / }).click(); const detail = page.locator('[data-inline-detail-for]').filter({ has: page.getByText('Live I/O (30m)') }).first(); diff --git a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts index ecf060d49..9e66e86ad 100644 --- a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts +++ b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts @@ -238,9 +238,13 @@ test.describe("Patrol Assistant operator briefing", () => { }); }); - await page.route("**/api/ai/sessions", async (route) => { - if (route.request().method() !== "GET") { - await route.continue(); + await page.route("**/api/ai/sessions*", async (route) => { + const requestUrl = new URL(route.request().url()); + if ( + route.request().method() !== "GET" || + requestUrl.pathname !== "/api/ai/sessions" + ) { + await route.fallback(); return; } @@ -395,75 +399,83 @@ test.describe("Patrol Assistant operator briefing", () => { }); }); + const operatorBriefingFinding = () => ({ + id: "finding-operator-briefing", + source: "ai-patrol", + severity: "critical", + category: "performance", + resource_id: "host:web-server", + resource_name: "web-server", + resource_type: "host", + title: "High CPU usage", + description: "CPU stayed above 95%.", + detected_at: "2026-05-06T12:00:00Z", + last_seen_at: "2026-05-06T12:06:00Z", + status: "active", + times_raised: 4, + regression_count: 2, + last_regression_at: "2026-05-06T12:06:00Z", + loop_state: "awaiting_approval", + remediation_id: "remediation-1", + investigation_status: "completed", + investigation_outcome: "fix_queued", + investigation_attempts: 1, + ...(includeUnifiedInvestigationRecord + ? { + investigation_record: { + id: "record-1", + finding_id: "finding-operator-briefing", + subject: { + resource_id: "host:web-server", + resource_name: "web-server", + resource_type: "host", + }, + trigger: { + detected_at: "2026-05-06T12:00:00Z", + title: "High CPU usage", + }, + status: "completed", + outcome: "fix_queued", + confidence: "high", + conclusion: "Backup job saturated CPU.", + recommended_action: + "Approve a controlled restart after the backup completes.", + evidence: [ + { + kind: "metrics", + summary: "CPU stayed above 95% for 10 minutes", + }, + ], + proposed_fix: { + id: "fix-1", + description: "Restart the workload service", + commands: ["systemctl restart workload.service"], + risk_level: "medium", + destructive: true, + }, + verification: ["CPU returned below 50%"], + tools_used: [], + started_at: "2026-05-06T12:00:00Z", + approval_id: "approval-1", + }, + } + : {}), + }); + + await page.route("**/api/ai/patrol/findings*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([operatorBriefingFinding()]), + }); + }); + await page.route("**/api/ai/unified/findings*", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ - findings: [ - { - id: "finding-operator-briefing", - source: "ai-patrol", - severity: "critical", - category: "performance", - resource_id: "host:web-server", - resource_name: "web-server", - resource_type: "host", - title: "High CPU usage", - description: "CPU stayed above 95%.", - detected_at: "2026-05-06T12:00:00Z", - last_seen_at: "2026-05-06T12:06:00Z", - status: "active", - times_raised: 4, - regression_count: 2, - last_regression_at: "2026-05-06T12:06:00Z", - loop_state: "awaiting_approval", - remediation_id: "remediation-1", - investigation_status: "completed", - investigation_outcome: "fix_queued", - investigation_attempts: 1, - ...(includeUnifiedInvestigationRecord - ? { - investigation_record: { - id: "record-1", - finding_id: "finding-operator-briefing", - subject: { - resource_id: "host:web-server", - resource_name: "web-server", - resource_type: "host", - }, - trigger: { - detected_at: "2026-05-06T12:00:00Z", - title: "High CPU usage", - }, - status: "completed", - outcome: "fix_queued", - confidence: "high", - conclusion: "Backup job saturated CPU.", - recommended_action: - "Approve a controlled restart after the backup completes.", - evidence: [ - { - kind: "metrics", - summary: "CPU stayed above 95% for 10 minutes", - }, - ], - proposed_fix: { - id: "fix-1", - description: "Restart the workload service", - commands: ["systemctl restart workload.service"], - risk_level: "medium", - destructive: true, - }, - verification: ["CPU returned below 50%"], - tools_used: [], - started_at: "2026-05-06T12:00:00Z", - approval_id: "approval-1", - }, - } - : {}), - }, - ], + findings: [operatorBriefingFinding()], count: 1, active_count: 1, }), @@ -599,78 +611,62 @@ test.describe("Patrol Assistant operator briefing", () => { .click(); await page.getByText("High CPU usage").click(); - const finding = page.locator("#finding-finding-operator-briefing"); - await finding - .getByRole("button", { name: "Discuss with Assistant" }) - .first() + const findingReview = page.locator( + "#finding-finding-operator-briefing-details", + ); + await findingReview.getByText("Manage", { exact: true }).click(); + await findingReview + .getByRole("button", { name: "Open in Assistant" }) .click(); const assistantContext = page.getByLabel("Assistant context"); await expect(assistantContext).toBeVisible(); - await expect(assistantContext).toContainText("Operator briefing attached"); + await expect(assistantContext).toContainText("Pulse Patrol"); await expect(assistantContext).toContainText( - "Attention: active critical finding; regressed 2 times; last regression 2026-05-06T12:06:00Z; loop awaiting approval; approval approval-1; live approval pending; destructive proposed fix; fix queued for governed review", + "Completed · Fix Queued · High confidence", ); await expect(assistantContext).toContainText( - `Decision: review live governed approval approval-1 before execution; approval pending; target web-server; expires ${approvalExpiresAt}; requested ${approvalRequestedAt}; proposed fix fix-1; risk high; destructive true`, + "High CPU usage on web-server (host)", ); await expect(assistantContext).toContainText( - "Command details stay in approval context; destructive actions require governed approval.", + "Approval required before any action.", ); - await expect( - assistantContext.getByRole("button", { - name: "Review approval risk and next step", - }), - ).toBeVisible(); - await expect( - assistantContext.getByRole("button", { - name: "Explain Patrol evidence and confidence", - }), - ).toBeVisible(); - await expect( - assistantContext.getByRole("button", { - name: "Summarize remediation without command text", - }), - ).toBeVisible(); await expect( assistantContext.getByText("systemctl restart workload.service"), ).toHaveCount(0); - await assistantContext - .getByRole("button", { name: "Review approval risk and next step" }) - .click(); - await expect( - page.getByPlaceholder("Ask about your infrastructure..."), - ).toHaveValue("Review approval risk and next step"); - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); await page.getByTitle("Pulse Assistant sessions").click(); - await expect(page.getByText("High CPU follow-up")).toBeVisible(); + const highCPUFollowUp = page.getByRole("option", { + name: /^Resume High CPU follow-up/, + }); + await expect(highCPUFollowUp).toBeVisible(); await expect(page.getByText("Pulse Patrol").last()).toBeVisible(); await expect(page.getByText("Approval required").last()).toBeVisible(); await expect(page.getByText(/approval pending/).last()).toBeVisible(); - await page.getByText("High CPU follow-up").click(); + await highCPUFollowUp.click(); const reloadedAssistantContext = page.getByLabel("Assistant context"); await expect(reloadedAssistantContext).toBeVisible(); await expect(reloadedAssistantContext).toContainText( "Patrol finding on web-server", ); + await expect(reloadedAssistantContext).toContainText("approval pending"); await expect(reloadedAssistantContext).toContainText( - "Finding finding-operator-briefing", - ); - await expect(reloadedAssistantContext).toContainText( - "Last known state: approval pending", + "Approval required before any action.", ); await expect( reloadedAssistantContext.getByText("systemctl restart workload.service"), ).toHaveCount(0); await page.getByTitle("Pulse Assistant sessions").click(); - await expect(page.getByText("Context-only Patrol follow-up")).toBeVisible(); + const contextOnlyFollowUp = page.getByRole("option", { + name: /^Resume Context-only Patrol follow-up/, + }); + await expect(contextOnlyFollowUp).toBeVisible(); await expect(page.getByText("Context attached").last()).toBeVisible(); - await page.getByText("Context-only Patrol follow-up").click(); + await contextOnlyFollowUp.click(); const contextOnlyAssistantContext = page.getByLabel("Assistant context"); await expect(contextOnlyAssistantContext).toBeVisible(); @@ -678,7 +674,7 @@ test.describe("Patrol Assistant operator briefing", () => { "Patrol finding on web-server", ); await expect(contextOnlyAssistantContext).toContainText( - "Finding finding-context-only", + "Approval required before any action.", ); await expect( contextOnlyAssistantContext.getByText( @@ -687,10 +683,13 @@ test.describe("Patrol Assistant operator briefing", () => { ).toHaveCount(0); await page.getByTitle("Pulse Assistant sessions").click(); - await expect(page.getByText("Runtime failure follow-up")).toBeVisible(); + const runtimeFailureFollowUp = page.getByRole("option", { + name: /^Resume Runtime failure follow-up/, + }); + await expect(runtimeFailureFollowUp).toBeVisible(); await expect(page.getByText("Runtime issue").last()).toBeVisible(); await expect(page.getByText(/run error/).last()).toBeVisible(); - await page.getByText("Runtime failure follow-up").click(); + await runtimeFailureFollowUp.click(); const runAssistantContext = page.getByLabel("Assistant context"); await expect(runAssistantContext).toBeVisible(); @@ -699,7 +698,6 @@ test.describe("Patrol Assistant operator briefing", () => { ); await expect(runAssistantContext).toContainText("Run run-runtime-error"); await expect(runAssistantContext).toContainText("Run type: Scoped run"); - await expect(runAssistantContext).toContainText("Run state: run error"); await expect(runAssistantContext).toContainText( "Review Patrol runtime issue", ); @@ -709,9 +707,12 @@ test.describe("Patrol Assistant operator briefing", () => { ).toHaveCount(0); await page.getByTitle("Pulse Assistant sessions").click(); - await expect(page.getByText("Assessment follow-up")).toBeVisible(); + const assessmentFollowUp = page.getByRole("option", { + name: /^Resume Assessment follow-up/, + }); + await expect(assessmentFollowUp).toBeVisible(); await expect(page.getByText("Assessment context").last()).toBeVisible(); - await page.getByText("Assessment follow-up").click(); + await assessmentFollowUp.click(); const restoredAssessmentAssistantContext = page.getByLabel("Assistant context"); @@ -720,27 +721,25 @@ test.describe("Patrol Assistant operator briefing", () => { "Patrol assessment handoff", ); await expect(restoredAssessmentAssistantContext).toContainText( - "Current Patrol assessment", - ); - await expect(restoredAssessmentAssistantContext).toContainText( - "Review Patrol assessment", + "Approval required before any action.", ); await page.getByTitle("Pulse Assistant sessions").click(); - await expect(page.getByText("Configuration follow-up")).toBeVisible(); + const configurationFollowUp = page.getByRole("option", { + name: /^Resume Configuration follow-up/, + }); + await expect(configurationFollowUp).toBeVisible(); await expect(page.getByText("Runtime issue").last()).toBeVisible(); - await page.getByText("Configuration follow-up").click(); + await configurationFollowUp.click(); const configurationAssistantContext = page.getByLabel("Assistant context"); await expect(configurationAssistantContext).toBeVisible(); await expect(configurationAssistantContext).toContainText( - "Patrol configuration failure", + "Patrol mode save failure", ); + await expect(configurationAssistantContext).toContainText("Patrol mode"); await expect(configurationAssistantContext).toContainText( - "Patrol configuration", - ); - await expect(configurationAssistantContext).toContainText( - "Review Patrol configuration issue", + "Review Patrol mode issue", ); includePendingApproval = false; @@ -753,30 +752,25 @@ test.describe("Patrol Assistant operator briefing", () => { const queuedFindingTitle = page.getByText("High CPU usage").first(); await expect(queuedFindingTitle).toBeVisible(); await queuedFindingTitle.click(); - const queuedFinding = page.locator("#finding-finding-operator-briefing"); + const queuedFinding = page.locator( + "#finding-finding-operator-briefing-details", + ); await expect(queuedFinding.getByText("details unavailable")).toBeVisible(); + await queuedFinding.getByText("Manage", { exact: true }).click(); await queuedFinding - .getByRole("button", { name: "Discuss with Assistant" }) - .last() + .getByRole("button", { name: "Open in Assistant" }) .click(); const queuedAssistantContext = page.getByLabel("Assistant context"); await expect(queuedAssistantContext).toBeVisible(); - await expect(queuedAssistantContext).toContainText( - "Operator briefing attached", - ); + await expect(queuedAssistantContext).toContainText("Pulse Patrol"); await expect(queuedAssistantContext).toContainText("Fix Queued"); await expect(queuedAssistantContext).toContainText( - "Attention: active finding; loop fix queued; fix queued for governed review", + "High CPU usage on web-server (host)", ); await expect(queuedAssistantContext).toContainText( - "Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.", + "Approval required before any action.", ); - await expect( - queuedAssistantContext.getByRole("button", { - name: "List approval prerequisites before action", - }), - ).toBeVisible(); await expect( queuedAssistantContext.getByText("systemctl restart workload.service"), ).toHaveCount(0); @@ -792,56 +786,48 @@ test.describe("Patrol Assistant operator briefing", () => { const expiredFindingTitle = page.getByText("High CPU usage").first(); await expect(expiredFindingTitle).toBeVisible(); await expiredFindingTitle.click(); - const expiredFinding = page.locator("#finding-finding-operator-briefing"); - await expect(expiredFinding.getByText("approval expired")).toBeVisible(); + const expiredFinding = page.locator( + "#finding-finding-operator-briefing-details", + ); + await expect(expiredFinding.getByText("Action details unavailable")).toBeVisible(); + await expiredFinding.getByText("Manage", { exact: true }).click(); await expiredFinding - .getByRole("button", { name: "Discuss with Assistant" }) - .first() + .getByRole("button", { name: "Open in Assistant" }) .click(); const hydratedFindingAssistantContext = page.getByLabel("Assistant context"); await expect(hydratedFindingAssistantContext).toBeVisible(); - await expect(hydratedFindingAssistantContext).toContainText( - "Operator briefing attached", - ); + await expect(hydratedFindingAssistantContext).toContainText("Pulse Patrol"); await expect(hydratedFindingAssistantContext).toContainText("Fix Queued"); await expect(hydratedFindingAssistantContext).toContainText( - "Proposed fix: Restart the workload service; target web-server; high risk; 1 command recorded for approval context; destructive proposed fix; rationale Workload service stayed wedged after backup pressure.", + "High CPU usage on web-server (host)", + ); + await expect(hydratedFindingAssistantContext).toContainText( + "Approval required before any action.", ); - await expect( - hydratedFindingAssistantContext.getByRole("button", { - name: "Summarize remediation without command text", - }), - ).toBeVisible(); await expect( hydratedFindingAssistantContext.getByText( "systemctl restart workload.service", ), ).toHaveCount(0); + await page.getByTestId("assistant-close-button").click(); await expiredFinding - .getByRole("button", { name: "Fix with Assistant" }) - .last() + .getByRole("button", { name: "Discuss with Assistant" }) + .first() .click(); const expiredAssistantContext = page.getByLabel("Assistant context"); await expect(expiredAssistantContext).toBeVisible(); - await expect(expiredAssistantContext).toContainText( - "Operator briefing attached", - ); + await expect(expiredAssistantContext).toContainText("Pulse Patrol"); await expect(expiredAssistantContext).toContainText("Fix Queued"); await expect(expiredAssistantContext).toContainText( - "Proposed fix: Restart the workload service; target web-server; high risk; 1 command recorded for approval context; destructive proposed fix; rationale Workload service stayed wedged after backup pressure.", + "High CPU usage on web-server", ); await expect(expiredAssistantContext).toContainText( - "Command details stay in approval context; destructive actions require governed approval.", + "Approval required before any action.", ); - await expect( - expiredAssistantContext.getByRole("button", { - name: "Summarize remediation without command text", - }), - ).toBeVisible(); await expect( expiredAssistantContext.getByText("systemctl restart workload.service"), ).toHaveCount(0);