Compare commits
7 Commits
511374ed35
...
d641d7afd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d641d7afd2 | ||
|
|
30aef57774 | ||
|
|
f284f3ffb1 | ||
|
|
07d27b33d4 | ||
|
|
9241864604 | ||
|
|
63a289e3ce | ||
|
|
a41a240626 |
@ -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,
|
||||
const tablist = page.locator('[data-testid="job-detail-tablist"]')
|
||||
await expect(tablist).toBeVisible({ timeout: 10_000 })
|
||||
const tabs = tablist.locator('[role="tab"]')
|
||||
expect(
|
||||
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)
|
||||
|
||||
const labels = (await tabs.allTextContents()).map((s) => s.trim())
|
||||
expect(labels).toEqual(['Flow', 'Exec Log'])
|
||||
|
||||
// Dependencies + Apps tabs are gone.
|
||||
expect(
|
||||
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)
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
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)
|
||||
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')
|
||||
|
||||
// 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()
|
||||
expect(
|
||||
nJobs,
|
||||
'Expanded batch did not render any [data-testid^=flow-job-] cards. Check JobsFlowView.tsx <JobCardNode /> rendering.',
|
||||
).toBeGreaterThan(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()
|
||||
|
||||
// Collapsed supernode glyph is visible.
|
||||
const supernode = page.locator(`[data-testid="flow-batch-supernode-${batchId}"]`)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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;
|
||||
}
|
||||
`
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
317
products/catalyst/bootstrap/ui/src/components/StatusStrip.tsx
Normal file
317
products/catalyst/bootstrap/ui/src/components/StatusStrip.tsx
Normal 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 Jobs↔Batches 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 Jobs↔Batches 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);
|
||||
}
|
||||
`
|
||||
@ -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
|
||||
* ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@ -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 ''
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
1079
products/catalyst/bootstrap/ui/src/pages/sovereign/FlowPage.tsx
Normal file
1079
products/catalyst/bootstrap/ui/src/pages/sovereign/FlowPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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); }
|
||||
`
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,6 +104,24 @@ export function JobsPage({
|
||||
{sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}
|
||||
</p>
|
||||
</div>
|
||||
<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 }}
|
||||
@ -157,6 +131,7 @@ export function JobsPage({
|
||||
← Back to apps
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveBackfillActive ? (
|
||||
<div
|
||||
@ -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} />
|
||||
)}
|
||||
</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);
|
||||
}
|
||||
`
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user