From 3865b930911ed95ef56af9d32e0b5fb65d1f6453 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 20:46:55 +0200 Subject: [PATCH] Repair live jobs queues action handoffs --- ...ve_jobs_queues_truthful_action_handoffs.md | 78 +++ .../modules/ui/execution-operations/README.md | 3 +- .../scripts/live-jobs-queues-action-sweep.mjs | 287 +++++++++ ...latform-jobs-queues-page.component.spec.ts | 117 ++++ .../platform-jobs-queues-page.component.ts | 574 ++++++++++++++---- .../StellaOps.Web/tsconfig.spec.features.json | 1 + 6 files changed, 934 insertions(+), 126 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_034_FE_live_jobs_queues_truthful_action_handoffs.md create mode 100644 src/Web/StellaOps.Web/scripts/live-jobs-queues-action-sweep.mjs create mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts diff --git a/docs/implplan/SPRINT_20260310_034_FE_live_jobs_queues_truthful_action_handoffs.md b/docs/implplan/SPRINT_20260310_034_FE_live_jobs_queues_truthful_action_handoffs.md new file mode 100644 index 000000000..5262b6595 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_034_FE_live_jobs_queues_truthful_action_handoffs.md @@ -0,0 +1,78 @@ +# Sprint 20260310-034 - Jobs Queues Truthful Action Handoffs + +## Topic & Scope +- Repair the live `Ops > Operations > Jobs & Queues` page so it stops advertising fake row-level controls that loop back to itself. +- Make the overview filters and copy actions behave honestly on the live shell instead of rendering inert UI. +- Keep this iteration limited to the `Jobs & Queues` page family, its focused frontend regression coverage, live Playwright proof, and the supporting docs update. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/implplan/SPRINT_20260310_034_FE_live_jobs_queues_truthful_action_handoffs.md`, `docs/modules/ui/execution-operations/README.md`. +- Expected evidence: focused Angular feature spec coverage, rebuilt web bundle synced into the live compose frontdoor, and a Playwright action sweep for every tab/action on `/ops/operations/jobs-queues`. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md` for the authenticated live sweep harness and on `SPRINT_20260310_033_FE_live_frontdoor_unified_search_route_matrix.md` for the current healthy frontdoor baseline. +- Safe parallelism: stay inside `src/Web/StellaOps.Web/**` plus the explicitly allowed docs files; do not take ownership of backend or unrelated route slices in parallel. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/ui/platform-ops-consolidation/README.md` +- `docs/modules/ui/execution-operations/README.md` + +## Delivery Tracker + +### FE-JOBSQUEUES-034-001 - Replace fake row actions with truthful execution handoffs +Status: DONE +Dependency: none +Owners: Product Manager, Architect, Developer +Task description: +- The live page currently presents `View`, `Run Now`, `Edit`, `Pause`, `Replay`, and `Drain` controls that all navigate back to `/ops/operations/jobs-queues`. That is a product contract failure because the page is an overview shell, not the owner of those mutations. +- Reframe the page as a truthful execution overview: every visible row action must hand off into the canonical JobEngine, Scheduler, Dead-Letter, or Data Integrity surfaces with labels that match what the destination actually does. + +Completion criteria: +- [x] No row action on `/ops/operations/jobs-queues` routes back to `/ops/operations/jobs-queues` unless it is explicitly labeled as staying on the overview. +- [x] The action labels match the actual destination behavior instead of implying unsupported row-level mutations. +- [x] The context copy on the page explains that execution control happens in canonical downstream surfaces. + +### FE-JOBSQUEUES-034-002 - Make Jobs & Queues filters and inline feedback real +Status: DONE +Dependency: FE-JOBSQUEUES-034-001 +Owners: Developer, QA +Task description: +- The current search, status, and type controls are inert. The copy buttons also execute silently. +- Wire the filters to the overview data for each tab, reset them safely when the user changes tabs, and surface explicit inline feedback for copy actions so the page is behaviorally testable. + +Completion criteria: +- [x] Search and select filters change the rendered rows on every tab they appear on. +- [x] Tab switches reset stale filters so one tab's facet state does not poison another tab. +- [x] Copy correlation actions show an inline status message on success or a manual-copy fallback. + +### FE-JOBSQUEUES-034-003 - Rebuild and prove the live page with Playwright +Status: DONE +Dependency: FE-JOBSQUEUES-034-002 +Owners: QA +Task description: +- Rebuild the web bundle, sync it into the live frontdoor static volume, and run a real authenticated Playwright sweep across every tab and every distinct visible action on the Jobs & Queues page. + +Completion criteria: +- [x] Focused Angular feature coverage passes for the page. +- [x] `npm run build` passes and the rebuilt bundle is synced into `compose_console-dist`. +- [x] A live Playwright sweep artifact records passing checks for the Jobs, Runs, Schedules, Dead Letters, and Workers tabs without runtime errors. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created from the next post-search live QA iteration after Playwright proved `/ops/operations/jobs-queues` still exposes self-linking placeholder actions and inert filters. | Developer | +| 2026-03-10 | Reframed `Jobs & Queues` into a truthful execution overview: self-linking fake actions were replaced with canonical JobEngine/Scheduler/Dead-Letter/Data Integrity handoffs, filters now work per tab with reset-on-tab-change behavior, and inline correlation-copy feedback was added. Focused Angular coverage passed (`5/5`), `npm run build` passed, the rebuilt bundle was synced into `compose_console-dist`, and `src/Web/StellaOps.Web/output/playwright/live-jobs-queues-action-sweep.json` recorded `11/11` live action checks passing with `runtimeIssueCount=0`. | QA | + +## Decisions & Risks +- Decision: `Jobs & Queues` remains an execution overview, not a fake CRUD surface. Real mutations belong to JobEngine, Scheduler, Dead-Letter, or Data Integrity pages, so the overview must hand off honestly instead of inventing unsupported per-row controls. +- Decision: inert filters are a defect, not a cosmetic gap. If a control is rendered on the page, it must either work or be removed. +- Decision: the page now treats clipboard restrictions as an operator-visible runtime condition. When browser clipboard APIs are unavailable, the UI surfaces a manual-copy fallback instead of failing silently. +- Risk: the page still uses synthetic overview data rather than live backend records. This iteration makes the surface truthful and testable, but deeper backend-backed execution parity may still need a later slice. + +## Next Checkpoints +- 2026-03-10: land the page/model rewrite and focused frontend coverage. +- 2026-03-10: rebuild and sync the web bundle into the live compose frontdoor. +- 2026-03-10: rerun authenticated Playwright against `/ops/operations/jobs-queues` and commit the iteration locally. diff --git a/docs/modules/ui/execution-operations/README.md b/docs/modules/ui/execution-operations/README.md index b3d920986..16525bf31 100644 --- a/docs/modules/ui/execution-operations/README.md +++ b/docs/modules/ui/execution-operations/README.md @@ -45,6 +45,8 @@ ## UX Rules - `Jobs & Queues` is the execution overview, not a dead-end card deck. It must deep-link into JobEngine, Scheduler, Dead-Letter, and related operator pages. +- `Jobs & Queues` must not advertise fake row-level mutations. If the overview cannot perform `run`, `pause`, `drain`, or `replay` locally, its visible actions must be relabeled as honest handoffs into the canonical downstream surfaces that own those operations. +- Overview filters inside `Jobs & Queues` must be functional on every tab they appear on. Inert search/select controls are treated as a behavioral defect, not acceptable placeholder chrome. - `JobEngine` owns queue health, job detail, DAG context, and quota controls. - `Scheduler` owns run monitoring, schedule management, worker fleet, and run-stream drill-in. - `Dead-Letter` owns queue browse, replay, resolve, export, and handoff back to canonical job detail. @@ -72,4 +74,3 @@ - `docs/modules/ui/offline-operations/README.md` - `docs/modules/ui/quota-health-aoc-operations/README.md` - `docs/features/checked/web/execution-operations-ui.md` - diff --git a/src/Web/StellaOps.Web/scripts/live-jobs-queues-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-jobs-queues-action-sweep.mjs new file mode 100644 index 000000000..18a879857 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-jobs-queues-action-sweep.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-jobs-queues-action-sweep.json'); +const authStatePath = path.join(outputDir, 'live-jobs-queues-action-sweep.state.json'); +const authReportPath = path.join(outputDir, 'live-jobs-queues-action-sweep.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; +const routePath = '/ops/operations/jobs-queues'; + +function scopedUrl(route = routePath) { + const separator = route.includes('?') ? '&' : '?'; + return `https://stella-ops.local${route}${separator}${scopeQuery}`; +} + +async function settle(page) { + await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {}); + await page.waitForTimeout(1_200); +} + +async function gotoPage(page) { + await page.goto(scopedUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); +} + +async function clickTab(page, tabName) { + await page.getByRole('button', { name: tabName }).click({ timeout: 10_000 }); + await settle(page); +} + +async function rowCount(page) { + return page.locator('tbody tr').count(); +} + +function searchFilter(page) { + return page.locator('section.filters input[type="search"]').first(); +} + +function filterSelect(page, index) { + return page.locator('section.filters select').nth(index); +} + +function actionBanner(page) { + return page.locator('section.jobs-queues .jobs-queues__banner[role="status"]').first(); +} + +async function captureRuntimeIssues(page) { + const alerts = await page + .locator('[role="alert"], .error-banner, .toast, .notification') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean), + ) + .catch(() => []); + + return alerts; +} + +async function readActionHrefs(page) { + return page.locator('tbody tr:first-child .actions a').evaluateAll((links) => + links.map((link) => ({ + label: (link.textContent || '').trim().replace(/\s+/g, ' '), + href: link.getAttribute('href') || '', + })), + ); +} + +async function assert(condition, message, details = {}) { + if (!condition) { + throw new Error(`${message}${Object.keys(details).length ? ` ${JSON.stringify(details)}` : ''}`); + } +} + +async function run() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + const page = await context.newPage(); + const consoleErrors = []; + const responseErrors = []; + const requestFailures = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (response.status() >= 400 && !url.includes('/connect/authorize')) { + responseErrors.push({ + status: response.status(), + method: response.request().method(), + url, + }); + } + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + const failure = request.failure()?.errorText ?? 'unknown'; + if (failure === 'net::ERR_ABORTED') { + return; + } + + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + requestFailures.push({ + method: request.method(), + url, + error: failure, + }); + }); + + const checks = []; + + try { + await gotoPage(page); + await assert((await page.locator('h1').first().textContent())?.trim() === 'Jobs & Queues', 'Unexpected page heading.'); + + let actions = await readActionHrefs(page); + await assert(actions.length === 2, 'Jobs tab did not expose the expected two row actions.', { actions }); + await assert(actions[0].href.includes('/ops/operations/jobengine'), 'Jobs primary action did not target JobEngine.', { actions }); + await assert(actions[1].href.includes('/ops/operations/jobengine/jobs'), 'Jobs secondary action did not target the Job list.', { actions }); + await assert(!actions.some((entry) => entry.href.includes('/ops/operations/jobs-queues')), 'Jobs tab still contains self-linking row actions.', { actions }); + checks.push({ name: 'jobs-row-actions', ok: true, actions }); + + await searchFilter(page).fill('Vulnerability'); + await settle(page); + let rows = await rowCount(page); + await assert(rows === 1, 'Jobs search filter did not reduce the table to one row.', { rows }); + await page.getByRole('button', { name: 'Clear filters' }).click(); + await settle(page); + rows = await rowCount(page); + await assert(rows === 4, 'Jobs clear filters did not restore all rows.', { rows }); + checks.push({ name: 'jobs-filters', ok: true, rowsAfterClear: rows }); + + await searchFilter(page).fill('Vulnerability'); + await settle(page); + await clickTab(page, 'Runs'); + await assert(await searchFilter(page).inputValue() === '', 'Tab switch did not clear the search filter.'); + checks.push({ name: 'tab-switch-clears-filters', ok: true }); + + await filterSelect(page, 0).selectOption('FAILED'); + await filterSelect(page, 1).selectOption('DEGRADED'); + await settle(page); + rows = await rowCount(page); + await assert(rows === 1, 'Runs filters did not isolate the failed degraded row.', { rows }); + checks.push({ name: 'runs-filters', ok: true, rows }); + + await page.getByRole('button', { name: 'Clear filters' }).click(); + await settle(page); + await page.getByRole('button', { name: 'Copy CorrID' }).first().click(); + await settle(page); + const statusText = ((await actionBanner(page).textContent()) || '').trim(); + await assert(statusText.includes('corr-run-001'), 'Runs copy action did not produce inline feedback.', { statusText }); + checks.push({ name: 'runs-copy-feedback', ok: true, statusText }); + + await filterSelect(page, 0).selectOption('DEAD-LETTER'); + await filterSelect(page, 1).selectOption('BLOCKING'); + await settle(page); + await page.getByRole('link', { name: 'Open Dead-Letter Queue' }).click(); + await settle(page); + await assert(page.url().includes('/ops/operations/dead-letter/queue'), 'Runs dead-letter handoff did not reach the DLQ.'); + checks.push({ name: 'runs-dead-letter-handoff', ok: true, targetUrl: page.url() }); + + await gotoPage(page); + await clickTab(page, 'Schedules'); + await filterSelect(page, 0).selectOption('FAIL'); + await filterSelect(page, 1).selectOption('Daily'); + await settle(page); + rows = await rowCount(page); + await assert(rows === 1, 'Schedules filters did not isolate the failed daily schedule.', { rows }); + await page.getByRole('link', { name: 'Review Dead-Letter Queue' }).click(); + await settle(page); + await assert(page.url().includes('/ops/operations/dead-letter/queue'), 'Schedules failure handoff did not reach the DLQ.'); + checks.push({ name: 'schedules-failure-handoff', ok: true, targetUrl: page.url() }); + + await gotoPage(page); + await clickTab(page, 'Schedules'); + await page.getByRole('link', { name: 'Manage Schedules' }).first().click(); + await settle(page); + await assert(page.url().includes('/ops/operations/scheduler/schedules'), 'Schedules primary handoff did not reach Scheduler.'); + checks.push({ name: 'schedules-primary-handoff', ok: true, targetUrl: page.url() }); + + await gotoPage(page); + await clickTab(page, 'Dead Letters'); + actions = await readActionHrefs(page); + await assert(actions[0].href.includes('/ops/operations/dead-letter/queue'), 'Dead-letter primary action did not target the queue.', { actions }); + await assert(actions[1].href.includes('/ops/operations/data-integrity/dlq'), 'Dead-letter recovery action did not target the DLQ recovery surface.', { actions }); + await page.getByRole('link', { name: 'Open Replay Recovery' }).first().click(); + await settle(page); + await assert(page.url().includes('/ops/operations/data-integrity/dlq'), 'Dead-letter recovery handoff did not reach Data Integrity.'); + checks.push({ name: 'dead-letter-handoffs', ok: true, targetUrl: page.url() }); + + await gotoPage(page); + await clickTab(page, 'Workers'); + await filterSelect(page, 0).selectOption('DEGRADED'); + await filterSelect(page, 1).selectOption('feeds'); + await settle(page); + rows = await rowCount(page); + await assert(rows === 1, 'Workers filters did not isolate the degraded feeds worker.', { rows }); + await page.getByRole('link', { name: 'Open Worker Fleet' }).click(); + await settle(page); + await assert(page.url().includes('/ops/operations/scheduler/workers'), 'Workers primary handoff did not reach Worker Fleet.'); + checks.push({ name: 'workers-primary-handoff', ok: true, targetUrl: page.url() }); + + await gotoPage(page); + await clickTab(page, 'Workers'); + await filterSelect(page, 0).selectOption('DEGRADED'); + await filterSelect(page, 1).selectOption('feeds'); + await settle(page); + await page.getByRole('link', { name: 'Inspect Scheduler Runs' }).click(); + await settle(page); + await assert(page.url().includes('/ops/operations/scheduler/runs'), 'Workers secondary handoff did not reach Scheduler Runs.'); + checks.push({ name: 'workers-secondary-handoff', ok: true, targetUrl: page.url() }); + + const runtimeIssues = { + consoleErrors, + responseErrors, + requestFailures, + alerts: await captureRuntimeIssues(page), + }; + const summary = { + checkedAtUtc: new Date().toISOString(), + routePath, + checks, + runtimeIssues, + failedCheckCount: checks.filter((check) => !check.ok).length, + runtimeIssueCount: + runtimeIssues.consoleErrors.length + + runtimeIssues.responseErrors.length + + runtimeIssues.requestFailures.length + + runtimeIssues.alerts.length, + }; + + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + + if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } + } catch (error) { + const summary = { + checkedAtUtc: new Date().toISOString(), + routePath, + checks, + error: error instanceof Error ? error.message : String(error), + consoleErrors, + responseErrors, + requestFailures, + }; + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + process.exitCode = 1; + } finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + } +} + +await run(); diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts new file mode 100644 index 000000000..10e3d97b3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts @@ -0,0 +1,117 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { PlatformJobsQueuesPageComponent } from './platform-jobs-queues-page.component'; + +describe('PlatformJobsQueuesPageComponent', () => { + let fixture: ComponentFixture; + let component: PlatformJobsQueuesPageComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlatformJobsQueuesPageComponent], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(PlatformJobsQueuesPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders truthful jobs handoff actions instead of self-links', () => { + const actions = Array.from( + fixture.nativeElement.querySelectorAll('tbody tr:first-child .actions a') as NodeListOf, + ); + const labels = actions.map((link) => link.textContent?.trim()); + const hrefs = actions.map((link) => link.getAttribute('href') ?? ''); + + expect(labels).toEqual(['Open JobEngine', 'View Jobs']); + expect(hrefs[0]).toContain('/ops/operations/jobengine'); + expect(hrefs[1]).toContain('/ops/operations/jobengine/jobs'); + expect(hrefs).not.toContain('/ops/operations/jobs-queues'); + }); + + it('filters the jobs table and clears stale filters when switching tabs', async () => { + const searchInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type="search"]'); + + searchInput.value = 'Vulnerability'; + searchInput.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + let rows = fixture.nativeElement.querySelectorAll('tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('Vulnerability sync (NVD)'); + + const runsTab: HTMLButtonElement = fixture.nativeElement.querySelectorAll('.tabs button')[1]; + runsTab.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.tab()).toBe('runs'); + expect(component.searchQuery()).toBe(''); + expect(component.statusFilter()).toBe(''); + expect(component.facetFilter()).toBe(''); + rows = fixture.nativeElement.querySelectorAll('tbody tr'); + expect(rows.length).toBe(4); + }); + + it('applies run filters using the active tab facets', async () => { + component.setTab('runs'); + fixture.detectChanges(); + + const selects = fixture.nativeElement.querySelectorAll('select'); + const statusSelect = selects[0] as HTMLSelectElement; + const impactSelect = selects[1] as HTMLSelectElement; + + statusSelect.value = 'FAILED'; + statusSelect.dispatchEvent(new Event('change')); + impactSelect.value = 'DEGRADED'; + impactSelect.dispatchEvent(new Event('change')); + await fixture.whenStable(); + fixture.detectChanges(); + + const rows = fixture.nativeElement.querySelectorAll('tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('run-003'); + expect(rows[0].textContent).toContain('FAILED'); + }); + + it('shows inline copy feedback for correlation actions', async () => { + const clipboard = { + writeText: jasmine.createSpy('writeText').and.returnValue(Promise.resolve()), + }; + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: clipboard, + }); + + component.setTab('runs'); + fixture.detectChanges(); + + const copyButton = fixture.nativeElement.querySelector('.actions button') as HTMLButtonElement; + copyButton.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(clipboard.writeText).toHaveBeenCalledWith('corr-run-001'); + const status = fixture.nativeElement.querySelector('[role="status"]'); + expect(status?.textContent).toContain('corr-run-001'); + }); + + it('routes dead-letter handoffs into canonical recovery pages', () => { + component.setTab('dead-letters'); + fixture.detectChanges(); + + const actions = Array.from( + fixture.nativeElement.querySelectorAll('tbody tr:first-child .actions a') as NodeListOf, + ); + const labels = actions.map((link) => link.textContent?.trim()); + const hrefs = actions.map((link) => link.getAttribute('href') ?? ''); + + expect(labels).toEqual(['Open Dead-Letter Queue', 'Open Replay Recovery']); + expect(hrefs[0]).toContain('/ops/operations/dead-letter/queue'); + expect(hrefs[1]).toContain('/ops/operations/data-integrity/dlq'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts index ec7af7a27..fa8bc7bbe 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts @@ -1,58 +1,68 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { OPERATIONS_PATHS, dataIntegrityPath, deadLetterQueuePath } from './operations-paths'; + type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers'; type JobImpact = 'BLOCKING' | 'DEGRADED' | 'INFO'; +type Cadence = 'Hourly' | 'Daily'; + +interface TabAction { + readonly label: string; + readonly route: string; +} interface JobDefinitionRow { - id: string; - name: string; - type: string; - lastRun: string; - health: 'OK' | 'WARN' | 'DLQ'; + readonly id: string; + readonly name: string; + readonly type: string; + readonly lastRun: string; + readonly health: 'OK' | 'WARN' | 'DLQ'; } interface JobRunRow { - id: string; - job: string; - status: 'RUNNING' | 'COMPLETED' | 'FAILED' | 'DEAD-LETTER'; - startedAt: string; - duration: string; - impact: JobImpact; - correlationId: string; + readonly id: string; + readonly job: string; + readonly status: 'RUNNING' | 'COMPLETED' | 'FAILED' | 'DEAD-LETTER'; + readonly startedAt: string; + readonly duration: string; + readonly impact: JobImpact; + readonly correlationId: string; } interface ScheduleRow { - id: string; - name: string; - cron: string; - nextRun: string; - lastStatus: 'OK' | 'WARN' | 'FAIL'; + readonly id: string; + readonly name: string; + readonly cron: string; + readonly nextRun: string; + readonly lastStatus: 'OK' | 'WARN' | 'FAIL'; + readonly cadence: Cadence; } interface DeadLetterRow { - id: string; - timestamp: string; - job: string; - error: string; - retryable: 'YES' | 'NO'; - impact: JobImpact; - correlationId: string; + readonly id: string; + readonly timestamp: string; + readonly job: string; + readonly error: string; + readonly retryable: 'YES' | 'NO'; + readonly impact: JobImpact; + readonly correlationId: string; } interface WorkerRow { - id: string; - name: string; - queue: string; - state: 'HEALTHY' | 'DEGRADED'; - capacity: string; - heartbeat: string; + readonly id: string; + readonly name: string; + readonly queue: string; + readonly state: 'HEALTHY' | 'DEGRADED'; + readonly capacity: string; + readonly heartbeat: string; } @Component({ selector: 'app-platform-jobs-queues-page', standalone: true, - imports: [RouterLink], + imports: [FormsModule, RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -60,22 +70,29 @@ interface WorkerRow {

Jobs & Queues

- Unified operator surface for orchestrator jobs, scheduler runs, schedules, - dead letters, and worker fleet posture. + Truthful execution overview for queue posture, scheduler health, dead-letter recovery, + and worker capacity. Execution control continues in the canonical JobEngine, Scheduler, + Dead-Letter, and Data Integrity surfaces linked below.

+ @if (actionNotice()) { +
+ {{ actionNotice() }} +
+ } +
@@ -86,31 +103,37 @@ interface WorkerRow { Workers {{ workers.length }}
-
+
+
@if (tab() === 'jobs') { @@ -126,16 +149,23 @@ interface WorkerRow { - @for (row of jobs; track row.id) { + @if (filteredJobs().length) { + @for (row of filteredJobs(); track row.id) { + + {{ row.name }} + {{ row.type }} + {{ row.lastRun }} + {{ row.health }} + + @for (action of jobActions(row); track action.label) { + {{ action.label }} + } + + + } + } @else { - {{ row.name }} - {{ row.type }} - {{ row.lastRun }} - {{ row.health }} - - View - Run Now - + No jobs match the current filters. } @@ -158,18 +188,26 @@ interface WorkerRow { - @for (row of runs; track row.id) { + @if (filteredRuns().length) { + @for (row of filteredRuns(); track row.id) { + + {{ row.id }} + {{ row.job }} + {{ row.status }} + {{ row.startedAt }} + {{ row.duration }} + {{ row.impact }} + + @for (action of runActions(row); track action.label) { + {{ action.label }} + } + + + + } + } @else { - {{ row.id }} - {{ row.job }} - {{ row.status }} - {{ row.startedAt }} - {{ row.duration }} - {{ row.impact }} - - View - - + No runs match the current filters. } @@ -190,16 +228,23 @@ interface WorkerRow { - @for (row of schedules; track row.id) { + @if (filteredSchedules().length) { + @for (row of filteredSchedules(); track row.id) { + + {{ row.name }} + {{ row.cron }} + {{ row.nextRun }} + {{ row.lastStatus }} + + @for (action of scheduleActions(row); track action.label) { + {{ action.label }} + } + + + } + } @else { - {{ row.name }} - {{ row.cron }} - {{ row.nextRun }} - {{ row.lastStatus }} - - Edit - Pause - + No schedules match the current filters. } @@ -221,17 +266,25 @@ interface WorkerRow { - @for (row of deadLetters; track row.id) { + @if (filteredDeadLetters().length) { + @for (row of filteredDeadLetters(); track row.id) { + + {{ row.timestamp }} + {{ row.job }} + {{ row.error }} + {{ row.retryable }} + {{ row.impact }} + + @for (action of deadLetterActions(row); track action.label) { + {{ action.label }} + } + + + + } + } @else { - {{ row.timestamp }} - {{ row.job }} - {{ row.error }} - {{ row.retryable }} - {{ row.impact }} - - Replay - - + No dead-letter entries match the current filters. } @@ -253,17 +306,24 @@ interface WorkerRow { - @for (row of workers; track row.id) { + @if (filteredWorkers().length) { + @for (row of filteredWorkers(); track row.id) { + + {{ row.name }} + {{ row.queue }} + {{ row.state }} + {{ row.capacity }} + {{ row.heartbeat }} + + @for (action of workerActions(row); track action.label) { + {{ action.label }} + } + + + } + } @else { - {{ row.name }} - {{ row.queue }} - {{ row.state }} - {{ row.capacity }} - {{ row.heartbeat }} - - View - Drain - + No workers match the current filters. } @@ -274,28 +334,27 @@ interface WorkerRow {

Context

@if (tab() === 'jobs') { -

Jobs define recurring and ad hoc automation units used by release/security/evidence pipelines.

+

Job inventory and quota-aware execution control live in JobEngine. Use this overview to route into the right downstream surface.

} @if (tab() === 'runs') {

- Active issue: run-004 is in dead-letter due to upstream feed rate limiting. - Impact: BLOCKING + Scheduler owns run monitoring. Dead-letter handoff stays explicit so blocking execution incidents do not masquerade as runnable items here.

} @if (tab() === 'schedules') { -

Schedules control deterministic execution windows and regional workload sequencing.

+

Schedules own cadence and worker orchestration. Use Scheduler for real edits and worker-fleet coordination.

} @if (tab() === 'dead-letters') {

Dead-letter triage is linked to release impact. - Open DLQ & Replays + Open DLQ recovery

} @if (tab() === 'workers') { -

Worker capacity and health affect queue latency and decision freshness SLAs.

+

Worker capacity and health affect queue latency and decision freshness SLAs. Fleet control stays in Scheduler.

} @@ -323,7 +382,7 @@ interface WorkerRow { margin: 0.2rem 0 0; font-size: 0.8rem; color: var(--color-text-secondary); - max-width: 72ch; + max-width: 76ch; } .jobs-queues__actions { @@ -342,6 +401,15 @@ interface WorkerRow { padding: 0.3rem 0.55rem; } + .jobs-queues__banner { + border-radius: var(--radius-md); + border: 1px solid var(--color-status-info-border); + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + padding: 0.55rem 0.7rem; + font-size: 0.75rem; + } + .tabs { display: flex; gap: 0.35rem; @@ -386,6 +454,7 @@ interface WorkerRow { display: flex; gap: 0.55rem; flex-wrap: wrap; + align-items: end; } .filters label { @@ -398,7 +467,8 @@ interface WorkerRow { } .filters input, - .filters select { + .filters select, + .filters__clear { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); @@ -408,6 +478,16 @@ interface WorkerRow { min-width: 170px; } + .filters__clear { + min-width: 0; + cursor: pointer; + } + + .filters__clear[disabled] { + cursor: default; + opacity: 0.65; + } + .table-wrap { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); @@ -436,6 +516,13 @@ interface WorkerRow { color: var(--color-text-secondary); } + .table-empty { + color: var(--color-text-secondary); + font-style: italic; + text-align: center; + white-space: normal; + } + .pill, .impact { border-radius: var(--radius-full); @@ -489,6 +576,7 @@ interface WorkerRow { display: flex; gap: 0.32rem; align-items: center; + flex-wrap: wrap; } .actions a, @@ -538,16 +626,22 @@ interface WorkerRow { `], }) export class PlatformJobsQueuesPageComponent { + readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + readonly dataIntegrityDlqPath = dataIntegrityPath('dlq'); readonly tab = signal('jobs'); + readonly searchQuery = signal(''); + readonly statusFilter = signal(''); + readonly facetFilter = signal(''); + readonly actionNotice = signal(null); - readonly jobs: JobDefinitionRow[] = [ + readonly jobs: readonly JobDefinitionRow[] = [ { id: 'job-def-1', name: 'Container scan', type: 'security', lastRun: '08:03 UTC (2m)', health: 'OK' }, { id: 'job-def-2', name: 'SBOM generation', type: 'supply', lastRun: '07:55 UTC (1m)', health: 'OK' }, { id: 'job-def-3', name: 'Compliance export', type: 'evidence', lastRun: '06:00 UTC (5m)', health: 'OK' }, { id: 'job-def-4', name: 'Vulnerability sync (NVD)', type: 'feeds', lastRun: '05:12 UTC (FAIL)', health: 'DLQ' }, ]; - readonly runs: JobRunRow[] = [ + readonly runs: readonly JobRunRow[] = [ { id: 'run-001', job: 'Container scan', @@ -586,13 +680,13 @@ export class PlatformJobsQueuesPageComponent { }, ]; - readonly schedules: ScheduleRow[] = [ - { id: 'sch-1', name: 'Nightly supply scan', cron: '0 2 * * *', nextRun: '02:00 UTC', lastStatus: 'OK' }, - { id: 'sch-2', name: 'Advisory sync', cron: '*/30 * * * *', nextRun: '22:30 UTC', lastStatus: 'WARN' }, - { id: 'sch-3', name: 'Evidence export', cron: '0 6 * * *', nextRun: '06:00 UTC', lastStatus: 'FAIL' }, + readonly schedules: readonly ScheduleRow[] = [ + { id: 'sch-1', name: 'Nightly supply scan', cron: '0 2 * * *', nextRun: '02:00 UTC', lastStatus: 'OK', cadence: 'Daily' }, + { id: 'sch-2', name: 'Advisory sync', cron: '*/30 * * * *', nextRun: '22:30 UTC', lastStatus: 'WARN', cadence: 'Hourly' }, + { id: 'sch-3', name: 'Evidence export', cron: '0 6 * * *', nextRun: '06:00 UTC', lastStatus: 'FAIL', cadence: 'Daily' }, ]; - readonly deadLetters: DeadLetterRow[] = [ + readonly deadLetters: readonly DeadLetterRow[] = [ { id: 'dlq-1', timestamp: '05:12 UTC', @@ -622,19 +716,249 @@ export class PlatformJobsQueuesPageComponent { }, ]; - readonly workers: WorkerRow[] = [ + readonly workers: readonly WorkerRow[] = [ { id: 'wrk-1', name: 'worker-east-01', queue: 'security', state: 'HEALTHY', capacity: '8/10', heartbeat: '5s ago' }, { id: 'wrk-2', name: 'worker-east-02', queue: 'feeds', state: 'DEGRADED', capacity: '10/10', heartbeat: '24s ago' }, { id: 'wrk-3', name: 'worker-eu-01', queue: 'supply', state: 'HEALTHY', capacity: '6/10', heartbeat: '7s ago' }, ]; + readonly searchPlaceholder = computed(() => { + switch (this.tab()) { + case 'jobs': + return 'Job name, type, or health'; + case 'runs': + return 'Run id, job, or correlation id'; + case 'schedules': + return 'Schedule name or cron'; + case 'dead-letters': + return 'Job, error, or correlation id'; + case 'workers': + return 'Worker, queue, or capacity'; + } + }); + + readonly statusLabel = computed(() => { + switch (this.tab()) { + case 'jobs': + return 'Health'; + case 'runs': + return 'Status'; + case 'schedules': + return 'Last Status'; + case 'dead-letters': + return 'Retryable'; + case 'workers': + return 'State'; + } + }); + + readonly facetLabel = computed(() => { + switch (this.tab()) { + case 'jobs': + return 'Type'; + case 'runs': + return 'Impact'; + case 'schedules': + return 'Cadence'; + case 'dead-letters': + return 'Impact'; + case 'workers': + return 'Queue'; + } + }); + + readonly statusOptions = computed(() => { + switch (this.tab()) { + case 'jobs': + return ['OK', 'WARN', 'DLQ']; + case 'runs': + return ['RUNNING', 'FAILED', 'DEAD-LETTER', 'COMPLETED']; + case 'schedules': + return ['OK', 'WARN', 'FAIL']; + case 'dead-letters': + return ['YES', 'NO']; + case 'workers': + return ['HEALTHY', 'DEGRADED']; + } + }); + + readonly facetOptions = computed(() => { + switch (this.tab()) { + case 'jobs': + return ['security', 'supply', 'evidence', 'feeds']; + case 'runs': + return ['BLOCKING', 'DEGRADED', 'INFO']; + case 'schedules': + return ['Hourly', 'Daily']; + case 'dead-letters': + return ['BLOCKING', 'DEGRADED']; + case 'workers': + return ['security', 'feeds', 'supply']; + } + }); + + readonly hasActiveFilters = computed(() => + Boolean(this.searchQuery().trim() || this.statusFilter() || this.facetFilter()), + ); + + readonly filteredJobs = computed(() => { + const search = this.normalizedSearch(); + const status = this.statusFilter(); + const facet = this.facetFilter(); + + return this.jobs.filter((row) => + this.matchesSearch(search, [row.id, row.name, row.type, row.health, row.lastRun]) + && this.matchesFilter(status, row.health) + && this.matchesFilter(facet, row.type), + ); + }); + + readonly filteredRuns = computed(() => { + const search = this.normalizedSearch(); + const status = this.statusFilter(); + const facet = this.facetFilter(); + + return this.runs.filter((row) => + this.matchesSearch(search, [row.id, row.job, row.status, row.impact, row.correlationId, row.startedAt]) + && this.matchesFilter(status, row.status) + && this.matchesFilter(facet, row.impact), + ); + }); + + readonly filteredSchedules = computed(() => { + const search = this.normalizedSearch(); + const status = this.statusFilter(); + const facet = this.facetFilter(); + + return this.schedules.filter((row) => + this.matchesSearch(search, [row.id, row.name, row.cron, row.nextRun, row.lastStatus, row.cadence]) + && this.matchesFilter(status, row.lastStatus) + && this.matchesFilter(facet, row.cadence), + ); + }); + + readonly filteredDeadLetters = computed(() => { + const search = this.normalizedSearch(); + const status = this.statusFilter(); + const facet = this.facetFilter(); + + return this.deadLetters.filter((row) => + this.matchesSearch(search, [row.id, row.job, row.error, row.retryable, row.impact, row.correlationId, row.timestamp]) + && this.matchesFilter(status, row.retryable) + && this.matchesFilter(facet, row.impact), + ); + }); + + readonly filteredWorkers = computed(() => { + const search = this.normalizedSearch(); + const status = this.statusFilter(); + const facet = this.facetFilter(); + + return this.workers.filter((row) => + this.matchesSearch(search, [row.id, row.name, row.queue, row.state, row.capacity, row.heartbeat]) + && this.matchesFilter(status, row.state) + && this.matchesFilter(facet, row.queue), + ); + }); + + setTab(tab: JobsQueuesTab): void { + if (this.tab() === tab) { + return; + } + + this.tab.set(tab); + this.clearFilters(); + this.actionNotice.set(null); + } + + clearFilters(): void { + this.searchQuery.set(''); + this.statusFilter.set(''); + this.facetFilter.set(''); + } + runsByStatus(status: JobRunRow['status']): number { return this.runs.filter((row) => row.status === status).length; } copyCorrelationId(correlationId: string): void { - if (typeof navigator !== 'undefined' && navigator.clipboard) { - void navigator.clipboard.writeText(correlationId).catch(() => null); + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + this.actionNotice.set(`Clipboard unavailable. Copy correlation ID ${correlationId} manually.`); + return; } + + void navigator.clipboard.writeText(correlationId) + .then(() => { + this.actionNotice.set(`Copied correlation ID ${correlationId}.`); + }) + .catch(() => { + this.actionNotice.set(`Clipboard unavailable. Copy correlation ID ${correlationId} manually.`); + }); + } + + jobActions(row: JobDefinitionRow): readonly TabAction[] { + return [ + { label: 'Open JobEngine', route: OPERATIONS_PATHS.jobEngine }, + { + label: row.health === 'DLQ' ? 'Open Dead-Letter Queue' : 'View Jobs', + route: row.health === 'DLQ' ? deadLetterQueuePath() : OPERATIONS_PATHS.jobEngineJobs, + }, + ]; + } + + runActions(row: JobRunRow): readonly TabAction[] { + return [ + { label: 'Open Scheduler Runs', route: OPERATIONS_PATHS.schedulerRuns }, + { + label: row.status === 'DEAD-LETTER' ? 'Open Dead-Letter Queue' : 'Open JobEngine Jobs', + route: row.status === 'DEAD-LETTER' ? deadLetterQueuePath() : OPERATIONS_PATHS.jobEngineJobs, + }, + ]; + } + + scheduleActions(row: ScheduleRow): readonly TabAction[] { + return [ + { label: 'Manage Schedules', route: OPERATIONS_PATHS.schedulerSchedules }, + { + label: row.lastStatus === 'FAIL' ? 'Review Dead-Letter Queue' : 'Open Worker Fleet', + route: row.lastStatus === 'FAIL' ? deadLetterQueuePath() : OPERATIONS_PATHS.schedulerWorkers, + }, + ]; + } + + deadLetterActions(row: DeadLetterRow): readonly TabAction[] { + return [ + { label: 'Open Dead-Letter Queue', route: deadLetterQueuePath() }, + { + label: row.retryable === 'YES' ? 'Open Replay Recovery' : 'Open JobEngine Jobs', + route: row.retryable === 'YES' ? dataIntegrityPath('dlq') : OPERATIONS_PATHS.jobEngineJobs, + }, + ]; + } + + workerActions(row: WorkerRow): readonly TabAction[] { + return [ + { label: 'Open Worker Fleet', route: OPERATIONS_PATHS.schedulerWorkers }, + { + label: row.state === 'DEGRADED' ? 'Inspect Scheduler Runs' : 'Open Scheduler Runs', + route: OPERATIONS_PATHS.schedulerRuns, + }, + ]; + } + + private normalizedSearch(): string { + return this.searchQuery().trim().toLowerCase(); + } + + private matchesSearch(search: string, values: readonly string[]): boolean { + if (!search) { + return true; + } + + return values.some((value) => value.toLowerCase().includes(search)); + } + + private matchesFilter(selected: string, actual: string): boolean { + return !selected || selected === actual; } } diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 9f92cb464..510fd050f 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -9,6 +9,7 @@ "src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts", "src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts", "src/app/features/deploy-diff/services/deploy-diff.service.spec.ts", + "src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts", "src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts", "src/app/features/policy-simulation/policy-simulation-defaults.spec.ts", "src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",