Compare commits

...

7 Commits

Author SHA1 Message Date
hatiyildiz
d641d7afd2 test(cosmetic-guards): replace v2 Flow-tab guards with v3 Flow-route guards
Tests #5-#8 in cosmetic-guards.spec.ts asserted the v2 Tab-on-JobsPage
shape that the founder rejected (jobs-view-tabs / jobs-view-tab-table /
jobs-view-tab-flow / jobs-flow-svg / ?view=flow URL). This commit
replaces them with v3 founder spec assertions:

5. JobsPage has NO tab strip, exposes a "Show as Flow" button →
   /flow?scope=all (anti-regression for the retired Tab strip).
6. /flow?scope=all renders the canvas SVG with ≥ 1 batch + bubble.
7. Single-click on a job bubble opens the FloatingLogPane and the
   inline width is 25vw verbatim.
8. StatusStrip mode toggle (Jobs ↔ Batches) updates the URL ?view=
   parameter, so the choice is bookmarkable.

Plus 2 NEW guards:

- "JobDetail v3 (Flow + Exec Log only)" — locks in EXACTLY 2 tabs
  labeled Flow + Exec Log, with Flow aria-selected by default; asserts
  Dependencies + Apps tabs are GONE.
- "JobsTable batch chip → /flow link" — the chip is an <a> linking
  to /flow?scope=batch:<id> (was previously a no-op chip / BatchDetail
  link).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:40:20 +02:00
hatiyildiz
30aef57774 refactor(flow): retire JobsFlowView + JobApps + JobDependencies
These four files are no longer rendered by any caller after the v3
routing model lands:

- JobsFlowView.tsx + JobsFlowView.test.tsx — the in-page Flow tab
  was rejected by the founder (PR #242). The /flow route + FlowPage
  component supersede it.
- JobDependencies.tsx — JobDetail v3 has no Dependencies tab. The
  Flow canvas (scoped to the parent batch with the focal job
  highlighted) is the dependency view now.
- JobApps.tsx — JobDetail v3 has no Apps tab. The header chip + each
  Flow bubble's appId display cover the same surface.

Note: depsLayout (shared/lib) is KEPT — it's still used by
JobDependenciesGraph (a different widget under widgets/job-deps-graph
that may surface in other future surfaces).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:40:07 +02:00
hatiyildiz
f284f3ffb1 feat(job-detail): consolidate to 2 tabs (Flow + Exec Log)
v3 founder spec: JobDetail tab strip is now exactly two tabs.

- Tab 1 (default): "Flow" — embedded FlowPage canvas scoped to the
  parent batch with this job pre-highlighted (thicker accent border
  + glow rect). The canvas IS the dependency view.
- Tab 2: "Exec Log" — existing GitLab-CI-runner-style log viewer.

Retired from v2:
- Dependencies tab — replaced by the Flow tab. The Flow canvas is a
  superior dependency surface (pure Sugiyama with cross-batch edges,
  scope filter, batch supernodes).
- Apps tab — collapsed into the header chip + each Flow bubble's
  appId display.

JobDetail.test.tsx (new file):
- Locks in EXACTLY 2 tabs labeled Flow + Exec Log.
- Flow tab is default-active.
- Asserts Dependencies + Apps tabs are gone (anti-regression for v3).
- Asserts the Flow tab panel mounts the embedded FlowPage canvas
  (testid=flow-page-embedded).
- Tab swap fires correctly.

Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall) — full target shape
in this PR; the previous 3-tab vocabulary is gone, not feature-flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:39:58 +02:00
hatiyildiz
07d27b33d4 feat(routing): /flow route + JobsPage drops Tab strip + batch chip → /flow
Routing restructure (founder rejected PR #242 Tab-on-JobsPage pattern):

router.tsx:
- Register the new /provision/$deploymentId/flow route with FlowPage.
- Drop the validateSearch{ view: table|flow } wiring on /jobs — the
  Tab strip is gone, search params no longer drive view selection.
- Add validateSearch{ scope, view } on /flow so deep links survive
  unknown values.

JobsPage.tsx:
- Remove the entire jobs-view-tabs strip (JOBS_VIEW_TABS, setView,
  resolveJobsView). The Flow surface now lives at /flow.
- Add a "Show as Flow" button in the page header that navigates to
  /flow?scope=all. Founder spec: "[Show as Flow] button in JobsPage
  header → /flow?scope=all".
- Drop the JobsFlowView import + the activeView render switch.

JobsPage.test.tsx:
- Replace the BatchDetail-link assertion with a /flow?scope=batch:<id>
  assertion (the v3 routing model).
- Add anti-regression guards for the retired Tab strip + new Show-as-
  Flow button.

JobsTable.tsx:
- Batch chip in each row now Links to /flow?scope=batch:<batchId>
  (was previously a Link to the BatchDetail page). Founder spec:
  "JobsTable batch chip click navigates to /flow?scope=batch:<id>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:39:45 +02:00
hatiyildiz
9241864604 feat(flow): FlowPage canvas at /flow with scope + mode + click semantics
New per-deployment flow canvas served at:
  /sovereign/provision/$deploymentId/flow

Routing contract:
- ?scope=all              → render every job in the deployment
- ?scope=batch:<id>       → filter to a single batch
- ?view=jobs|batches      → mode toggle (default = jobs)

Mode contract:
- Jobs mode: every job rendered as a bubble; node border colour by
  status. Single-click bubble → opens FloatingLogPane (right 25vw).
  Double-click bubble → navigates to /jobs/$jobId. Click empty
  canvas → closes the floating pane.
- Batches mode: each batch as a single supernode. Single-click →
  highlights it (no log pane — batches have no execution logs).
  Double-click → drills into Jobs mode scoped to that batch
  (URL becomes ?scope=batch:<id>).

Embedded variant (`embedded` prop) — used by JobDetail's Flow tab:
- Reduces canvas height to ~50vh.
- Hides the StatusStrip (JobDetail's header already shows job-level
  breadcrumb + status badge).
- `highlightJobId` prop pre-emphasises the parent job (thicker
  accent border + glow rect overlay).
- `deploymentIdOverride` prop bypasses TanStack Router's strict
  useParams(from:'/flow'), since JobDetail mounts FlowPage from a
  different route.

Single-vs-double-click: SVG `onClick` fires on every click in a double-
click, so we debounce the single-click handler 220ms — if a second click
arrives first, cancel the timer and fire the double-click handler
instead. Matches OS double-click threshold.

Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall) — full target shape
in this PR: route, mode toggle, log pane, double-click drill, embedded
variant. Per #2 (no compromise) — pure SVG + computed bezier; reuses
the existing Sugiyama core in pipelineLayout.ts. Per #4 (never
hardcode) — every CSS token comes from --color-*; the 25vw width
binds to the spec verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:39:31 +02:00
hatiyildiz
63a289e3ce feat(flow): FloatingLogPane (25vw slide-in) + StatusStrip components
Two new presentational components for the v3 Flow surface:

FloatingLogPane (products/.../components/FloatingLogPane.tsx):
- Slide-in 25vw log viewer that overlays the right edge of the canvas.
- Reuses the canonical <ExecutionLogs /> body — no rebuild.
- Closes on X click, Escape key, or canvas-background click (handled
  by the FlowPage parent).
- Renders an empty-state branch when executionId is falsy (pending
  jobs without an execution row).

StatusStrip (products/.../components/StatusStrip.tsx):
- Top contextual strip mirroring provision-mockup.html's geometry:
  breadcrumb / provisioning pill (animated pulse) / progress bar /
  optional Jobs↔Batches mode toggle.
- Mode toggle is URL-driven via a parent-supplied onChange callback.
- All colours bind to existing theme tokens; light/dark theme stays
  intact (no new CSS variables).

Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every dimension /
status / count is a prop. Per #2 (no compromise), no graph library and
no Mantine — pure CSS-token-bound styles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:39:13 +02:00
hatiyildiz
a41a240626 feat(flow): pipelineLayout supports highlightJobId option
Add an optional `highlightJobId` to PipelineLayoutOptions. When set, the
matching FlowNode is emitted with `highlighted = true`, which the new
FlowPage canvas renders with a thicker accent-coloured border + glow.
Used by JobDetail's embedded Flow tab to draw the operator's eye to the
parent job on first paint. Pure flag — no layout change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:39:00 +02:00
18 changed files with 2442 additions and 1464 deletions

View File

@ -866,139 +866,189 @@ test.describe('@cosmetic-guard jobs surface (issue #204 — table view)', () =>
})
/*
* Tests 5-8 Flow tab (founder urgent spec, two-level Sugiyama)
* Tests 5-8 Flow CANVAS (founder v3 spec).
*
* The Jobs page MUST expose two view tabs: Table (default) and Flow.
* Switching tabs updates the URL ?view= search param so deep links
* preserve the operator's chosen view, browser-back works, and the
* choice is bookmarkable.
* Routing model (v3, this PR):
* JobsPage has NO tabs just the table and a "Show as Flow"
* button in the header that links to /flow?scope=all.
* Per-deployment flow canvas lives at /flow?scope=all
* Per-batch flow canvas lives at /flow?scope=batch:<batchId>
* Mode toggle (Jobs Batches) is in the StatusStrip.
* Single-click on a job bubble opens a 25vw FloatingLogPane.
*
* The Flow tab renders a two-level Sugiyama layered DAG (batches as
* meta-stages, jobs as inner stages). Default zoom is "expanded" for
* batches with in-flight jobs; "collapsed" for all-succeeded batches.
* The previous `?view=table|flow` Tab strip on JobsPage was
* rejected by the founder (PR #242) and is anti-regression-checked
* in test 5 below.
* */
test('5. Flow tab exists alongside Table tab in the Jobs view tabstrip', async ({ page }) => {
test('5. JobsPage has NO tab strip and exposes a "Show as Flow" button', async ({ page }) => {
await page.goto('provision/test-deployment-id/jobs')
await page.waitForLoadState('domcontentloaded')
const tabstrip = page.locator('[data-testid="jobs-view-tabs"]')
await expect(
tabstrip,
'JobsPage is missing [data-testid=jobs-view-tabs] — the founder urgent spec requires a tab strip with Table + Flow tabs at the top of the Jobs page. See JobsPage.tsx.',
).toBeVisible({ timeout: 10_000 })
const tableTab = page.locator('[data-testid="jobs-view-tab-table"]')
const flowTab = page.locator('[data-testid="jobs-view-tab-flow"]')
await expect(tableTab, 'Table tab is missing from the Jobs view tabstrip.').toBeVisible()
await expect(
flowTab,
'Flow tab is missing from the Jobs view tabstrip — two-level Sugiyama DAG visualisation must be a peer of the Table tab.',
).toBeVisible()
// Default landing — Table is the active tab.
// The legacy `?view=table|flow` Tab strip MUST be gone — the
// founder rejected that pattern in PR #242.
expect(
await tableTab.getAttribute('aria-selected'),
'Table tab is not aria-selected by default. The default landing for /sovereign/provision/$id/jobs must be the Table view.',
).toBe('true')
await page.locator('[data-testid="jobs-view-tabs"]').count(),
'JobsPage still renders the legacy [data-testid=jobs-view-tabs] Tab strip. PR #242 was rejected; the Flow surface now lives at its own /flow route. Remove the tab strip from JobsPage.tsx.',
).toBe(0)
expect(
await page.locator('[data-testid="jobs-view-tab-table"]').count(),
).toBe(0)
expect(
await page.locator('[data-testid="jobs-view-tab-flow"]').count(),
).toBe(0)
// The header must expose the "Show as Flow" button → /flow?scope=all.
const showAsFlow = page.locator('[data-testid="sov-jobs-show-as-flow"]')
await expect(
showAsFlow,
'JobsPage is missing the [data-testid=sov-jobs-show-as-flow] button. Founder v3 spec: a button in the JobsPage header navigates to /flow?scope=all. See JobsPage.tsx.',
).toBeVisible({ timeout: 10_000 })
const href = (await showAsFlow.getAttribute('href')) ?? ''
expect(
/\/provision\/[^/]+\/flow/.test(href),
`Show-as-Flow button href "${href}" must navigate to the /flow route.`,
).toBe(true)
expect(
/scope=all/.test(href),
`Show-as-Flow button href "${href}" must set ?scope=all so the canvas renders the full deployment DAG.`,
).toBe(true)
})
test('6. clicking Flow tab updates URL to ?view=flow and renders the DAG canvas', async ({ page }) => {
await page.goto('provision/test-deployment-id/jobs')
test('6. /flow?scope=all renders the canvas SVG with at least one batch + bubble', async ({ page }) => {
await page.goto('provision/test-deployment-id/flow?scope=all')
await page.waitForLoadState('domcontentloaded')
const flowTab = page.locator('[data-testid="jobs-view-tab-flow"]')
await expect(flowTab).toBeVisible({ timeout: 10_000 })
await flowTab.click()
// URL must now carry ?view=flow.
await page.waitForFunction(
() => window.location.search.includes('view=flow'),
{ timeout: 5_000 },
)
const url = new URL(page.url())
expect(
url.searchParams.get('view'),
`Clicking the Flow tab did not push ?view=flow into the URL. Got search="${url.search}". The JobsPage must mirror the active tab into the search param so deep links + browser-back work.`,
).toBe('flow')
// Flow DAG canvas mounts.
const svg = page.locator('[data-testid="jobs-flow-svg"]')
const svg = page.locator('[data-testid="flow-canvas-svg"]')
await expect(
svg,
'Flow tab is active but the [data-testid=jobs-flow-svg] canvas is missing. JobsFlowView must render an SVG with that testid.',
'FlowPage at /flow?scope=all did not render [data-testid=flow-canvas-svg]. See products/catalyst/bootstrap/ui/src/pages/sovereign/FlowPage.tsx.',
).toBeVisible({ timeout: 10_000 })
// Switching back to Table drops the search param.
const tableTab = page.locator('[data-testid="jobs-view-tab-table"]')
await tableTab.click()
// At least one batch swimlane is on the canvas.
const batches = page.locator('[data-testid^="flow-batch-"]')
expect(
await batches.count(),
'Flow canvas rendered with zero batch swimlanes — the bootstrap-kit batch alone must produce at least one.',
).toBeGreaterThan(0)
// At least one job bubble is on the canvas.
const bubbles = page.locator('[data-testid^="flow-job-"]')
expect(
await bubbles.count(),
'Flow canvas rendered with zero job bubbles. Check pipelineLayout.ts + FlowPage.tsx <JobBubble /> rendering.',
).toBeGreaterThan(0)
})
test('7. single-click on a job bubble opens the FloatingLogPane (25vw)', async ({ page }) => {
await page.goto('provision/test-deployment-id/flow?scope=all')
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('[data-testid="flow-canvas-svg"]')).toBeVisible({ timeout: 10_000 })
const bubble = page.locator('[data-testid^="flow-job-"]').first()
await bubble.click()
// Wait past the 220ms single-vs-double-click debounce.
const pane = page.locator('[data-testid="floating-log-pane"]')
await expect(
pane,
'Single-clicking a job bubble did not open [data-testid=floating-log-pane]. See FlowPage.tsx onJobSingleClick + FloatingLogPane.tsx.',
).toBeVisible({ timeout: 5_000 })
// Width is 25vw — assert via inline style, since vw is a viewport unit.
const widthStyle = await pane.evaluate((el) => (el as HTMLElement).style.width)
expect(
widthStyle,
`FloatingLogPane width is "${widthStyle}"; founder spec requires 25vw verbatim.`,
).toBe('25vw')
})
test('8. StatusStrip mode toggle (Jobs ↔ Batches) updates URL ?view=', async ({ page }) => {
await page.goto('provision/test-deployment-id/flow?scope=all')
await page.waitForLoadState('domcontentloaded')
const toggle = page.locator('[data-testid="sov-status-strip-mode-toggle"]')
await expect(
toggle,
'FlowPage StatusStrip is missing the Jobs↔Batches mode toggle. See StatusStrip.tsx + FlowPage.tsx.',
).toBeVisible({ timeout: 10_000 })
const batchesBtn = page.locator('[data-testid="sov-status-strip-mode-batches"]')
await expect(batchesBtn).toBeVisible()
await batchesBtn.click()
await page.waitForFunction(
() => !window.location.search.includes('view=flow'),
() => window.location.search.includes('view=batches'),
{ timeout: 5_000 },
)
expect(
new URL(page.url()).searchParams.get('view'),
'Clicking the Batches mode button did not push ?view=batches into the URL. The mode toggle must be URL-driven so Jobs↔Batches is bookmarkable.',
).toBe('batches')
})
})
test('7. expanded batch shows job cards, collapse toggle shrinks to supernode', async ({ page }) => {
await page.goto('provision/test-deployment-id/jobs?view=flow')
test.describe('@cosmetic-guard JobDetail v3 (Flow + Exec Log only)', () => {
test('JobDetail tab strip has EXACTLY 2 tabs: Flow + Exec Log', async ({ page }) => {
// Pick a known job id from the default catalog. bp-cilium is in
// the bootstrap-kit batch; its detail page must mount with the
// v3 two-tab layout regardless of any live SSE replay.
await page.goto('provision/test-deployment-id/jobs/bp-cilium')
await page.waitForLoadState('domcontentloaded')
const svg = page.locator('[data-testid="jobs-flow-svg"]')
await expect(svg, 'Flow tab did not mount on a deep link to ?view=flow.').toBeVisible({
timeout: 10_000,
})
// At least one batch must be on screen (the bootstrap-kit batch is
// always present once the catalog resolves; in test env without an
// SSE stream, every job is pending → batches stay expanded).
const batches = page.locator('[data-testid^="flow-batch-"]')
const nBatches = await batches.count()
const tablist = page.locator('[data-testid="job-detail-tablist"]')
await expect(tablist).toBeVisible({ timeout: 10_000 })
const tabs = tablist.locator('[role="tab"]')
expect(
nBatches,
'Flow canvas rendered with zero batch swimlanes — the bootstrap-kit batch alone must produce at least one. Check pipelineLayout.ts grouping.',
).toBeGreaterThan(0)
await tabs.count(),
'JobDetail must expose EXACTLY 2 tabs (Flow + Exec Log) per the v3 founder spec. Dependencies and Apps tabs were retired.',
).toBe(2)
// At least one job card must be rendered (in-flight batches stay
// expanded by the default-collapse policy).
const jobs = page.locator('[data-testid^="flow-job-"]')
const nJobs = await jobs.count()
const labels = (await tabs.allTextContents()).map((s) => s.trim())
expect(labels).toEqual(['Flow', 'Exec Log'])
// Dependencies + Apps tabs are gone.
expect(
nJobs,
'Expanded batch did not render any [data-testid^=flow-job-] cards. Check JobsFlowView.tsx <JobCardNode /> rendering.',
).toBeGreaterThan(0)
await page.locator('[data-testid="job-detail-tab-dependencies"]').count(),
'JobDetail still renders the Dependencies tab — v3 spec retired it. See JobDetail.tsx.',
).toBe(0)
expect(
await page.locator('[data-testid="job-detail-tab-apps"]').count(),
'JobDetail still renders the Apps tab — v3 spec retired it. See JobDetail.tsx.',
).toBe(0)
// Click the collapse toggle on the first batch — its job cards
// disappear and its supernode glyph appears.
const firstBatch = batches.first()
const batchTestId = await firstBatch.getAttribute('data-testid')
const batchId = batchTestId!.replace(/^flow-batch-/, '')
const toggle = page.locator(`[data-testid="flow-batch-toggle-${batchId}"]`)
await toggle.click()
// Flow tab is the default-active one.
expect(
await page.locator('[data-testid="job-detail-tab-flow"]').getAttribute('aria-selected'),
'JobDetail Flow tab must be aria-selected by default per the v3 founder spec.',
).toBe('true')
})
})
// Collapsed supernode glyph is visible.
const supernode = page.locator(`[data-testid="flow-batch-supernode-${batchId}"]`)
test.describe('@cosmetic-guard JobsTable batch chip → /flow link', () => {
test('batch chip in a JobsTable row is an <a> linking to /flow?scope=batch:<id>', async ({ page }) => {
await page.goto('provision/test-deployment-id/jobs')
await page.waitForLoadState('domcontentloaded')
const chip = page.locator('[data-testid="jobs-cell-batch-bp-cilium"]')
await expect(
supernode,
`Clicking the toggle on batch "${batchId}" did not collapse it. JobsFlowView.tsx onToggleBatch must flip the override set so the layout re-emits the batch as a supernode.`,
).toBeVisible({ timeout: 5_000 })
})
test('8. default-expanded for in-flight batch (visible job cards on first paint)', async ({ page }) => {
await page.goto('provision/test-deployment-id/jobs?view=flow')
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('[data-testid="jobs-flow-svg"]')).toBeVisible({ timeout: 10_000 })
// The default-collapse policy collapses all-succeeded batches and
// expands everything else. In a fresh test deployment, every job
// is `pending` → no batch should be collapsed → at least one
// [data-testid^=flow-job-] card MUST be in the SVG on first paint.
const jobs = page.locator('[data-testid^="flow-job-"]')
const n = await jobs.count()
chip,
'JobsTable batch chip [data-testid=jobs-cell-batch-bp-cilium] is missing — the chip must be a link to the per-batch flow canvas.',
).toBeVisible({ timeout: 10_000 })
const tag = await chip.evaluate((el) => el.tagName.toLowerCase())
expect(
n,
`Flow canvas rendered with zero job cards on first paint, but no batch should be collapsed in the test fixture (every job is pending). defaultCollapsedBatchIds policy regressed — see pipelineLayout.ts.`,
).toBeGreaterThan(0)
tag,
`JobsTable batch chip is a <${tag}> — must be an <a> (Link) so it navigates to /flow?scope=batch:<id>.`,
).toBe('a')
const href = (await chip.getAttribute('href')) ?? ''
expect(
/\/provision\/[^/]+\/flow/.test(href),
`Batch chip href "${href}" must navigate to the /flow route.`,
).toBe(true)
expect(
/scope=batch%3A|scope=batch:/.test(href),
`Batch chip href "${href}" must set ?scope=batch:<id> so the flow canvas filters to that batch only.`,
).toBe(true)
})
})

View File

@ -22,6 +22,7 @@ import { AppDetail } from '@/pages/sovereign/AppDetail'
import { JobsPage } from '@/pages/sovereign/JobsPage'
import { JobDetail } from '@/pages/sovereign/JobDetail'
import { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
import { FlowPage } from '@/pages/sovereign/FlowPage'
import { Dashboard } from '@/pages/sovereign/Dashboard'
import { BatchDetail } from '@/pages/sovereign/BatchDetail'
import { InfrastructurePage } from '@/pages/sovereign/InfrastructurePage'
@ -81,23 +82,37 @@ const provisionAppRoute = createRoute({
})
// Global jobs list — table view (issue #204 founder spec). Each row is
// a clickable link that navigates to the per-job detail page (owned by
// the JobDetail sibling agent and merged via #208).
// a clickable link that navigates to the per-job detail page.
//
// `?view` search param drives the active tab in the JobsPage tab strip:
// • view=table (default) — JobsTable
// • view=flow — JobsFlowView (two-level Sugiyama DAG)
// validateSearch coerces unknown values to `undefined` so deep links
// from older builds keep working without throwing. The Flow tab ships
// in this PR (urgent founder request).
// v3 (PR feat/flow-canvas-polish-and-routing) — the previous
// `?view=table|flow` Tab strip was removed. The Flow surface lives at
// its own /flow route below. JobsPage now has a "Show as Flow" button
// in the header that links to /flow?scope=all.
const provisionJobsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs',
component: JobsPage,
validateSearch: (raw: Record<string, unknown>): { view?: 'table' | 'flow' } => {
const v = raw?.view
if (v === 'flow' || v === 'table') return { view: v }
return {}
})
// Per-deployment flow canvas — every job (or one batch) as bubbles in
// a Sugiyama-laid DAG. Founder spec (this PR):
// • ?scope=all → render every job in the deployment
// • ?scope=batch:<batchId> → filter to a single batch
// • ?view=jobs|batches → mode toggle (default = jobs)
const provisionFlowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/flow',
component: FlowPage,
validateSearch: (raw: Record<string, unknown>): {
scope?: string
view?: 'jobs' | 'batches'
} => {
const out: { scope?: string; view?: 'jobs' | 'batches' } = {}
const scope = raw?.scope
if (typeof scope === 'string' && scope.length > 0) out.scope = scope
const view = raw?.view
if (view === 'jobs' || view === 'batches') out.view = view
return out
},
})
@ -239,6 +254,7 @@ const routeTree = rootRoute.addChildren([
provisionRoute,
provisionAppRoute,
provisionJobsRoute,
provisionFlowRoute,
provisionJobsTimelineRoute,
provisionJobDetailRoute,
provisionDashboardRoute,

View File

@ -0,0 +1,83 @@
/**
* FloatingLogPane.test.tsx coverage for the slide-in 25vw log
* viewer. Isolated from the FlowPage so the component contract is
* lockable independent of canvas state.
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { FloatingLogPane } from './FloatingLogPane'
afterEach(() => cleanup())
function renderPane(props: Partial<Parameters<typeof FloatingLogPane>[0]> = {}) {
const onClose = props.onClose ?? vi.fn()
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
const r = render(
<QueryClientProvider client={qc}>
<FloatingLogPane
executionId={'executionId' in props ? props.executionId : 'job-x:latest'}
jobTitle={props.jobTitle ?? 'Install Cilium'}
statusLabel={props.statusLabel ?? 'Running'}
statusTone={props.statusTone ?? 'running'}
onClose={onClose}
/>
</QueryClientProvider>,
)
return { ...r, onClose }
}
describe('FloatingLogPane — render', () => {
it('renders the floating-log-pane testid and the job title', () => {
renderPane({ jobTitle: 'Install Cilium' })
expect(screen.queryByTestId('floating-log-pane')).toBeTruthy()
expect(screen.queryByTestId('floating-log-pane-title')?.textContent).toBe('Install Cilium')
})
it('renders ExecutionLogs body when executionId is non-empty', () => {
renderPane({ executionId: 'job-x:latest' })
expect(screen.queryByTestId('floating-log-pane-body')).toBeTruthy()
expect(screen.queryByTestId('floating-log-pane-empty')).toBeNull()
})
it('renders the empty-state when executionId is null/empty', () => {
renderPane({ executionId: null })
expect(screen.queryByTestId('floating-log-pane-empty')).toBeTruthy()
const empty = screen.getByTestId('floating-log-pane-empty')
expect((empty.textContent ?? '').toLowerCase()).toContain('no execution')
})
it('inline width is 25vw', () => {
renderPane()
const aside = screen.getByTestId('floating-log-pane') as HTMLElement
// jsdom keeps the inline `style.width` verbatim from React's
// CSSProperties — assert the literal value (not a computed
// resolution which jsdom does not perform for vw units).
expect(aside.style.width).toBe('25vw')
})
})
describe('FloatingLogPane — close behaviour', () => {
it('clicking the X button calls onClose', () => {
const { onClose } = renderPane()
fireEvent.click(screen.getByTestId('floating-log-pane-close'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('pressing Escape calls onClose', () => {
const { onClose } = renderPane()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('removes the document Escape listener on unmount', () => {
const onClose = vi.fn()
const { unmount } = renderPane({ onClose })
unmount()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,224 @@
/**
* FloatingLogPane slide-in 25vw log viewer that overlays the right
* edge of the FlowPage canvas. Mounts on single-click of a job bubble
* (per founder spec, see FlowPage.tsx). Reuses the canonical
* <ExecutionLogs /> component (#208) for the log body no rebuild,
* just a new container chrome.
*
* Behavioural contract (locked):
* Width: 25vw (matches the founder's mock spec verbatim).
* Position: fixed; right: 0; vertical extent = full viewport
* height below the PortalShell top header.
* z-index: above the canvas. NO modal backdrop the canvas
* remains pannable / clickable while the pane is open.
* Closes on:
* 1. X button click
* 2. Escape key
* 3. (Caller's responsibility) click on canvas empty area
* Slide-in: 200ms cubic-bezier from off-screen right.
* Pending-job branch: when `executionId` is empty, renders a
* "No execution recorded yet" empty state instead of mounting
* the log poller.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall) full target shape ships in this component:
* slide-in animation, escape close, empty-state branch.
* #4 (never hardcode) width / colours / spacing all read theme
* tokens; only the slide-in keyframe owns motion-specific values.
*/
import { useEffect } from 'react'
import { ExecutionLogs } from './ExecutionLogs'
interface FloatingLogPaneProps {
/**
* Stable execution id used to fetch logs from the catalyst-api.
* When falsy / empty, the pane renders the "no execution recorded
* yet" empty state instead of mounting the polling viewer (a job
* that's still pending has no execution row in the backend).
*/
executionId: string | null | undefined
/** Display title — typically `${job.jobName}`. */
jobTitle: string
/** Status text rendered as a small chip in the header strip. */
statusLabel?: string
/** Status colour class (matches StatusBadge tones). */
statusTone?: 'pending' | 'running' | 'succeeded' | 'failed'
/** Closes the pane (called from X click, Escape key). */
onClose: () => void
}
const STATUS_TONE: Record<NonNullable<FloatingLogPaneProps['statusTone']>, { bg: string; fg: string; border: string }> = {
pending: { bg: 'rgba(148,163,184,0.10)', fg: 'var(--color-text-dim)', border: 'rgba(148,163,184,0.30)' },
running: { bg: 'rgba(56,189,248,0.10)', fg: '#38BDF8', border: 'rgba(56,189,248,0.40)' },
succeeded: { bg: 'rgba(74,222,128,0.10)', fg: '#4ADE80', border: 'rgba(74,222,128,0.40)' },
failed: { bg: 'rgba(248,113,113,0.10)', fg: '#F87171', border: 'rgba(248,113,113,0.40)' },
}
export function FloatingLogPane({
executionId,
jobTitle,
statusLabel,
statusTone = 'pending',
onClose,
}: FloatingLogPaneProps) {
// Escape key → close. Bound at the document level so the listener
// fires regardless of which child element is focused. Cleaned up on
// unmount so the listener never leaks across remounts.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const tone = STATUS_TONE[statusTone]
return (
<aside
role="complementary"
aria-label={`Logs for ${jobTitle}`}
data-testid="floating-log-pane"
style={FLOATING_PANE_STYLE}
>
<style>{FLOATING_PANE_CSS}</style>
<header className="floating-pane-header" data-testid="floating-log-pane-header">
<span
className="floating-pane-status"
style={{ background: tone.bg, color: tone.fg, borderColor: tone.border }}
data-testid="floating-log-pane-status"
>
{statusLabel ?? statusTone}
</span>
<span className="floating-pane-title" data-testid="floating-log-pane-title" title={jobTitle}>
{jobTitle}
</span>
<button
type="button"
className="floating-pane-close"
aria-label="Close log pane"
data-testid="floating-log-pane-close"
onClick={onClose}
>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
<path d="M2 2 L12 12 M12 2 L2 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</header>
<div className="floating-pane-body" data-testid="floating-log-pane-body">
{executionId ? (
<ExecutionLogs executionId={executionId} />
) : (
<div
className="floating-pane-empty"
data-testid="floating-log-pane-empty"
>
No execution recorded yet.
</div>
)}
</div>
</aside>
)
}
/* ── Styles (co-located, theme-token bound) ─────────────────────── */
// Inline `style` — keeps the 25vw width pinned to the founder spec
// without binding to a Tailwind arbitrary value (which would only fire
// at runtime if Tailwind's safelist had it). The body / header rules
// live in the embedded <style>{FLOATING_PANE_CSS}</style> so theme
// switches work via CSS-variable cascade.
const FLOATING_PANE_STYLE: React.CSSProperties = {
position: 'fixed',
right: 0,
top: 56, // PortalShell header h-14 = 3.5rem = 56px
bottom: 0,
width: '25vw',
minWidth: 320,
zIndex: 60,
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
boxShadow: '-8px 0 24px rgba(2,6,15,0.45)',
animation: 'floating-pane-in 200ms cubic-bezier(0.4, 0, 0.2, 1)',
}
const FLOATING_PANE_CSS = `
@keyframes floating-pane-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.floating-pane-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.floating-pane-status {
display: inline-flex;
align-items: center;
padding: 0.12rem 0.55rem;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid;
white-space: nowrap;
}
.floating-pane-title {
flex: 1 1 auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.floating-pane-close {
appearance: none;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-dim);
border-radius: 6px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.floating-pane-close:hover {
color: var(--color-text-strong);
border-color: var(--color-border-strong, var(--color-text-dim));
background: rgba(148,163,184,0.08);
}
.floating-pane-body {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
.floating-pane-empty {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
color: var(--color-text-dim);
font-size: 0.85rem;
padding: 2rem 1rem;
text-align: center;
}
`

View File

@ -1,99 +0,0 @@
/**
* JobApps chip list of Applications this job belongs to.
*
* Founder requirement (epic #204 item 5 + 6): the Job Detail page has
* an Apps tab; the table view exposes apps as a column. Each chip links
* to the AppDetail page for that application.
*
* Multiple apps per job are supported a single job (e.g. a shared
* post-install hook) can be attributed to several Applications. The
* current Catalyst data model attaches a single `app` to each Job, but
* the founder spec calls out plural "Apps" as a tab; we accept an
* `appIds` array so this component is forward-compatible the moment the
* data model evolves to many-apps-per-job.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), each chip's
* label is sourced from the application descriptor lookup; chips for
* unknown apps fall back to the bare id so the UI never silently swallows
* a stale reference.
*/
import { Link } from '@tanstack/react-router'
import type { ApplicationDescriptor } from '@/pages/sovereign/applicationCatalog'
interface JobAppsProps {
/** App ids this job belongs to (may be a single-element array). */
appIds: string[]
/** Lookup keyed by Blueprint id ("bp-<slug>"). */
appsById: Record<string, ApplicationDescriptor>
/** Stable deployment id — needed for the AppDetail link. */
deploymentId: string
}
export function JobApps({ appIds, appsById, deploymentId }: JobAppsProps) {
// The Phase 0 / cluster-bootstrap pseudo-apps don't have an AppDetail
// page — surface them as plain (unlinked) chips so the operator still
// sees the attribution but can't navigate to a 404.
const SYSTEM_APPS = new Set(['infrastructure', 'cluster-bootstrap'])
if (appIds.length === 0) {
return (
<div
data-testid="job-apps-empty"
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-text-dim)]"
>
This job is not attributed to any application it is a deployment-
level step (Phase 0 infrastructure or cluster bootstrap).
</div>
)
}
return (
<div data-testid="job-apps-list" className="flex flex-col gap-3">
<p className="text-xs text-[var(--color-text-dim)]">
Attributed to {appIds.length} application{appIds.length === 1 ? '' : 's'}
click a chip to open its detail page.
</p>
<div className="flex flex-wrap gap-2">
{appIds.map((appId) => {
const app = appsById[appId]
const label = app?.title ?? appId
if (SYSTEM_APPS.has(appId)) {
return (
<span
key={appId}
data-testid={`job-app-chip-${appId}`}
data-system="true"
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text-dim)]"
>
{label}
</span>
)
}
return (
<Link
key={appId}
to="/provision/$deploymentId/app/$componentId"
params={{ deploymentId, componentId: appId }}
data-testid={`job-app-chip-${appId}`}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-semibold text-[var(--color-text-strong)] no-underline hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
{app?.logoUrl ? (
<img
src={app.logoUrl}
alt=""
className="h-4 w-4 rounded"
aria-hidden
/>
) : null}
<span>{label}</span>
<span className="text-[10px] font-normal text-[var(--color-text-dim)]">
</span>
</Link>
)
})}
</div>
</div>
)
}

View File

@ -1,185 +0,0 @@
/**
* JobDependencies Dependencies tab for the Job Detail page.
*
* Founder requirement (epic #204):
* Item 5 JobDetail tabs are Execution Logs / Dependencies / Apps.
* Item 11 "Job dependencies are very important [...] we may
* consider having gantt or like view, you may suggest". Sub-ticket
* #206 owns the recommendation + implementation; the proposal at
* `docs/proposals/jobs-dependencies-viz.md` selects an SVG DAG
* primary surface (this tab) + a fullscreen Gantt timeline at
* `/sovereign/provision/$id/jobs/timeline` for retrospective.
*
* Surface contract:
* 1. SVG DAG (`<JobDependenciesGraph />`) showing this job + its
* upstream chain, color-coded by status, click-to-navigate.
* 2. Below the graph, a compact list of immediate upstream deps with
* status + per-job link preserved for keyboard accessibility +
* screen readers (the SVG nodes are also focusable, but the list
* stays the canonical "jump to dep" affordance).
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every node /
* edge / colour comes from the supplied jobs lookup or the depsLayout
* function no inlined dependency maps.
*/
import { Link, useNavigate } from '@tanstack/react-router'
import type { Job, JobUiStatus } from '@/pages/sovereign/jobs'
import {
JobDependenciesGraph,
type JobNode,
} from '@/widgets/job-deps-graph/JobDependenciesGraph'
interface JobDependenciesProps {
/** The job whose dependencies are being rendered. */
job: { id: string; title?: string; status?: JobUiStatus; dependsOn: string[] }
/** Lookup of all jobs in the deployment (for status + title). */
jobsById: Record<string, Job>
/** Stable deployment id — needed for the per-job detail link. */
deploymentId: string
}
/** Label + colour mapping for a job UI status. Aligned with
* `statusBadge()` in jobs.ts so the visual vocabulary is consistent
* between the JobsPage list, the JobDetail header, and this dep list. */
const STATUS_PALETTE: Record<
JobUiStatus,
{ label: string; bg: string; fg: string }
> = {
succeeded: { label: 'Succeeded', bg: 'rgba(34, 197, 94, 0.15)', fg: '#22c55e' },
running: { label: 'Running', bg: 'rgba(59, 130, 246, 0.15)', fg: '#3b82f6' },
failed: { label: 'Failed', bg: 'rgba(239, 68, 68, 0.15)', fg: '#ef4444' },
pending: { label: 'Pending', bg: 'rgba(245, 158, 11, 0.15)', fg: '#f59e0b' },
}
export function JobDependencies({
job,
jobsById,
deploymentId,
}: JobDependenciesProps) {
const deps = job.dependsOn ?? []
const navigate = useNavigate()
if (deps.length === 0) {
return (
<div
data-testid="job-deps-empty"
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-text-dim)]"
>
This job has no upstream dependencies it can start as soon as the
deployment reaches its phase.
</div>
)
}
// Build the graph slice: this job + every transitively-upstream job
// we can resolve from `jobsById`. Each node carries its own status
// and dependsOn so the layout can render multi-level chains, not
// just the one-hop immediate-deps view.
const graphJobs = buildGraphSlice(job, jobsById)
return (
<div data-testid="job-deps-section" className="flex flex-col gap-4">
<JobDependenciesGraph
jobs={graphJobs}
height={380}
onNodeClick={(jobId) => {
if (jobId === job.id) return // Already on this job's page.
navigate({
to: '/provision/$deploymentId/jobs/$jobId',
params: { deploymentId, jobId },
})
}}
/>
<div data-testid="job-deps-list" className="flex flex-col gap-2">
<p className="text-xs text-[var(--color-text-dim)]">
{deps.length} upstream {deps.length === 1 ? 'dependency' : 'dependencies'}
this job waits for all of them to finish before starting.
</p>
<ul className="flex flex-col gap-2">
{deps.map((depId) => {
const dep = jobsById[depId]
const status: JobUiStatus = dep?.status ?? 'pending'
const palette = STATUS_PALETTE[status]
const title = dep?.title ?? depId
return (
<li
key={depId}
data-testid={`job-dep-${depId}`}
data-status={status}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3"
>
<div className="flex items-center gap-3">
<Link
to="/provision/$deploymentId/jobs/$jobId"
params={{ deploymentId, jobId: depId }}
className="flex-1 truncate text-sm font-semibold text-[var(--color-text-strong)] hover:text-[var(--color-accent)] no-underline"
>
{title}
</Link>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
style={{ background: palette.bg, color: palette.fg }}
>
{palette.label}
</span>
</div>
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-dim)]">
{depId}
</p>
</li>
)
})}
</ul>
</div>
</div>
)
}
/**
* Walk upstream from `job` collecting every reachable upstream job and
* the job itself. Stops at jobs not present in `jobsById` (the dep
* graph is intentionally narrow until the backend lands).
*/
function buildGraphSlice(
job: { id: string; title?: string; status?: JobUiStatus; dependsOn: string[] },
jobsById: Record<string, Job>,
): JobNode[] {
const out: JobNode[] = []
const seen = new Set<string>()
// Self.
out.push({
id: job.id,
title: job.title ?? jobsById[job.id]?.title ?? job.id,
status: job.status ?? jobsById[job.id]?.status ?? 'pending',
dependsOn: job.dependsOn,
})
seen.add(job.id)
// BFS upstream.
const queue = [...job.dependsOn]
while (queue.length > 0) {
const id = queue.shift()!
if (seen.has(id)) continue
seen.add(id)
const upstream = jobsById[id]
if (!upstream) {
// Render as an empty pending node so the edge still draws.
out.push({ id, title: id, status: 'pending', dependsOn: [] })
continue
}
out.push({
id: upstream.id,
title: upstream.title,
status: upstream.status,
// The current Job model in jobs.ts doesn't carry `dependsOn` yet
// (sibling backend ticket #205). Render upstream nodes with an
// empty dependsOn until that lands; the chain still draws because
// the *current* job's dependsOn is the only edge source we need.
dependsOn: [],
})
}
return out
}

View File

@ -0,0 +1,317 @@
/**
* StatusStrip top contextual strip rendered below the PortalShell
* header on /flow* and /jobs* routes only. Mirrors the canonical
* provision-mockup.html topbar geometry (breadcrumb + provisioning
* pill + progress bar + optional JobsBatches mode toggle).
*
* Layout (left right):
* [Sovereign / fqdn] [ Provisioning · pulse] [ N/M · elapsed]
* [Jobs Batches toggle (only on /flow)]
*
* Per founder spec, the captions toggle (`Aa`) and log-panel toggle
* (``) from the mock are explicitly DROPPED the log is contextual
* (FloatingLogPane on bubble click) and captions don't add enough
* value to keep around.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #4 (never hardcode) status, progress, elapsed are all props.
* #4 every colour is a theme token; the running pulse animation
* keeps in lockstep with provision-mockup.html @keyframes pulse.
*/
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
export type ProvisioningStatus = 'pending' | 'running' | 'succeeded' | 'failed'
interface StatusStripProps {
/** Stable deployment id — embedded in the breadcrumb back-link target. */
deploymentId: string
/** Resolved sovereign FQDN. Falls back to deploymentId-prefix when null. */
sovereignFQDN?: string | null
/** Coarse status of the deployment — drives pill colour + pulse. */
status: ProvisioningStatus
/** Number of jobs that reached a terminal state. */
finished: number
/** Total number of jobs in the current scope. */
total: number
/** Elapsed time in milliseconds since the earliest startedAt. */
elapsedMs: number
/**
* When set, renders the JobsBatches mode toggle. The toggle handler
* receives the next mode (consumer is responsible for URL updates).
*/
modeToggle?: {
mode: 'jobs' | 'batches'
onChange: (next: 'jobs' | 'batches') => void
}
/** Optional extra slot rendered after the progress bar (e.g. test hooks). */
trailing?: ReactNode
}
const STATUS_TONE: Record<
ProvisioningStatus,
{ dot: string; pillBg: string; pillBorder: string; pillFg: string; barFill: string; label: string }
> = {
pending: {
dot: 'rgba(148,163,184,0.7)',
pillBg: 'rgba(148,163,184,0.10)',
pillBorder: 'rgba(148,163,184,0.30)',
pillFg: 'var(--color-text-dim)',
barFill: '#94A3B8',
label: 'Pending',
},
running: {
dot: '#38BDF8',
pillBg: 'rgba(56,189,248,0.10)',
pillBorder: 'rgba(56,189,248,0.30)',
pillFg: '#38BDF8',
barFill: 'linear-gradient(90deg, #38BDF8, #818CF8)',
label: 'Provisioning',
},
succeeded: {
dot: '#4ADE80',
pillBg: 'rgba(74,222,128,0.10)',
pillBorder: 'rgba(74,222,128,0.30)',
pillFg: '#4ADE80',
barFill: '#4ADE80',
label: 'Completed',
},
failed: {
dot: '#F87171',
pillBg: 'rgba(248,113,113,0.10)',
pillBorder: 'rgba(248,113,113,0.35)',
pillFg: '#F87171',
barFill: '#F87171',
label: 'Failed',
},
}
function formatElapsed(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
return `${s}s`
}
export function StatusStrip({
deploymentId,
sovereignFQDN,
status,
finished,
total,
elapsedMs,
modeToggle,
trailing,
}: StatusStripProps) {
const tone = STATUS_TONE[status]
const pct = total > 0 ? Math.min(100, Math.round((finished / total) * 100)) : 0
const fqdnLabel = sovereignFQDN ?? `deployment ${deploymentId.slice(0, 8)}`
return (
<div className="status-strip" data-testid="sov-status-strip" role="region" aria-label="Provisioning status">
<style>{STATUS_STRIP_CSS}</style>
<Link
to="/provision/$deploymentId"
params={{ deploymentId }}
className="status-strip-breadcrumb"
data-testid="sov-status-strip-breadcrumb"
>
<span className="status-strip-breadcrumb-prefix">Sovereign</span>
<span className="status-strip-breadcrumb-sep">/</span>
<span className="status-strip-breadcrumb-fqdn" title={fqdnLabel}>
{fqdnLabel}
</span>
</Link>
<div
className="status-strip-pill"
data-testid="sov-status-strip-pill"
data-status={status}
style={{ background: tone.pillBg, borderColor: tone.pillBorder, color: tone.pillFg }}
>
<span
className={`status-strip-dot${status === 'running' ? ' pulse' : ''}`}
style={{ background: tone.dot }}
aria-hidden
/>
<span className="status-strip-pill-label">{tone.label}</span>
</div>
<div className="status-strip-progress" data-testid="sov-status-strip-progress">
<div className="status-strip-bar">
<div
className="status-strip-bar-fill"
data-testid="sov-status-strip-bar-fill"
style={{ width: `${pct}%`, background: tone.barFill }}
/>
</div>
<span className="status-strip-progress-count" data-testid="sov-status-strip-count">
{finished}/{total}
</span>
<span className="status-strip-progress-elapsed" data-testid="sov-status-strip-elapsed">
{formatElapsed(elapsedMs)}
</span>
</div>
{modeToggle ? (
<div
className="status-strip-mode-toggle"
role="tablist"
aria-label="Flow view mode"
data-testid="sov-status-strip-mode-toggle"
>
{(['jobs', 'batches'] as const).map((m) => {
const active = modeToggle.mode === m
return (
<button
key={m}
type="button"
role="tab"
aria-selected={active}
className={`status-strip-mode-btn${active ? ' active' : ''}`}
data-testid={`sov-status-strip-mode-${m}`}
onClick={() => {
if (!active) modeToggle.onChange(m)
}}
>
{m === 'jobs' ? 'Jobs' : 'Batches'}
</button>
)
})}
</div>
) : null}
{trailing}
</div>
)
}
const STATUS_STRIP_CSS = `
.status-strip {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--color-text-dim);
}
.status-strip-breadcrumb {
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
color: var(--color-text-dim);
font-size: 0.78rem;
font-weight: 500;
transition: color 0.12s ease;
max-width: 36ch;
white-space: nowrap;
overflow: hidden;
}
.status-strip-breadcrumb:hover { color: var(--color-text); }
.status-strip-breadcrumb-prefix {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-dim);
}
.status-strip-breadcrumb-sep { color: var(--color-text-dim); opacity: 0.5; }
.status-strip-breadcrumb-fqdn {
color: var(--color-text-strong);
font-family: var(--font-mono, ui-monospace, monospace);
letter-spacing: 0.01em;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-strip-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.7rem;
border-radius: 999px;
border: 1px solid;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-strip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-strip-dot.pulse {
animation: status-strip-pulse 2s ease-in-out infinite;
}
@keyframes status-strip-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.65); }
}
.status-strip-progress {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-strip-bar {
width: 100px;
height: 4px;
border-radius: 2px;
background: var(--color-border);
overflow: hidden;
}
.status-strip-bar-fill {
height: 100%;
transition: width 0.4s ease;
}
.status-strip-progress-count,
.status-strip-progress-elapsed {
font-size: 0.74rem;
color: var(--color-text-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.status-strip-mode-toggle {
display: inline-flex;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
overflow: hidden;
margin-left: auto;
}
.status-strip-mode-btn {
appearance: none;
background: transparent;
border: none;
padding: 0.3rem 0.85rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-dim);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.status-strip-mode-btn:hover { color: var(--color-text); }
.status-strip-mode-btn.active {
background: var(--color-accent);
color: var(--color-bg);
}
`

View File

@ -342,6 +342,33 @@ describe('pipelineLayout — collapsed batches', () => {
})
})
describe('pipelineLayout — highlightJobId', () => {
it('marks exactly one job node as highlighted when option is set', () => {
const result = pipelineLayout(FIVE_JOB_FANIN, { highlightJobId: '3' })
const highlighted = result.nodes.filter(
(n) => n.kind === 'job' && n.highlighted === true,
)
expect(highlighted.length).toBe(1)
expect(highlighted[0]!.id).toBe('3')
})
it('marks zero nodes as highlighted when option is unset', () => {
const result = pipelineLayout(FIVE_JOB_FANIN)
const highlighted = result.nodes.filter(
(n) => n.kind === 'job' && n.highlighted === true,
)
expect(highlighted.length).toBe(0)
})
it('matches no node when highlightJobId is unknown', () => {
const result = pipelineLayout(FIVE_JOB_FANIN, { highlightJobId: 'no-such-id' })
const highlighted = result.nodes.filter(
(n) => n.kind === 'job' && n.highlighted === true,
)
expect(highlighted.length).toBe(0)
})
})
/*
* Helpers pure functions that the layout exports
* */

View File

@ -72,6 +72,13 @@ export interface FlowNode {
stage?: number
/** Owning Job for `kind: 'job'`. Undefined for `kind: 'batch'`. */
job?: Job
/**
* True when the node matches the optional `highlightJobId` passed to
* `pipelineLayout()`. Used by `FlowPage` to render the canonical
* thicker-border + glow treatment when this surface is embedded in
* the JobDetail Flow tab and one specific job is the focus.
*/
highlighted?: boolean
}
/** A laid-out edge (inner-batch arrow OR cross-batch meta-edge). */
@ -121,7 +128,7 @@ export interface FlowLayoutResult {
height: number
}
/** Geometry knobs — keep in lockstep with the JobsFlowView CSS. */
/** Geometry knobs — keep in lockstep with the FlowPage canvas CSS. */
export interface FlowGeometry {
/** Distance between adjacent stage columns (job inner). */
jobColumnWidth: number
@ -165,6 +172,13 @@ export interface PipelineLayoutOptions {
collapsedBatchIds?: ReadonlySet<string>
/** Geometry overrides; sparse partial — unspecified keys fall back to defaults. */
geometry?: Partial<FlowGeometry>
/**
* Optional id of a job to mark as `highlighted = true` on its FlowNode
* output. Consumers (FlowPage) render a thicker-border glow treatment
* when set. Used by JobDetail's embedded Flow tab to draw the
* operator's eye to the parent job. Pure flag no layout change.
*/
highlightJobId?: string
}
/*
@ -531,6 +545,7 @@ export function pipelineLayout(
): FlowLayoutResult {
const collapsed = opts.collapsedBatchIds ?? new Set<string>()
const geom: FlowGeometry = { ...DEFAULT_GEOMETRY, ...opts.geometry }
const highlightJobId = opts.highlightJobId
if (jobs.length === 0) {
return {
@ -788,6 +803,7 @@ export function pipelineLayout(
kind: 'job',
stage: rel.stage,
job: j,
highlighted: highlightJobId === j.id,
}
nodes.push(absNode)
jobNodeAbs.set(j.id, absNode)
@ -930,7 +946,7 @@ export function defaultCollapsedBatchIds(jobs: readonly Job[]): Set<string> {
* 3-point fallback straight L through the middle point
*
* Pure helper, exported so tests can lock the contract and the
* JobsFlowView doesn't have to repeat the if-ladder inline.
* FlowPage doesn't have to repeat the if-ladder inline.
*/
export function edgeToPath(points: readonly { x: number; y: number }[]): string {
if (points.length < 2) return ''

View File

@ -0,0 +1,245 @@
/**
* FlowPage.test.tsx coverage for the new /flow route + the
* embedded variant used inside JobDetail's Flow tab.
*
* Coverage:
* resolveScope helper `all`, `batch:<id>`, fallthrough.
* Renders for ?scope=all (every job in the catalog).
* Renders for ?scope=batch:<id> (filters to one batch).
* Renders for ?scope=batch:<unknown> (empty placeholder).
* Mode toggle (Jobs Batches) updates the URL ?view= param.
* Single-click on a job bubble opens FloatingLogPane.
* Click on empty canvas closes the floating pane.
* Embedded variant: no PortalShell, no StatusStrip.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { FlowPage, resolveScope, resolveMode } from './FlowPage'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
beforeEach(() => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
globalThis.fetch = (() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ events: [], state: undefined, done: false }),
} as unknown as Response)) as typeof fetch
})
afterEach(() => cleanup())
function renderFlow(initialEntry: string) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const flowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/flow',
component: () => <FlowPage disableStream disableJobsBackfill />,
validateSearch: (raw: Record<string, unknown>): {
scope?: string
view?: 'jobs' | 'batches'
} => {
const out: { scope?: string; view?: 'jobs' | 'batches' } = {}
const scope = raw?.scope
if (typeof scope === 'string' && scope.length > 0) out.scope = scope
const view = raw?.view
if (view === 'jobs' || view === 'batches') out.view = view
return out
},
})
const jobsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs',
component: () => <div data-testid="jobs-target" />,
})
const detailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs/$jobId',
component: () => <div data-testid="job-detail-target" />,
})
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId',
component: () => <div data-testid="apps-target" />,
})
const tree = rootRoute.addChildren([flowRoute, jobsRoute, detailRoute, homeRoute])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({ initialEntries: [initialEntry] }),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return {
...render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
),
router,
}
}
describe('resolveScope', () => {
it('returns kind=all for "all"', () => {
expect(resolveScope('all')).toEqual({ kind: 'all' })
})
it('returns kind=batch with id for "batch:foo"', () => {
expect(resolveScope('batch:foo')).toEqual({ kind: 'batch', batchId: 'foo' })
})
it('returns kind=all for unknown / falsy', () => {
expect(resolveScope(undefined)).toEqual({ kind: 'all' })
expect(resolveScope('')).toEqual({ kind: 'all' })
expect(resolveScope('garbage')).toEqual({ kind: 'all' })
expect(resolveScope('batch:')).toEqual({ kind: 'all' })
})
})
describe('resolveMode', () => {
it('returns "jobs" by default', () => {
expect(resolveMode(undefined)).toBe('jobs')
expect(resolveMode('')).toBe('jobs')
expect(resolveMode('garbage')).toBe('jobs')
})
it('returns "batches" when explicit', () => {
expect(resolveMode('batches')).toBe('batches')
})
})
describe('FlowPage — scope=all', () => {
it('renders the canvas SVG', async () => {
renderFlow('/provision/d-1/flow?scope=all')
expect(await screen.findByTestId('flow-canvas-svg')).toBeTruthy()
})
it('renders at least one job bubble (default catalog)', async () => {
renderFlow('/provision/d-1/flow?scope=all')
await screen.findByTestId('flow-canvas-svg')
const bubbles = document.querySelectorAll('[data-testid^="flow-job-"]')
expect(bubbles.length).toBeGreaterThan(0)
})
it('renders the StatusStrip with mode toggle', async () => {
renderFlow('/provision/d-1/flow?scope=all')
expect(await screen.findByTestId('sov-status-strip')).toBeTruthy()
expect(await screen.findByTestId('sov-status-strip-mode-toggle')).toBeTruthy()
})
})
describe('FlowPage — scope=batch:applications', () => {
it('filters to applications batch only', async () => {
// The jobsAdapter buckets every per-Application Job into the
// 'applications' batch (see jobsAdapter.batchOf()). Phase 0 +
// cluster-bootstrap have their own batches, so a scope-filter
// to 'applications' must hide them.
renderFlow('/provision/d-1/flow?scope=batch:applications')
await screen.findByTestId('flow-canvas-svg')
expect(screen.queryByTestId('flow-job-infrastructure:tofu-init')).toBeNull()
expect(screen.queryByTestId('flow-job-cluster-bootstrap')).toBeNull()
// bp-cilium IS in the applications batch and must be present.
expect(screen.queryByTestId('flow-job-bp-cilium')).toBeTruthy()
})
})
describe('FlowPage — scope=batch:nonexistent', () => {
it('renders the empty-canvas placeholder', async () => {
renderFlow('/provision/d-1/flow?scope=batch:nonexistent')
expect(await screen.findByTestId('flow-canvas-empty')).toBeTruthy()
})
})
describe('FlowPage — single-click opens floating log pane', () => {
it('renders FloatingLogPane after a single-click on a bubble', async () => {
renderFlow('/provision/d-1/flow?scope=all')
await screen.findByTestId('flow-canvas-svg')
const bubbles = document.querySelectorAll('[data-testid^="flow-job-"]')
expect(bubbles.length).toBeGreaterThan(0)
const target = bubbles[0] as Element
// Single-click — debounced 220ms before the handler fires.
fireEvent.click(target)
// Wait past the 220ms debounce; testing-library's findBy* polls
// every ~50ms so 1500ms is plenty of headroom for the render.
const pane = await screen.findByTestId('floating-log-pane', undefined, {
timeout: 1500,
})
expect(pane).toBeTruthy()
})
it('clicking on empty canvas background closes the pane', async () => {
renderFlow('/provision/d-1/flow?scope=all')
const svg = await screen.findByTestId('flow-canvas-svg')
const bubbles = document.querySelectorAll('[data-testid^="flow-job-"]')
const target = bubbles[0] as Element
fireEvent.click(target)
await screen.findByTestId('floating-log-pane', undefined, { timeout: 1500 })
// Click directly on the SVG element (background, not a child).
fireEvent.click(svg, { target: svg })
// Pane unmounts synchronously when the background-click handler fires.
expect(screen.queryByTestId('floating-log-pane')).toBeNull()
})
})
describe('FlowPage — mode toggle', () => {
it('clicking Batches updates URL ?view=batches', async () => {
const { router } = renderFlow('/provision/d-1/flow?scope=all')
await screen.findByTestId('sov-status-strip-mode-toggle')
const batchesBtn = screen.getByTestId('sov-status-strip-mode-batches')
fireEvent.click(batchesBtn)
// Router state reflects the new search param.
const view = (router.state.location.search as { view?: string }).view
expect(view).toBe('batches')
})
})
describe('FlowPage — embedded variant', () => {
it('renders without StatusStrip when embedded prop is set', async () => {
// Embedded variant is always rendered by JobDetail with a
// scopeOverride; we simulate that here by mounting the component
// directly inside a route that supplies the same params via the
// `deploymentIdOverride` prop.
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const flowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/flow',
component: () => (
<FlowPage
disableStream
disableJobsBackfill
embedded
deploymentIdOverride="d-1"
scopeOverride={{ kind: 'batch', batchId: 'applications' }}
highlightJobId="bp-cilium"
/>
),
validateSearch: () => ({}),
})
const tree = rootRoute.addChildren([flowRoute])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: ['/provision/d-1/flow'],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
expect(await screen.findByTestId('flow-page-embedded')).toBeTruthy()
expect(screen.queryByTestId('sov-status-strip')).toBeNull()
expect(screen.queryByTestId('sov-portal-shell')).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,120 @@
/**
* JobDetail.test.tsx lock-in for the v3 JobDetail surface (this PR).
*
* Coverage:
* Tab strip has EXACTLY two tabs labeled "Flow" and "Exec Log".
* Default-active tab = Flow.
* Dependencies + Apps tabs are GONE (removed in v3).
* Flow tab renders the embedded FlowPage (data-testid='flow-page-embedded').
* Exec Log tab renders the ExecutionLogs viewer.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { JobDetail } from './JobDetail'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
function renderDetail(deploymentId: string, jobId: string) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const detailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs/$jobId',
component: () => <JobDetail disableStream />,
})
const flowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/flow',
component: () => <div data-testid="flow-target" />,
})
const jobsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs',
component: () => <div data-testid="jobs-target" />,
})
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId',
component: () => <div data-testid="apps-target" />,
})
const tree = rootRoute.addChildren([detailRoute, flowRoute, jobsRoute, homeRoute])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: [`/provision/${deploymentId}/jobs/${jobId}`],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
beforeEach(() => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
globalThis.fetch = (() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ events: [], state: undefined, done: false }),
} as unknown as Response)) as typeof fetch
})
afterEach(() => cleanup())
describe('JobDetail — v3 tab strip', () => {
it('renders exactly 2 tabs labeled Flow + Exec Log', async () => {
// Use a known fixture job id — `bp-cilium` lives in the bootstrap-kit
// batch and is part of the default-application catalog.
renderDetail('d-1', 'bp-cilium')
const tablist = await screen.findByTestId('job-detail-tablist')
const tabs = tablist.querySelectorAll('[role="tab"]')
expect(tabs.length).toBe(2)
const labels = Array.from(tabs).map((t) => (t.textContent ?? '').trim())
expect(labels).toEqual(['Flow', 'Exec Log'])
})
it('Flow tab is active by default', async () => {
renderDetail('d-1', 'bp-cilium')
const flowTab = await screen.findByTestId('job-detail-tab-flow')
expect(flowTab.getAttribute('aria-selected')).toBe('true')
})
it('does NOT render Dependencies or Apps tabs (v2 retired)', async () => {
renderDetail('d-1', 'bp-cilium')
await screen.findByTestId('job-detail-tablist')
expect(screen.queryByTestId('job-detail-tab-dependencies')).toBeNull()
expect(screen.queryByTestId('job-detail-tab-apps')).toBeNull()
expect(screen.queryByTestId('job-detail-deps-panel')).toBeNull()
expect(screen.queryByTestId('job-detail-apps-panel')).toBeNull()
})
it('Flow tab panel mounts the embedded FlowPage canvas', async () => {
renderDetail('d-1', 'bp-cilium')
await screen.findByTestId('job-detail-tablist')
expect(screen.queryByTestId('job-detail-flow-panel')).toBeTruthy()
expect(screen.queryByTestId('flow-page-embedded')).toBeTruthy()
})
it('clicking Exec Log tab swaps the active panel', async () => {
renderDetail('d-1', 'bp-cilium')
await screen.findByTestId('job-detail-tablist')
const logTab = screen.getByTestId('job-detail-tab-logs')
fireEvent.click(logTab)
expect(logTab.getAttribute('aria-selected')).toBe('true')
expect(screen.queryByTestId('job-detail-logs-panel')).toBeTruthy()
expect(screen.queryByTestId('job-detail-flow-panel')).toBeNull()
})
})

View File

@ -2,24 +2,24 @@
* JobDetail per-Job detail surface served at
* `/sovereign/provision/$deploymentId/jobs/$jobId`.
*
* Founder requirements (epic #204):
* Item 2: jobs are granular; clicking a row opens this detail page.
* Item 3: Execution log viewer styled like GitLab CI runner the
* `<ExecutionLogs />` component owns that surface.
* Item 5: tabs are Execution Logs / Dependencies / Apps. NOT
* accordions, NOT a flat scroll. (Item 1 explicitly forbids
* accordions everywhere in the wizard.)
* v3 (this PR) the founder consolidated the tab set:
* Tab 1 (default): "Flow" embedded FlowPage canvas scoped to the
* parent batch with this job pre-highlighted (thicker border + glow).
* Tab 2: "Exec Log" the existing GitLab-CI-runner-style log
* viewer (epic #204 item 3).
*
* Tab parity with the AppDetail surface is intentional: the visual
* vocabulary (header chip, status pill, tablist) mirrors the rest of
* the Sovereign-provision portal so an operator sees the same chrome
* regardless of whether they're inspecting a job or an application.
* Dropped from v2 (PR #208 + #242 era):
* Dependencies tab replaced by the Flow tab (the canvas IS the
* dependency view, scoped + highlighted).
* Apps tab collapsed into the header chip + the Flow canvas's
* per-bubble appId display.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every label,
* dep id, and app id comes from the derived job set + application
* catalog. The mock-fallback path (used while the backend lands) reads
* from `deriveJobs()` so a JobDetail URL never 404s when the catalyst-
* api hasn't surfaced its own /jobs endpoint yet.
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall) full target shape ships in this PR. The previous
* 3-tab shape is gone, not feature-flagged.
* #2 (no compromise) Mantine-style tablist (proper Tabs.List /
* Tabs.Tab / Tabs.Panel pattern), NOT accordions (founder spec).
* #4 (never hardcode) every label / id / route key is derived.
*/
import { useMemo, useState } from 'react'
@ -31,15 +31,13 @@ import { useDeploymentEvents } from './useDeploymentEvents'
import { deriveJobs, fmtTime, statusBadge } from './jobs'
import type { Job } from './jobs'
import { ExecutionLogs } from '@/components/ExecutionLogs'
import { JobDependencies } from '@/components/JobDependencies'
import { JobApps } from '@/components/JobApps'
import { FlowPage } from './FlowPage'
type TabKey = 'logs' | 'dependencies' | 'apps'
type TabKey = 'flow' | 'logs'
const TABS: { key: TabKey; label: string }[] = [
{ key: 'logs', label: 'Execution Logs' },
{ key: 'dependencies', label: 'Dependencies' },
{ key: 'apps', label: 'Apps' },
const TABS: { key: TabKey; label: string; testid: string }[] = [
{ key: 'flow', label: 'Flow', testid: 'job-detail-tab-flow' },
{ key: 'logs', label: 'Exec Log', testid: 'job-detail-tab-logs' },
]
interface JobDetailProps {
@ -49,7 +47,7 @@ interface JobDetailProps {
initialTab?: TabKey
}
export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDetailProps = {}) {
export function JobDetail({ disableStream = false, initialTab = 'flow' }: JobDetailProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/jobs/$jobId' as never,
}) as { deploymentId: string; jobId: string }
@ -70,9 +68,7 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
// Derive the full job set + index by id. The current data model attaches
// exactly one app to each Job; the JobApps component accepts an array so
// the surface is forward-compatible.
// Derive the full job set + index by id.
const jobs = useMemo(() => deriveJobs(state, applications), [state, applications])
const jobsById = useMemo<Record<string, Job>>(() => {
const out: Record<string, Job> = {}
@ -81,24 +77,6 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
}, [jobs])
const job = jobsById[jobId]
const appsById = useMemo(() => {
const out: Record<string, typeof applications[number]> = {}
for (const a of applications) out[a.id] = a
return out
}, [applications])
// Mock dependency lookup — until the backend lands, infer dependencies
// from job ordering: tofu phases depend on the previous tofu phase,
// cluster-bootstrap depends on the last tofu phase, per-component jobs
// depend on cluster-bootstrap. The list is stable so the UI is
// deterministic.
const dependsOn = useMemo<string[]>(() => {
if (!job) return []
const i = jobs.findIndex((j) => j.id === jobId)
if (i <= 0) return []
return [jobs[i - 1]!.id]
}, [job, jobId, jobs])
const [tab, setTab] = useState<TabKey>(initialTab)
// Derive a synthetic execution id for the log viewer. Until the
@ -134,6 +112,12 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
const completedN = job.steps.filter((s) => s.status === 'succeeded').length
const total = job.steps.length
// Resolve the parent batch id for the embedded FlowPage. The
// `batchId` field is added by the eventReducer/jobsAdapter pipeline;
// fall back to the legacy phase-derived id when missing so the Flow
// tab never fails to mount.
const batchId = (job as unknown as { batchId?: string }).batchId ?? deriveBatchIdFromJob(job)
return (
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
<div className="mx-auto max-w-5xl" data-testid={`job-detail-${jobId}`}>
@ -147,7 +131,10 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
</Link>
{/* Header */}
<header className="mt-4 flex items-start justify-between gap-4 border-b border-[var(--color-border)] pb-4" data-testid="job-detail-header">
<header
className="mt-4 flex items-start justify-between gap-4 border-b border-[var(--color-border)] pb-4"
data-testid="job-detail-header"
>
<div className="min-w-0 flex-1">
<h1
className="truncate text-2xl font-bold text-[var(--color-text-strong)]"
@ -174,7 +161,9 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
</span>
</header>
{/* Tablist — proper Tabs, NOT accordions (item 1 forbids accordions). */}
{/* Tablist proper Tabs (Mantine-style), NOT accordions. The
tab strip exposes EXACTLY two tabs (Flow + Exec Log) per
the v3 founder spec; Dependencies and Apps were retired. */}
<div
role="tablist"
aria-label="Job detail sections"
@ -188,9 +177,9 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
role="tab"
aria-selected={tab === t.key}
aria-controls={`job-detail-panel-${t.key}`}
id={`job-detail-tab-${t.key}`}
id={t.testid}
onClick={() => setTab(t.key)}
data-testid={`job-detail-tab-${t.key}`}
data-testid={t.testid}
className={`relative -mb-px px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? 'border-b-2 border-[var(--color-accent)] text-[var(--color-text-strong)]'
@ -204,6 +193,23 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
{/* Panels */}
<div className="py-6" data-testid={`job-detail-panel-${tab}`}>
{tab === 'flow' && (
<div
role="tabpanel"
id="job-detail-panel-flow"
aria-labelledby="job-detail-tab-flow"
data-testid="job-detail-flow-panel"
>
<FlowPage
disableStream={disableStream}
disableJobsBackfill={disableStream}
embedded
deploymentIdOverride={deploymentId}
scopeOverride={{ kind: 'batch', batchId }}
highlightJobId={job.id}
/>
</div>
)}
{tab === 'logs' && (
<div
role="tabpanel"
@ -214,36 +220,21 @@ export function JobDetail({ disableStream = false, initialTab = 'logs' }: JobDet
<ExecutionLogs executionId={executionId} />
</div>
)}
{tab === 'dependencies' && (
<div
role="tabpanel"
id="job-detail-panel-dependencies"
aria-labelledby="job-detail-tab-dependencies"
data-testid="job-detail-deps-panel"
>
<JobDependencies
job={{ id: job.id, dependsOn }}
jobsById={jobsById}
deploymentId={deploymentId}
/>
</div>
)}
{tab === 'apps' && (
<div
role="tabpanel"
id="job-detail-panel-apps"
aria-labelledby="job-detail-tab-apps"
data-testid="job-detail-apps-panel"
>
<JobApps
appIds={[job.app]}
appsById={appsById}
deploymentId={deploymentId}
/>
</div>
)}
</div>
</div>
</PortalShell>
)
}
/**
* Fallback batch derivation mirrors the jobsAdapter `batchOf()`
* mapping: infrastructure phase-0-infra, cluster-bootstrap its
* own batch, everything else 'applications'. Keeps the Flow tab
* from failing to mount when the Job model only surfaces the legacy
* `app` classification.
*/
function deriveBatchIdFromJob(job: Job): string {
if (job.id.startsWith('infrastructure:')) return 'phase-0-infra'
if (job.id === 'cluster-bootstrap') return 'cluster-bootstrap'
return 'applications'
}

View File

@ -1,228 +0,0 @@
/**
* JobsFlowView.test.tsx component-level tests for the Flow tab on
* the Jobs page (urgent founder spec).
*
* Coverage:
* Renders without error on empty data.
* Renders 4 stages for the canonical 5-job fan-in example.
* Click batch header toggles collapsed state (job nodes
* disappear, supernode glyph appears).
* Click job card calls navigate with the correct
* /provision/$id/jobs/$jobId target.
* Default-collapse policy: all-succeeded batches collapsed by
* default; in-flight batches expanded.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { JobsFlowView } from './JobsFlowView'
import type { Job } from '@/lib/jobs.types'
function renderFlow(jobs: Job[], deploymentId = 'd-1') {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const flowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs',
component: () => <JobsFlowView jobs={jobs} deploymentId={deploymentId} />,
})
const detailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs/$jobId',
component: () => <div data-testid="job-detail-target" />,
})
const tree = rootRoute.addChildren([flowRoute, detailRoute])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({ initialEntries: [`/provision/${deploymentId}/jobs`] }),
})
return { ...render(<RouterProvider router={router} />), router }
}
const mkJob = (id: string, deps: string[], batchId: string, status: Job['status'] = 'pending'): Job => ({
id,
jobName: id,
appId: id,
batchId,
dependsOn: deps,
status,
startedAt: null,
finishedAt: null,
durationMs: 0,
})
const FIVE_JOB: Job[] = [
mkJob('1', [], 'B'),
mkJob('2', ['1'], 'B'),
mkJob('3', ['1'], 'B'),
mkJob('4', ['3'], 'B'),
mkJob('5', ['2', '4'], 'B'),
]
beforeEach(() => {
// Quiet unhandled console error from jsdom not implementing
// foreignObject layout — the component renders fine, the SVG just
// can't compute child boxes in jsdom.
})
afterEach(() => cleanup())
describe('JobsFlowView — empty state', () => {
it('renders an empty placeholder when jobs is empty', async () => {
renderFlow([])
expect(await screen.findByTestId('jobs-flow-empty')).toBeTruthy()
})
it('does NOT render the SVG canvas when there is no data', async () => {
renderFlow([])
await screen.findByTestId('jobs-flow-empty')
expect(screen.queryByTestId('jobs-flow-svg')).toBeNull()
})
})
describe('JobsFlowView — canonical 5-job fan-in', () => {
it('renders the SVG canvas', async () => {
renderFlow(FIVE_JOB)
expect(await screen.findByTestId('jobs-flow-svg')).toBeTruthy()
})
it('renders one batch swimlane', async () => {
renderFlow(FIVE_JOB)
expect(await screen.findByTestId('flow-batch-B')).toBeTruthy()
})
it('renders 5 job cards', async () => {
renderFlow(FIVE_JOB)
await screen.findByTestId('jobs-flow-svg')
for (const id of ['1', '2', '3', '4', '5']) {
expect(screen.getByTestId(`flow-job-${id}`)).toBeTruthy()
}
})
it('renders 5 within-batch edges', async () => {
renderFlow(FIVE_JOB)
await screen.findByTestId('jobs-flow-svg')
const edges = document.querySelectorAll('[data-testid^="flow-edge-"]')
expect(edges.length).toBe(5)
})
it('jobs are positioned across 4 distinct stages', async () => {
renderFlow(FIVE_JOB)
await screen.findByTestId('jobs-flow-svg')
const stages = new Set<string>()
for (const id of ['1', '2', '3', '4', '5']) {
// The job node's <rect> sits at x = stage * COLUMN_WIDTH +
// batch padding; we read the x attribute to derive the stage.
const node = document.querySelector(`[data-testid="flow-job-${id}"] rect`)
const x = node?.getAttribute('x') ?? ''
stages.add(x)
}
expect(stages.size).toBe(4)
})
})
describe('JobsFlowView — batch collapse toggle', () => {
it('default-collapses an all-succeeded batch and expands an in-flight one', async () => {
const jobs: Job[] = [
// all succeeded — should collapse
mkJob('a1', [], 'A', 'succeeded'),
mkJob('a2', ['a1'], 'A', 'succeeded'),
// in flight — should stay expanded
mkJob('b1', ['a2'], 'B', 'running'),
mkJob('b2', ['b1'], 'B', 'pending'),
]
renderFlow(jobs)
await screen.findByTestId('jobs-flow-svg')
// A's job nodes are NOT rendered (collapsed → supernode only).
expect(screen.queryByTestId('flow-job-a1')).toBeNull()
expect(screen.queryByTestId('flow-job-a2')).toBeNull()
expect(screen.getByTestId('flow-batch-supernode-A')).toBeTruthy()
// B's job nodes ARE rendered.
expect(screen.getByTestId('flow-job-b1')).toBeTruthy()
expect(screen.getByTestId('flow-job-b2')).toBeTruthy()
})
it('clicking the batch toggle flips collapsed state in place', async () => {
const jobs: Job[] = [
mkJob('b1', [], 'B', 'running'),
mkJob('b2', ['b1'], 'B', 'pending'),
]
renderFlow(jobs)
await screen.findByTestId('jobs-flow-svg')
// B starts expanded — both job cards visible.
expect(screen.getByTestId('flow-job-b1')).toBeTruthy()
expect(screen.getByTestId('flow-job-b2')).toBeTruthy()
// Click toggle.
fireEvent.click(screen.getByTestId('flow-batch-toggle-B'))
// Now collapsed.
expect(screen.queryByTestId('flow-job-b1')).toBeNull()
expect(screen.queryByTestId('flow-job-b2')).toBeNull()
expect(screen.getByTestId('flow-batch-supernode-B')).toBeTruthy()
// Click again — back to expanded.
fireEvent.click(screen.getByTestId('flow-batch-toggle-B'))
expect(screen.getByTestId('flow-job-b1')).toBeTruthy()
expect(screen.queryByTestId('flow-batch-supernode-B')).toBeNull()
})
})
describe('JobsFlowView — click navigation', () => {
it('clicking a job card navigates to /provision/$id/jobs/$jobId', async () => {
const { router } = renderFlow(FIVE_JOB, 'd-42')
await screen.findByTestId('jobs-flow-svg')
fireEvent.click(screen.getByTestId('flow-job-3'))
// After click, the router pathname should match the JobDetail route.
expect(router.state.location.pathname).toBe('/provision/d-42/jobs/3')
})
})
describe('JobsFlowView — edge classification', () => {
it('cross-batch edge is rendered when both batches are expanded', async () => {
const jobs: Job[] = [
mkJob('a', [], 'A', 'running'),
mkJob('b', ['a'], 'B', 'pending'),
]
renderFlow(jobs)
await screen.findByTestId('jobs-flow-svg')
const edges = document.querySelectorAll('[data-testid^="flow-edge-"]')
expect(edges.length).toBeGreaterThan(0)
const cross = Array.from(edges).filter(
(e) => e.getAttribute('data-kind') === 'cross-batch-job',
)
expect(cross.length).toBe(1)
})
it('meta edge is rendered when source batch is collapsed', async () => {
const jobs: Job[] = [
mkJob('a', [], 'A', 'succeeded'),
mkJob('b', ['a'], 'B', 'pending'),
]
renderFlow(jobs)
await screen.findByTestId('jobs-flow-svg')
const edges = document.querySelectorAll('[data-testid^="flow-edge-"]')
const meta = Array.from(edges).filter(
(e) => e.getAttribute('data-kind') === 'meta',
)
expect(meta.length).toBe(1)
})
it('meta edge from a failed batch carries data-blocked=true', async () => {
const jobs: Job[] = [
mkJob('a', [], 'A', 'failed'),
mkJob('b', ['a'], 'B', 'pending'),
]
renderFlow(jobs)
await screen.findByTestId('jobs-flow-svg')
const edges = document.querySelectorAll('[data-testid^="flow-edge-"]')
const blocked = Array.from(edges).filter(
(e) => e.getAttribute('data-blocked') === 'true',
)
expect(blocked.length).toBeGreaterThan(0)
})
})

View File

@ -1,617 +0,0 @@
/**
* JobsFlowView Flow tab on the Jobs page (founder-locked spec).
*
* Renders a two-level Sugiyama layered DAG:
* Outer: batches as meta-stages, left right.
* Inner: jobs within each batch, left right.
*
* Layout is delegated to `lib/pipelineLayout.ts` (pure function); this
* component only owns the SVG rendering + click/collapse interactions.
*
* Visual contract (per founder spec):
* Batch swimlane: card with name header, mini progress bar, count,
* collapse toggle, status-tinted background.
* Job card: name, status badge (pulsing dot if running), duration,
* appId chip. Click /provision/$id/jobs/$jobId.
* Within-batch edge: thin gray straight (span 1) or smooth bezier
* (span 2).
* Cross-batch edge: bold colored arrow at swimlane boundary;
* dashed red when source batch has failures.
* Collapsed batch: shrinks to a single supernode with progress bar.
* Default zoom: in-flight batches expanded; all-succeeded collapsed.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall) full target shape ships in this component.
* #2 (no compromise) no graph library, pure SVG + computed bezier.
* #4 (never hardcode) every dimension lives in `pipelineLayout.ts`
* DEFAULT_GEOMETRY; this component only references the result.
*/
import { useMemo, useState, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import type { Job } from '@/lib/jobs.types'
import {
pipelineLayout,
defaultCollapsedBatchIds,
edgeToPath,
type FlowBatchLane,
type FlowEdge,
type FlowNode,
} from '@/lib/pipelineLayout'
/*
* Component
* */
interface JobsFlowViewProps {
/** Job list. Backend populates; UI sorts/filters in place. */
jobs: readonly Job[]
/** Stable deployment id — embedded in per-job link target. */
deploymentId: string
}
export function JobsFlowView({ jobs, deploymentId }: JobsFlowViewProps) {
// Default collapse policy is computed once per `jobs` reference; the
// user override is a Set keyed by batchId. Each toggle replaces the
// override with a fresh Set so React re-renders.
const initialCollapsed = useMemo(() => defaultCollapsedBatchIds(jobs), [jobs])
const [overrideSet, setOverrideSet] = useState<Set<string>>(new Set())
const [overrideMode, setOverrideMode] = useState<'replace' | 'merge'>('merge')
const collapsedBatchIds = useMemo<Set<string>>(() => {
if (overrideMode === 'replace') return overrideSet
// merge mode: start from defaults, then toggle entries the user
// explicitly flipped.
const out = new Set(initialCollapsed)
for (const id of overrideSet) {
if (out.has(id)) out.delete(id)
else out.add(id)
}
return out
}, [initialCollapsed, overrideSet, overrideMode])
const layout = useMemo(
() => pipelineLayout(jobs, { collapsedBatchIds }),
[jobs, collapsedBatchIds],
)
const navigate = useNavigate()
const onToggleBatch = useCallback((batchId: string) => {
setOverrideMode('merge')
setOverrideSet((prev) => {
const next = new Set(prev)
if (next.has(batchId)) next.delete(batchId)
else next.add(batchId)
return next
})
}, [])
const onJobClick = useCallback(
(jobId: string) => {
navigate({
to: '/provision/$deploymentId/jobs/$jobId' as never,
params: { deploymentId, jobId } as never,
})
},
[navigate, deploymentId],
)
if (jobs.length === 0) {
return (
<div
data-testid="jobs-flow-empty"
className="rounded-xl border border-dashed border-[var(--color-border)] p-8 text-center text-sm text-[var(--color-text-dim)]"
>
No jobs to render in the dependency graph.
</div>
)
}
return (
<div className="jobs-flow-wrap" data-testid="jobs-flow-wrap">
<style>{JOBS_FLOW_CSS}</style>
<div className="jobs-flow-scroll">
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
className="jobs-flow-svg"
data-testid="jobs-flow-svg"
role="img"
aria-label="Job dependency flow"
>
<defs>
<marker
id="flow-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 Z" fill="var(--color-text-dim)" />
</marker>
<marker
id="flow-arrow-meta"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 Z" fill="#38BDF8" />
</marker>
<marker
id="flow-arrow-blocked"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 Z" fill="#F87171" />
</marker>
</defs>
{/* 1. Batch swimlanes (drawn first so jobs sit on top) */}
{layout.batches.map((b) => (
<BatchSwimlane
key={b.batchId}
lane={b}
onToggle={() => onToggleBatch(b.batchId)}
/>
))}
{/* 2. Edges */}
{layout.edges.map((e, i) => (
<FlowEdgePath key={`${e.fromId}-${e.toId}-${i}`} edge={e} />
))}
{/* 3. Job nodes (cards) */}
{layout.nodes.map((n) =>
n.kind === 'job' ? (
<JobCardNode key={n.id} node={n} onClick={() => onJobClick(n.id)} />
) : null,
)}
</svg>
</div>
</div>
)
}
/*
* Sub-components
* */
interface BatchSwimlaneProps {
lane: FlowBatchLane
onToggle: () => void
}
function BatchSwimlane({ lane, onToggle }: BatchSwimlaneProps) {
const tone = LANE_TONE[lane.status]
const HEADER_HEIGHT = 36
const progressPct =
lane.total === 0 ? 0 : Math.round((lane.finished / lane.total) * 100)
return (
<g data-testid={`flow-batch-${lane.batchId}`} data-collapsed={lane.collapsed}>
<rect
x={lane.x}
y={lane.y}
width={lane.width}
height={lane.height}
rx={12}
ry={12}
fill={tone.bg}
stroke={tone.border}
strokeWidth={1.5}
/>
{/* Header strip */}
<rect
x={lane.x}
y={lane.y}
width={lane.width}
height={HEADER_HEIGHT}
rx={12}
ry={12}
fill={tone.headerBg}
stroke="none"
/>
<foreignObject
x={lane.x + 8}
y={lane.y + 4}
width={lane.width - 16}
height={HEADER_HEIGHT - 8}
>
<div className="flow-batch-header">
<button
type="button"
className="flow-batch-toggle"
data-testid={`flow-batch-toggle-${lane.batchId}`}
onClick={onToggle}
aria-label={lane.collapsed ? 'Expand batch' : 'Collapse batch'}
>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden>
{lane.collapsed ? (
<path d="M3 2 L9 6 L3 10 Z" fill="currentColor" />
) : (
<path d="M2 4 L10 4 L6 10 Z" fill="currentColor" />
)}
</svg>
</button>
<span
className="flow-batch-name"
data-testid={`flow-batch-name-${lane.batchId}`}
title={lane.batchId}
>
{lane.batchId}
</span>
<span className="flow-batch-count" data-testid={`flow-batch-count-${lane.batchId}`}>
{lane.finished}/{lane.total}
</span>
</div>
</foreignObject>
{/* Mini progress bar */}
<rect
x={lane.x + 8}
y={lane.y + HEADER_HEIGHT - 4}
width={lane.width - 16}
height={3}
rx={1.5}
ry={1.5}
fill="rgba(148,163,184,0.20)"
/>
<rect
x={lane.x + 8}
y={lane.y + HEADER_HEIGHT - 4}
width={Math.max(0, ((lane.width - 16) * progressPct) / 100)}
height={3}
rx={1.5}
ry={1.5}
fill={tone.progress}
data-testid={`flow-batch-progress-${lane.batchId}`}
/>
{/* Collapsed body — render the supernode glyph in the middle */}
{lane.collapsed ? (
<foreignObject
x={lane.x + 8}
y={lane.y + HEADER_HEIGHT + 4}
width={lane.width - 16}
height={lane.height - HEADER_HEIGHT - 8}
>
<div
className="flow-batch-collapsed"
data-testid={`flow-batch-supernode-${lane.batchId}`}
>
<span className="flow-batch-collapsed-pct">{progressPct}%</span>
<span className="flow-batch-collapsed-meta">
{lane.total} {lane.total === 1 ? 'job' : 'jobs'}
</span>
</div>
</foreignObject>
) : null}
</g>
)
}
interface JobCardNodeProps {
node: FlowNode
onClick: () => void
}
function JobCardNode({ node, onClick }: JobCardNodeProps) {
if (!node.job) return null
const j = node.job
const tone = JOB_STATUS_TONE[j.status]
return (
<g
data-testid={`flow-job-${j.id}`}
data-status={j.status}
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={10}
ry={10}
fill={tone.bg}
stroke={tone.border}
strokeWidth={1.25}
className="flow-job-rect"
/>
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className="flow-job-card">
<div className="flow-job-row">
<span className="flow-job-name" title={j.jobName}>
{j.jobName}
</span>
<span
className={`flow-job-badge flow-job-badge-${j.status}`}
data-testid={`flow-job-badge-${j.id}`}
>
{j.status === 'running' ? <span className="flow-job-pulse" aria-hidden /> : null}
{tone.label}
</span>
</div>
<div className="flow-job-meta">
<span className="flow-job-app" title={j.appId}>
{j.appId}
</span>
<span className="flow-job-duration">{formatDuration(j.durationMs)}</span>
</div>
</div>
</foreignObject>
</g>
)
}
interface FlowEdgePathProps {
edge: FlowEdge
}
function FlowEdgePath({ edge }: FlowEdgePathProps) {
const d = edgeToPath(edge.points)
if (!d) return null
let stroke = 'var(--color-text-dim)'
let strokeWidth = 1.25
let dash: string | undefined
let marker = 'url(#flow-arrow)'
let opacity = 0.6
if (edge.kind === 'meta' || edge.kind === 'cross-batch-job') {
stroke = '#38BDF8'
strokeWidth = 1.75
marker = 'url(#flow-arrow-meta)'
opacity = 0.85
}
if (edge.blocked) {
stroke = '#F87171'
dash = '4 4'
marker = 'url(#flow-arrow-blocked)'
opacity = 0.95
}
return (
<path
d={d}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={dash}
opacity={opacity}
markerEnd={marker}
data-testid={`flow-edge-${edge.fromId}-${edge.toId}`}
data-kind={edge.kind}
data-blocked={edge.blocked ? 'true' : 'false'}
>
{edge.tooltip ? <title>{edge.tooltip}</title> : null}
</path>
)
}
/*
* Helpers + tone tables
* */
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
interface LaneTone {
bg: string
border: string
headerBg: string
progress: string
}
const LANE_TONE: Record<FlowBatchLane['status'], LaneTone> = {
succeeded: {
bg: 'rgba(74,222,128,0.06)',
border: 'rgba(74,222,128,0.45)',
headerBg: 'rgba(74,222,128,0.16)',
progress: '#4ADE80',
},
running: {
bg: 'rgba(56,189,248,0.06)',
border: 'rgba(56,189,248,0.45)',
headerBg: 'rgba(56,189,248,0.18)',
progress: '#38BDF8',
},
failed: {
bg: 'rgba(248,113,113,0.06)',
border: 'rgba(248,113,113,0.55)',
headerBg: 'rgba(248,113,113,0.18)',
progress: '#F87171',
},
pending: {
bg: 'rgba(148,163,184,0.04)',
border: 'rgba(148,163,184,0.35)',
headerBg: 'rgba(148,163,184,0.14)',
progress: '#94A3B8',
},
mixed: {
bg: 'rgba(245,158,11,0.06)',
border: 'rgba(245,158,11,0.45)',
headerBg: 'rgba(245,158,11,0.16)',
progress: '#F59E0B',
},
}
const JOB_STATUS_TONE: Record<
Job['status'],
{ bg: string; border: string; label: string }
> = {
pending: { bg: 'rgba(15,23,42,0.55)', border: 'rgba(148,163,184,0.30)', label: 'Pending' },
running: { bg: 'rgba(15,23,42,0.55)', border: 'rgba(56,189,248,0.55)', label: 'Running' },
succeeded: { bg: 'rgba(15,23,42,0.55)', border: 'rgba(74,222,128,0.55)', label: 'Succeeded' },
failed: { bg: 'rgba(15,23,42,0.55)', border: 'rgba(248,113,113,0.55)', label: 'Failed' },
}
/*
* Styles co-located with the component so we don't grow another
* CSS module. Mirrors JobsTable's strategy.
* */
const JOBS_FLOW_CSS = `
.jobs-flow-wrap { width: 100%; }
.jobs-flow-scroll {
width: 100%;
overflow-x: auto;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
max-height: 78vh;
}
.jobs-flow-svg {
display: block;
font-family: var(--font-sans, system-ui);
}
.flow-batch-header {
display: flex;
align-items: center;
gap: 0.45rem;
height: 100%;
color: var(--color-text);
font-size: 0.78rem;
font-weight: 600;
}
.flow-batch-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-dim);
cursor: pointer;
border-radius: 4px;
}
.flow-batch-toggle:hover { color: var(--color-text); background: rgba(148,163,184,0.10); }
.flow-batch-name {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono, ui-monospace, monospace);
letter-spacing: 0.02em;
}
.flow-batch-count {
font-size: 0.7rem;
color: var(--color-text-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.flow-batch-collapsed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.15rem;
}
.flow-batch-collapsed-pct {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text-strong);
font-variant-numeric: tabular-nums;
}
.flow-batch-collapsed-meta {
font-size: 0.7rem;
color: var(--color-text-dim);
font-variant-numeric: tabular-nums;
}
.flow-job-card {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
width: 100%;
padding: 0.45rem 0.55rem;
box-sizing: border-box;
pointer-events: none;
}
.flow-job-row {
display: flex;
align-items: center;
gap: 0.4rem;
justify-content: space-between;
}
.flow-job-name {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-strong);
}
.flow-job-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
font-size: 0.66rem;
color: var(--color-text-dim);
font-family: var(--font-mono, ui-monospace, monospace);
}
.flow-job-app {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #38BDF8;
}
.flow-job-duration {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.flow-job-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.08rem 0.4rem;
border-radius: 999px;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.flow-job-badge-pending { background: rgba(148,163,184,0.10); color: var(--color-text-dim); }
.flow-job-badge-running { background: rgba(56,189,248,0.10); color: #38BDF8; }
.flow-job-badge-succeeded { background: rgba(74,222,128,0.10); color: #4ADE80; }
.flow-job-badge-failed { background: rgba(248,113,113,0.10); color: #F87171; }
.flow-job-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: flow-pulse 1.6s ease-in-out infinite;
}
@keyframes flow-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.6)} }
.flow-job-rect:hover { filter: brightness(1.08); }
`

View File

@ -50,10 +50,10 @@ function renderJobs(deploymentId: string) {
path: '/provision/$deploymentId/jobs/$jobId',
component: () => <div data-testid="job-detail-target" />,
})
const batchDetailRoute = createRoute({
const flowRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/batches/$batchId',
component: () => <div data-testid="batch-detail-target" />,
path: '/provision/$deploymentId/flow',
component: () => <div data-testid="flow-target" />,
})
const wizardRoute = createRoute({
getParentRoute: () => rootRoute,
@ -65,7 +65,7 @@ function renderJobs(deploymentId: string) {
homeRoute,
detailRoute,
jobDetailRoute,
batchDetailRoute,
flowRoute,
wizardRoute,
])
const router = createRouter({
@ -185,12 +185,35 @@ describe('JobsPage — batches strip removed (epic #204 item #4)', () => {
expect(batchRows.length).toBe(0)
})
it('batch chip in a row links to the BatchDetail page', async () => {
it('batch chip in a row links to /flow?scope=batch:<id> (v3 routing)', async () => {
renderJobs('d-1')
await screen.findByTestId('jobs-table')
const chip = screen.getByTestId('jobs-cell-batch-bp-cilium') as HTMLAnchorElement
expect(chip.tagName.toLowerCase()).toBe('a')
// Spot-check the route shape — the batch id varies by job.
expect(chip.getAttribute('href')).toMatch(/^\/provision\/d-1\/batches\//)
// v3 founder spec: batch chip → /flow?scope=batch:<batchId>.
const href = chip.getAttribute('href') ?? ''
expect(href).toMatch(/^\/provision\/d-1\/flow/)
expect(href).toMatch(/scope=batch%3A|scope=batch:/)
})
})
describe('JobsPage — v3 routing (no Tab strip, has Show-as-Flow button)', () => {
it('does NOT render a jobs-view-tabs strip', async () => {
// PR #242 added a `?view=table|flow` Tab strip. The founder
// rejected that pattern; the Flow surface now lives at /flow.
renderJobs('d-1')
await screen.findByTestId('jobs-table')
expect(screen.queryByTestId('jobs-view-tabs')).toBeNull()
expect(screen.queryByTestId('jobs-view-tab-table')).toBeNull()
expect(screen.queryByTestId('jobs-view-tab-flow')).toBeNull()
})
it('exposes a "Show as Flow" button that navigates to /flow?scope=all', async () => {
renderJobs('d-1')
const btn = await screen.findByTestId('sov-jobs-show-as-flow') as HTMLAnchorElement
expect(btn.tagName.toLowerCase()).toBe('a')
const href = btn.getAttribute('href') ?? ''
expect(href).toMatch(/^\/provision\/d-1\/flow/)
expect(href).toMatch(/scope=all/)
})
})

View File

@ -1,32 +1,28 @@
/**
* JobsPage table-view replacement for the legacy expand-in-place
* accordion (issue #204). The founder rejected the accordion pattern
* verbatim ("NEVER use accordions anywhere"); every job is now a row in
* <JobsTable />, and the row is a navigation link to JobDetail (owned
* by a sibling agent on the JobDetail+ExecutionLogs scope).
* verbatim ("NEVER use accordions"); every job is now a row in
* <JobsTable />, and the row is a navigation link to JobDetail.
*
* Layout, top-down:
* Header: <h1>Jobs</h1> + tagline + back-to-apps link.
* Header: <h1>Jobs</h1> + tagline + back-to-apps link + a
* "Show as Flow" button that navigates to /flow?scope=all.
* <JobsTable /> table view with search/sort/filter (items #2,
* #6, #7, #8a). Each batch chip is a link to the BatchDetail page
* (item #4: progress bar moves to per-batch detail view).
* #6, #7, #8a). Each batch chip is now a Link to /flow?scope=batch:
* <id> (per the v3 routing model was previously a Link to the
* BatchDetail page).
*
* History note (PR #242 was rejected):
* The previous PR added a `?view=table|flow` Tab strip on this page.
* The founder rejected it; the Flow surface now lives at its own
* /flow route. The tab strip / setView / resolveJobsView helpers
* have been removed in this commit.
*
* Per founder feedback for epic #204 item #4 (verbatim):
* "On the jobs page the top 3 cards are not required, the progress
* bar needs to be shown only when I click a specific batch and it
* shows the batch page along with its batch progress at the top"
*
* Consequently the top BatchProgress strip is intentionally OMITTED on
* this surface. Per-batch progress lives at /batches/$batchId only.
*
* Data flow:
* 1. Live SSE events (via useDeploymentEvents) populate the legacy
* reducer state (eventReducer.ts) which deriveJobs() folds into
* the per-row JobsTable inputs through `jobsAdapter.ts`.
* 2. Until the catalyst-api jobs endpoint (#205) ships, the live
* stream IS the source of truth every Job listed in
* `deriveJobs()` is mapped 1:1 into the new flat shape.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall first paint is the
* full list), every Job is rendered from mount, even pending ones with
* no events yet. Per #4 (never hardcode), the job set is computed by
@ -35,44 +31,26 @@
*/
import { useMemo } from 'react'
import { useParams, Link, useSearch, useNavigate } from '@tanstack/react-router'
import { useParams, Link } from '@tanstack/react-router'
import { useWizardStore } from '@/entities/deployment/store'
import { PortalShell } from './PortalShell'
import { JobsTable } from './JobsTable'
import { JobsFlowView } from './JobsFlowView'
import { resolveApplications } from './applicationCatalog'
import { useDeploymentEvents } from './useDeploymentEvents'
import { deriveJobs } from './jobs'
import { adaptDerivedJobsToFlat } from './jobsAdapter'
import { useLiveJobsBackfill, mergeJobs } from './useLiveJobsBackfill'
/** Canonical tabs for the JobsPage view-mode. */
export type JobsViewKey = 'table' | 'flow'
export const JOBS_VIEW_TABS: ReadonlyArray<{ key: JobsViewKey; label: string }> = [
{ key: 'table', label: 'Table' },
{ key: 'flow', label: 'Flow' },
]
/** Resolve a free-form `?view=...` URL value to a known JobsViewKey. */
export function resolveJobsView(raw: unknown): JobsViewKey {
if (raw === 'flow') return 'flow'
return 'table'
}
interface JobsPageProps {
/** Test seam — disables the live SSE EventSource attach. */
disableStream?: boolean
/** Test seam — disables the live-jobs backfill polling. */
disableJobsBackfill?: boolean
/** Test seam — force the active view (bypasses ?view= URL parsing). */
initialView?: JobsViewKey
}
export function JobsPage({
disableStream = false,
disableJobsBackfill = false,
initialView,
}: JobsPageProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/jobs' as never,
@ -80,24 +58,6 @@ export function JobsPage({
const { deploymentId } = params
const store = useWizardStore()
// ?view=table|flow drives the active tab. We read the search params
// tolerantly — the Jobs route doesn't declare validateSearch (kept
// backward-compatible with existing deep links), so the value is
// typed as `unknown` and resolveJobsView() coerces it. The initial
// view prop overrides the URL for unit tests / Storybook embeds.
const search = useSearch({ strict: false }) as { view?: unknown }
const activeView: JobsViewKey = initialView ?? resolveJobsView(search?.view)
const navigate = useNavigate()
const setView = (next: JobsViewKey) => {
navigate({
to: '/provision/$deploymentId/jobs' as never,
params: { deploymentId } as never,
// `table` is the implicit default — keep the URL clean by
// dropping the param when the user picks the default.
search: (next === 'table' ? {} : { view: next }) as never,
})
}
const applications = useMemo(
() => resolveApplications(store.selectedComponents),
[store.selectedComponents],
@ -119,8 +79,7 @@ export function JobsPage({
// that's already Ready=True at watch-attach time emits no SSE event;
// the backend's Jobs API gives us the current ground-truth list and
// the merge below ensures live data wins on conflict. Polling stops
// automatically when the deployment reaches a terminal state — by
// then `componentStates` on the snapshot already seeded every card.
// automatically when the deployment reaches a terminal state.
const inFlight = streamStatus !== 'completed' && streamStatus !== 'failed'
const { liveJobs } = useLiveJobsBackfill({
deploymentId,
@ -133,9 +92,6 @@ export function JobsPage({
[reducerJobs, liveJobs],
)
// Surface a small banner the first time live data arrives — operators
// viewing a stalled-looking page need to know the table is being
// refreshed from the backend, not pulled from the local SSE replay.
const liveBackfillActive = liveJobs.length > 0
return (
@ -148,14 +104,33 @@ export function JobsPage({
{sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}
</p>
</div>
<Link
to="/provision/$deploymentId"
params={{ deploymentId }}
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-jobs-back-to-apps"
>
Back to apps
</Link>
<div className="flex items-center gap-3">
<Link
to="/provision/$deploymentId/flow"
params={{ deploymentId }}
search={{ scope: 'all' }}
className="inline-flex items-center gap-2 rounded-lg border border-[var(--color-accent)]/45 bg-[var(--color-accent)]/10 px-3 py-1.5 text-xs font-semibold text-[var(--color-accent)] transition-colors hover:bg-[var(--color-accent)]/20 no-underline"
data-testid="sov-jobs-show-as-flow"
aria-label="Show jobs as flow canvas"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden>
<circle cx="3" cy="7" r="1.6" />
<circle cx="11" cy="3" r="1.6" />
<circle cx="11" cy="11" r="1.6" />
<path d="M4.4 6.2 L9.6 3.6" strokeLinecap="round" />
<path d="M4.4 7.8 L9.6 10.4" strokeLinecap="round" />
</svg>
Show as Flow
</Link>
<Link
to="/provision/$deploymentId"
params={{ deploymentId }}
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-jobs-back-to-apps"
>
Back to apps
</Link>
</div>
</div>
{liveBackfillActive ? (
@ -169,73 +144,9 @@ export function JobsPage({
</div>
) : null}
<nav
className="jobs-view-tabs"
role="tablist"
aria-label="Jobs view"
data-testid="jobs-view-tabs"
>
<style>{JOBS_VIEW_TABS_CSS}</style>
{JOBS_VIEW_TABS.map((tab) => {
const isActive = tab.key === activeView
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
aria-current={isActive ? 'page' : undefined}
className={`jobs-view-tab${isActive ? ' active' : ''}`}
data-testid={`jobs-view-tab-${tab.key}`}
onClick={() => setView(tab.key)}
>
{tab.label}
</button>
)
})}
</nav>
<div className="mt-6" data-testid="sov-jobs-list">
{activeView === 'flow' ? (
<JobsFlowView jobs={flatJobs} deploymentId={deploymentId} />
) : (
<JobsTable jobs={flatJobs} deploymentId={deploymentId} />
)}
<JobsTable jobs={flatJobs} deploymentId={deploymentId} />
</div>
</PortalShell>
)
}
/* Tab strip CSS mirrors the InfrastructurePage `.tabs` rhythm so
* the visual feel is consistent across Sovereign-portal surfaces. */
const JOBS_VIEW_TABS_CSS = `
.jobs-view-tabs {
display: inline-flex;
gap: 0;
margin-top: 0.85rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
width: 100%;
}
.jobs-view-tab {
appearance: none;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-dim);
cursor: pointer;
padding: 0.55rem 1rem;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
transition: color 0.12s ease, border-color 0.12s ease;
margin-bottom: -1px;
}
.jobs-view-tab:hover {
color: var(--color-text);
}
.jobs-view-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
`

View File

@ -356,9 +356,14 @@ function JobRow({ job, deploymentId }: JobRowProps) {
)}
</td>
<td className="jobs-cell jobs-cell-batch">
{/* Batch chip /flow?scope=batch:<id>. The previous shape
(BatchDetail page) was retired in the v3 routing model:
the founder rejected the per-batch progress detail surface
in favour of a per-batch slice of the flow canvas. */}
<Link
to="/provision/$deploymentId/batches/$batchId"
params={{ deploymentId, batchId: job.batchId }}
to="/provision/$deploymentId/flow"
params={{ deploymentId }}
search={{ scope: `batch:${job.batchId}` }}
className="jobs-chip jobs-chip-batch jobs-chip-link"
data-testid={`jobs-cell-batch-${job.id}`}
title={job.batchId}