diff --git a/docs-archived/implplan/SPRINT_20260308_004_FE_execution_operations_cutover.md b/docs-archived/implplan/SPRINT_20260308_004_FE_execution_operations_cutover.md new file mode 100644 index 000000000..fde190d3a --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260308_004_FE_execution_operations_cutover.md @@ -0,0 +1,105 @@ +# Sprint 20260308_004_FE - Execution Operations Cutover + +## Topic & Scope +- Complete the `Ops > Operations` execution cluster so `Jobs & Queues`, `JobEngine`, `Scheduler`, `Dead-Letter`, and `Scanner Ops` behave as one usable operator surface instead of a mix of placeholders and stale links. +- Replace remaining stale `/platform-ops/*`, `/ops/scanner`, and other non-canonical execution links with mounted canonical routes while preserving bookmark compatibility where needed. +- Finish the missing workflows inside these execution surfaces so operators can navigate, inspect, export, retry, resolve, and manage quotas without dead links or fake success. +- Working directory: `src/Web/StellaOps.Web/`. +- Expected evidence: targeted Angular tests, Playwright execution-ops flow coverage, checked-feature documentation, and archived sprint notes. + +## Dependencies & Concurrency +- Depends on the shipped `Operations` shell, `Offline Operations`, and `Quota / Health / AOC` cutovers already archived in `docs-archived/implplan/`. +- Safe parallelism: backend modules are out of scope; only frontend code, frontend docs, and verification assets are permitted. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/modules/ui/AGENTS.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/modules/ui/README.md` +- `docs/modules/ui/architecture.md` +- `docs/modules/ui/implementation_plan.md` +- `docs/modules/ui/platform-ops-consolidation/README.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### FE-EXO-001 - Freeze canonical execution route and alias contract +Status: DONE +Dependency: none +Owners: Developer / Implementer +Task description: +- Reconcile the execution surfaces with the current `Ops > Operations` owner shell. Standardize route helpers for `jobs-queues`, `jobengine`, `scheduler`, `dead-letter`, and any surviving `scanner-ops` entry points. +- Preserve known bookmarks and stale entry paths, but remove active navigation and intra-feature links that still point at `platform-ops` or other pre-consolidation locations. + +Completion criteria: +- [x] Canonical helpers exist for execution subpages and detail views. +- [x] Legacy `platform-ops` and stale scanner entry points land on mounted canonical pages without losing query state. +- [x] Navigation config no longer points execution surfaces at stale paths. + +### FE-EXO-002 - Complete JobEngine and scheduler operator workflows +Status: DONE +Dependency: FE-EXO-001 +Owners: Developer / Implementer +Task description: +- Replace remaining placeholder JobEngine pages and stale scheduler links with usable list, detail, quota, and action flows. Use the existing control clients where contracts already exist; where backend parity is missing, ship honest offline-safe exports or summaries rather than fake mutations. +- Keep the `Jobs & Queues` overview as the execution owner surface and ensure it deep-links into the real pages instead of looping back to itself. + +Completion criteria: +- [x] `Jobs & Queues` overview links to real JobEngine, scheduler, worker, and dead-letter pages. +- [x] JobEngine dashboard, jobs, detail, and quotas stay within canonical execution routes. +- [x] Scheduler runs, schedules, and worker fleet use canonical links and actionable flows. +- [x] Placeholder-only JobEngine views are replaced with usable operator surfaces. + +### FE-EXO-003 - Complete dead-letter and scanner-ops supporting workflows +Status: DONE +Dependency: FE-EXO-001 +Owners: Developer / Implementer +Task description: +- Finish the dead-letter batch actions and scanner-ops operational tools so operators can export, inspect, compare, verify, or promote through the UI without console-only placeholders. +- Any missing backend operations must degrade honestly into downloadable plans, route-backed detail views, or explicit local-only actions rather than pretending the server accepted a change. + +Completion criteria: +- [x] Dead-letter queue and entry detail actions use canonical job handoffs and real batch behavior. +- [x] Scanner-ops actions no longer rely on `console.log`. +- [x] Supporting exports or summaries are deterministic and usable offline. +- [x] Operator notices accurately describe local-only fallback behavior. + +### FE-EXO-004 - Verify cutover, sync docs, and archive +Status: DONE +Dependency: FE-EXO-002, FE-EXO-003 +Owners: Developer / Implementer, QA +Task description: +- Add focused tests for the repaired execution routes and workflows, then run targeted Angular and Playwright verification. Record the shipped behavior in checked-feature docs and archive the sprint only when every task is done. + +Completion criteria: +- [x] Targeted Angular tests cover redirect contracts and repaired execution workflows. +- [x] Playwright verifies at least one end-to-end journey through the restored execution surfaces. +- [x] UI docs and checked-feature notes reflect the shipped behavior. +- [x] Sprint moved to `docs-archived/implplan/` only after all tasks are marked DONE. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created and moved to DOING for the execution operations cutover. | Codex | +| 2026-03-08 | Repaired canonical execution helpers, stale aliases, and navigation targets for JobEngine, Scheduler, Dead-Letter, and Scanner Ops. | Developer | +| 2026-03-08 | Completed JobEngine, scheduler, dead-letter, and scanner-support workflows with real route-backed or honest local operator actions. | Developer | +| 2026-03-08 | Verified targeted Angular coverage with `npm run test -- --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/scheduler_ops/scheduler-orchestrator-ops-ui.behavior.spec.ts --include src/tests/deadletter/deadletter-queue.component.spec.ts --include src/tests/deadletter/deadletter-entry-detail.component.spec.ts --include src/tests/scanner_ops/scanner-ops-settings-ui.behavior.spec.ts --include src/tests/scanner_ops/scanner-ops-supporting-flows.behavior.spec.ts --include src/tests/jobengine/jobengine-execution-ops.behavior.spec.ts`: 38 tests passed across 7 files. | QA | +| 2026-03-08 | Verified browser cutover flow with `npx playwright test tests/e2e/execution-operations-cutover.spec.ts --workers=1`: 1 scenario passed. | QA | +| 2026-03-08 | Production build passed via `npm run build`; existing bundle budget warnings remain unchanged from the baseline. | QA | +| 2026-03-08 | Synced execution-operations docs, checked-feature evidence, and task-board status for archive. | Documentation author | + +## Decisions & Risks +- Risk: some scanner-ops actions have no dedicated backend endpoint. Mitigation: ship honest offline-safe artifacts or route-aware summaries instead of fake API success. +- Risk: JobEngine detail and quota pages still contain placeholder UX. Mitigation: use the existing mock/control clients already registered in the app to deliver usable operator flows now. +- Risk: stale execution entry points exist in both admin navigation and legacy `platform-ops` aliases. Mitigation: fix both route aliases and current nav links in the same sprint. +- Delivery rule: this sprint is only complete when the canonical execution tree is mounted, main actions are usable, and the core operator journeys are verified end to end. +- Reference design note: `docs/modules/ui/execution-operations/README.md`. +- Docs synced: + - `docs/modules/ui/execution-operations/README.md` + - `docs/features/checked/web/execution-operations-ui.md` + - `docs/modules/ui/README.md` + - `docs/modules/ui/implementation_plan.md` + - `docs/modules/ui/TASKS.md` + +## Next Checkpoints +- 2026-03-08: archived after implementation, verification, and docs sync completed. diff --git a/docs/features/checked/web/execution-operations-ui.md b/docs/features/checked/web/execution-operations-ui.md new file mode 100644 index 000000000..ea6b15011 --- /dev/null +++ b/docs/features/checked/web/execution-operations-ui.md @@ -0,0 +1,79 @@ +# Execution Operations UI + +## Module +Web + +## Status +VERIFIED + +## Description +Shipped the execution-operations cutover so `Ops > Operations` now owns usable JobEngine, Scheduler, and Dead-Letter flows, while the companion `Scanner Ops` pages no longer stop at placeholder console actions. The work repaired stale jobengine or scheduler aliases, completed the missing operator workflows, and kept execution drill-ins on mounted canonical routes. + +## Implementation Details +- **Feature directories**: + - `src/Web/StellaOps.Web/src/app/features/jobengine/` + - `src/Web.StellaOps.Web/src/app/features/scheduler-ops/` + - `src/Web.StellaOps.Web/src/app/features/deadletter/` + - `src/Web.StellaOps.Web/src/app/features/scanner-ops/` + - `src/Web.StellaOps.Web/src/app/features/platform/ops/` + - `src/Web.StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts` +- **Primary routes**: + - `/ops/operations/jobs-queues` + - `/ops/operations/jobengine` + - `/ops/operations/jobengine/jobs` + - `/ops/operations/jobengine/jobs/:jobId` + - `/ops/operations/jobengine/jobs/:jobId/dag` + - `/ops/operations/jobengine/quotas` + - `/ops/operations/dead-letter` + - `/ops/operations/dead-letter/queue` + - `/ops/operations/dead-letter/entry/:entryId` + - `/ops/operations/scheduler/runs` + - `/ops/operations/scheduler/runs/:runId/stream` + - `/ops/operations/scheduler/schedules` + - `/ops/operations/scheduler/workers` + - `/ops/scanner-ops` + - `/ops/scanner-ops/offline-kits` + - `/ops/scanner-ops/baselines` + - `/ops/scanner-ops/analyzers` + - `/ops/scanner-ops/performance` +- **Legacy aliases**: + - `/ops/jobengine/*` + - `/ops/scheduler/*` + - `/ops/scanner` + - `/ops/scanner/:page` + - `/platform-ops/*` + - `/platform/ops/*` +- **Notable repaired behaviors**: + - JobEngine job detail and DAG drill-in stay on canonical routes + - JobEngine quota actions export usable snapshots instead of placeholder buttons + - dead-letter batch replay and resolve call the real client methods and hand back to canonical job detail + - scheduler runs can open a route-backed live-stream view + - scanner offline-kit, baseline, analyzer, and performance tools perform honest local workflows instead of `console.log` + +## E2E Test Plan +- **Setup**: + - [x] Use the local Angular test server from Playwright `webServer` via `npm run serve:test`. + - [x] Use a test session with Ops and orchestration scopes. +- **Core verification**: + - [x] Verify canonical JobEngine list and detail navigation. + - [x] Verify dead-letter queue replay remains usable on the canonical queue page. + - [x] Verify scheduler runs can open the new run-stream page. + - [x] Verify scanner-support actions surface honest completion notices. +- **Cutover verification**: + - [x] Verify Angular tests cover alias inventory and the repaired execution workflows. + - [x] Verify the production build still completes after the execution cutover. + +## Verification +- Run: + - `npm run test -- --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/scheduler_ops/scheduler-orchestrator-ops-ui.behavior.spec.ts --include src/tests/deadletter/deadletter-queue.component.spec.ts --include src/tests/deadletter/deadletter-entry-detail.component.spec.ts --include src/tests/scanner_ops/scanner-ops-settings-ui.behavior.spec.ts --include src/tests/scanner_ops/scanner-ops-supporting-flows.behavior.spec.ts --include src/tests/jobengine/jobengine-execution-ops.behavior.spec.ts` + - `npx playwright test tests/e2e/execution-operations-cutover.spec.ts --workers=1` + - `npm run build` +- Tier 0 (source): pass +- Tier 1 (build/tests): pass +- Tier 2 (behavior): pass +- Notes: + - Angular targeted tests passed: `7` files, `38` tests. + - Playwright passed: `1` scenario. + - Production build passed; existing initial bundle and setup-wizard style budget warnings remain unchanged from the baseline. +- Verified on (UTC): 2026-03-08T07:26:09.3020863Z + diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index b3c77d9cf..0fd65356d 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -8,6 +8,10 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows. +## Latest updates (2026-03-08) +- Shipped the execution-operations cutover for canonical JobEngine, Scheduler, Dead-Letter, and companion Scanner Ops workflows under `Ops > Operations`. +- Added checked-feature verification for execution operations at `../../features/checked/web/execution-operations-ui.md`. + ## Latest updates (2026-03-07) - Generated the first-pass UI component preservation map at `component-preservation-map/README.md`. - The preservation map currently tracks 303 candidate components: 167 high-confidence dead surfaces and 136 routed-but-weakly-surfaced surfaces. @@ -75,6 +79,7 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt - ./platform-ops-consolidation/README.md - ./offline-operations/README.md - ./quota-health-aoc-operations/README.md +- ./execution-operations/README.md - ./triage-explainability-workspace/README.md - ./workflow-visualization-replay/README.md - ./contextual-actions-patterns/README.md diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index e8e66e10b..db6c517b4 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -96,6 +96,10 @@ - [DONE] FE-QHA-002 Complete quota operator workflows - [DONE] FE-QHA-003 Complete health and AOC operator workflows - [DONE] FE-QHA-004 Verify cutover, sync docs, and archive +- [DONE] FE-EXO-001 Freeze canonical execution route and alias contract +- [DONE] FE-EXO-002 Complete JobEngine and scheduler operator workflows +- [DONE] FE-EXO-003 Complete dead-letter and scanner-ops supporting workflows +- [DONE] FE-EXO-004 Verify cutover, sync docs, and archive - [DONE] FE-PO-001 Freeze Operations overview taxonomy and submenu structure - [DONE] FE-PO-002 Overview page regrouping and blocking-card contract - [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops diff --git a/docs/modules/ui/execution-operations/README.md b/docs/modules/ui/execution-operations/README.md new file mode 100644 index 000000000..b3d920986 --- /dev/null +++ b/docs/modules/ui/execution-operations/README.md @@ -0,0 +1,75 @@ +# Execution Operations + +## Purpose +- Complete the execution cluster under the active UI instead of leaving JobEngine, Scheduler, Dead-Letter, and Scanner support flows split across stale aliases or half-wired pages. +- Keep operator workflows usable end to end: inspect jobs, open DAG context, manage quotas, replay dead-letter entries, follow scheduler runs, and finish scanner-support actions without console-only placeholders. + +## Canonical Owner +- Owner shell: `Ops > Operations` +- Primary routes: + - `/ops/operations/jobs-queues` + - `/ops/operations/jobengine` + - `/ops/operations/jobengine/jobs` + - `/ops/operations/jobengine/jobs/:jobId` + - `/ops/operations/jobengine/jobs/:jobId/dag` + - `/ops/operations/jobengine/quotas` + - `/ops/operations/dead-letter` + - `/ops/operations/dead-letter/queue` + - `/ops/operations/dead-letter/entry/:entryId` + - `/ops/operations/scheduler` + - `/ops/operations/scheduler/runs` + - `/ops/operations/scheduler/runs/:runId/stream` + - `/ops/operations/scheduler/schedules` + - `/ops/operations/scheduler/workers` +- Companion execution tools: + - `/ops/scanner-ops` + - `/ops/scanner-ops/offline-kits` + - `/ops/scanner-ops/baselines` + - `/ops/scanner-ops/analyzers` + - `/ops/scanner-ops/performance` + - `/ops/scanner-ops/settings` + +## Legacy Alias Policy +- Preserve stale bookmarks and older navigation entry points by redirecting: + - `/ops/jobengine/*` + - `/ops/scheduler/*` + - `/ops/scanner` + - `/ops/scanner/:page` + - `/platform-ops/jobengine/*` + - `/platform-ops/scheduler/*` + - `/platform-ops/scanner*` + - `/platform/ops/jobengine/*` + - `/platform/ops/scheduler/*` + - `/platform/ops/dead-letter/*` +- Redirects must preserve query params and fragments because job, queue, and stream pages use route-backed detail state. + +## 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. +- `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. +- `Scanner Ops` remains scanner-owned, but its supporting actions must be honest and usable because it is part of the same operator journey. + +## Shipped In This Cut +- Repaired canonical route helpers, navigation targets, and legacy aliases for JobEngine, Scheduler, Dead-Letter, and Scanner Ops entry points. +- Replaced placeholder JobEngine dashboards with working summary, list, detail, DAG, and quota flows backed by the existing clients. +- Added a route-backed Scheduler Run Stream page and kept scheduler schedules and worker-fleet links inside the canonical execution subtree. +- Completed dead-letter batch replay, batch resolve, export, entry-detail replay handoff, and canonical job deep links. +- Replaced scanner-support `console.log` actions with honest local verification, export, promote, and refresh flows. + +## Preserved Value +- Keep: + - execution queue visibility and job DAG drill-in + - quota operations and export snapshots + - dead-letter replay and manual resolution + - scheduler run stream visibility + - scanner baseline, analyzer, and offline-kit support actions +- Why: + - these are not abandoned concepts; they are real operator workflows that were left partially unwired after the Operations-shell consolidation + +## Related Docs +- `docs/modules/ui/platform-ops-consolidation/README.md` +- `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/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index f4bf36308..fcd92b42e 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -30,10 +30,12 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/features/checked/web/unified-audit-surfaces-ui.md` - shipped verification note for the Evidence-owned audit shell, admin bookmark redirects, repaired audit subview links, and secondary handoff entry points. - `docs/features/checked/web/offline-operations-ui.md` - shipped verification note for the canonical Offline Kit and Feeds & Airgap owner routes, repaired stale aliases, and completed offline shell actions. - `docs/features/checked/web/quota-health-aoc-operations-ui.md` - shipped verification note for canonical quota, health, and AOC owner routes, repaired deep links, route-backed filters, and completed operator actions. +- `docs/features/checked/web/execution-operations-ui.md` - shipped verification note for canonical execution routes, repaired jobengine and scheduler aliases, completed dead-letter actions, and usable scanner-support workflows. - `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract. - `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan. - `docs/modules/ui/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy. - `docs/modules/ui/quota-health-aoc-operations/README.md` - canonical owner-shell contract for quota, health, and AOC operations cutover plus alias and action rules. +- `docs/modules/ui/execution-operations/README.md` - canonical execution owner-shell contract for JobEngine, Scheduler, Dead-Letter, and companion Scanner Ops workflows. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. - `docs/modules/ui/workflow-visualization-replay/README.md` - detailed run-detail graph, timeline, replay, and evidence UX dossier. - `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs. diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 4fc5e6e4f..bbad2ab1f 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -174,6 +174,17 @@ export const routes: Routes = [ path: 'platform-ops', loadChildren: () => import('./routes/platform-ops.routes').then((m) => m.PLATFORM_OPS_ROUTES), }, + { + path: 'jobengine', + children: [ + { path: '', redirectTo: '/ops/operations/jobengine', pathMatch: 'full' }, + { path: 'jobs', redirectTo: '/ops/operations/jobengine/jobs', pathMatch: 'full' }, + { path: 'jobs/:jobId', redirectTo: '/ops/operations/jobengine/jobs/:jobId', pathMatch: 'full' }, + { path: 'jobs/:jobId/dag', redirectTo: '/ops/operations/jobengine/jobs/:jobId/dag', pathMatch: 'full' }, + { path: 'quotas', redirectTo: '/ops/operations/jobengine/quotas', pathMatch: 'full' }, + { path: '**', redirectTo: '/ops/operations/jobengine' }, + ], + }, { path: 'platform', children: [ diff --git a/src/Web/StellaOps.Web/src/app/core/api/deadletter.models.ts b/src/Web/StellaOps.Web/src/app/core/api/deadletter.models.ts index 7bdeed2f9..e29917866 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/deadletter.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/deadletter.models.ts @@ -196,9 +196,9 @@ export const ERROR_CODE_REFERENCES: Record = { 'Network latency between services', ], resolutionSteps: [ - 'Check Scanner service health: /ops/scanner', + 'Check Scanner service health: /ops/scanner-ops', 'Verify artifact size (large images may need timeout increase)', - 'Review queue depth: /ops/scheduler', + 'Review queue depth: /ops/operations/scheduler', 'If service healthy, retry with extended timeout', ], relatedDocs: [ diff --git a/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts b/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts new file mode 100644 index 000000000..62918c81c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts @@ -0,0 +1,272 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AuthSessionStore } from '../auth/auth-session.store'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { JOBENGINE_API_BASE_URL } from './jobengine.client'; +import { generateTraceId } from './trace.util'; + +export interface JobEngineJobsQuery { + readonly tenantId?: string; + readonly projectId?: string; + readonly traceId?: string; + readonly status?: string; + readonly jobType?: string; + readonly createdAfter?: string; + readonly createdBefore?: string; + readonly limit?: number; + readonly cursor?: string; +} + +export interface JobEngineJobRecord { + readonly tenantId: string; + readonly projectId?: string | null; + readonly jobId: string; + readonly runId?: string | null; + readonly jobType: string; + readonly status: string; + readonly priority: number; + readonly attempt: number; + readonly maxAttempts: number; + readonly correlationId?: string | null; + readonly workerId?: string | null; + readonly taskRunnerId?: string | null; + readonly createdAt: string; + readonly scheduledAt?: string | null; + readonly leasedAt?: string | null; + readonly completedAt?: string | null; + readonly notBefore?: string | null; + readonly reason?: string | null; + readonly replayOf?: string | null; + readonly createdBy: string; +} + +export interface JobEngineJobDetail extends JobEngineJobRecord { + readonly payloadDigest: string; + readonly payload: string; + readonly idempotencyKey: string; + readonly leaseId?: string | null; + readonly leaseUntil?: string | null; +} + +export interface JobEngineDagEdge { + readonly edgeId: string; + readonly runId: string; + readonly parentJobId: string; + readonly childJobId: string; + readonly edgeType: string; + readonly createdAt: string; +} + +export interface JobEngineJobListResult { + readonly jobs: readonly JobEngineJobRecord[]; + readonly nextCursor: string | null; +} + +interface JobEngineJobListResponse { + readonly jobs?: readonly JobEngineJobApiRecord[]; + readonly nextCursor?: string | null; +} + +interface JobEngineDagEdgeListResponse { + readonly edges?: readonly JobEngineDagEdgeApiRecord[]; +} + +interface JobEngineJobApiRecord { + readonly tenantId?: string; + readonly projectId?: string | null; + readonly jobId?: string; + readonly runId?: string | null; + readonly jobType?: string; + readonly status?: string; + readonly priority?: number; + readonly attempt?: number; + readonly maxAttempts?: number; + readonly correlationId?: string | null; + readonly workerId?: string | null; + readonly taskRunnerId?: string | null; + readonly createdAt?: string; + readonly scheduledAt?: string | null; + readonly leasedAt?: string | null; + readonly completedAt?: string | null; + readonly notBefore?: string | null; + readonly reason?: string | null; + readonly replayOf?: string | null; + readonly createdBy?: string; +} + +interface JobEngineJobDetailApiRecord extends JobEngineJobApiRecord { + readonly payloadDigest?: string; + readonly payload?: string; + readonly idempotencyKey?: string; + readonly leaseId?: string | null; + readonly leaseUntil?: string | null; +} + +interface JobEngineDagEdgeApiRecord { + readonly edgeId?: string; + readonly runId?: string; + readonly parentJobId?: string; + readonly childJobId?: string; + readonly edgeType?: string; + readonly createdAt?: string; +} + +@Injectable({ providedIn: 'root' }) +export class JobEngineJobsClient { + private static readonly MAX_PAGE_SIZE = 200; + + constructor( + private readonly http: HttpClient, + private readonly authSession: AuthSessionStore, + private readonly tenantService: TenantActivationService, + @Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string, + ) {} + + listJobs(query: JobEngineJobsQuery = {}): Observable { + const tenant = this.resolveTenant(query.tenantId); + const traceId = query.traceId ?? generateTraceId(); + + let params = new HttpParams(); + if (query.status) params = params.set('status', query.status); + if (query.jobType) params = params.set('jobType', query.jobType); + if (query.projectId) params = params.set('projectId', query.projectId); + if (query.createdAfter) params = params.set('createdAfter', query.createdAfter); + if (query.createdBefore) params = params.set('createdBefore', query.createdBefore); + if (query.limit) params = params.set('limit', String(Math.min(query.limit, JobEngineJobsClient.MAX_PAGE_SIZE))); + if (query.cursor) params = params.set('cursor', query.cursor); + + this.tenantService.authorize('jobengine', 'read', ['orch:read'], query.projectId, traceId); + + return this.http + .get(`${this.baseUrl}/jobengine/jobs`, { + params, + headers: this.buildHeaders(tenant, traceId, query.projectId), + }) + .pipe( + map((response) => ({ + jobs: (response.jobs ?? []).map((job) => this.mapJob(job)), + nextCursor: response.nextCursor ?? null, + })), + ); + } + + getJob(jobId: string, query: Pick = {}): Observable { + const tenant = this.resolveTenant(query.tenantId); + const traceId = query.traceId ?? generateTraceId(); + + this.tenantService.authorize('jobengine', 'read', ['orch:read'], query.projectId, traceId); + + return this.http + .get(`${this.baseUrl}/jobengine/jobs/${encodeURIComponent(jobId)}`, { + headers: this.buildHeaders(tenant, traceId, query.projectId), + }) + .pipe(map((response) => this.mapJob(response))); + } + + getJobDetail(jobId: string, query: Pick = {}): Observable { + const tenant = this.resolveTenant(query.tenantId); + const traceId = query.traceId ?? generateTraceId(); + + this.tenantService.authorize('jobengine', 'read', ['orch:read'], query.projectId, traceId); + + return this.http + .get(`${this.baseUrl}/jobengine/jobs/${encodeURIComponent(jobId)}/detail`, { + headers: this.buildHeaders(tenant, traceId, query.projectId), + }) + .pipe(map((response) => this.mapJobDetail(response))); + } + + getJobParents(jobId: string, query: Pick = {}): Observable { + return this.getJobEdges(`job/${encodeURIComponent(jobId)}/parents`, query); + } + + getJobChildren(jobId: string, query: Pick = {}): Observable { + return this.getJobEdges(`job/${encodeURIComponent(jobId)}/children`, query); + } + + private getJobEdges( + relativePath: string, + query: Pick, + ): Observable { + const tenant = this.resolveTenant(query.tenantId); + const traceId = query.traceId ?? generateTraceId(); + + this.tenantService.authorize('jobengine', 'read', ['orch:read'], query.projectId, traceId); + + return this.http + .get(`${this.baseUrl}/jobengine/dag/${relativePath}`, { + headers: this.buildHeaders(tenant, traceId, query.projectId), + }) + .pipe(map((response) => (response.edges ?? []).map((edge) => this.mapEdge(edge)))); + } + + private mapJob(job: JobEngineJobApiRecord): JobEngineJobRecord { + return { + tenantId: job.tenantId ?? '', + projectId: job.projectId ?? null, + jobId: job.jobId ?? '', + runId: job.runId ?? null, + jobType: job.jobType ?? 'unknown', + status: (job.status ?? 'pending').toLowerCase(), + priority: job.priority ?? 0, + attempt: job.attempt ?? 0, + maxAttempts: job.maxAttempts ?? 0, + correlationId: job.correlationId ?? null, + workerId: job.workerId ?? null, + taskRunnerId: job.taskRunnerId ?? null, + createdAt: job.createdAt ?? new Date().toISOString(), + scheduledAt: job.scheduledAt ?? null, + leasedAt: job.leasedAt ?? null, + completedAt: job.completedAt ?? null, + notBefore: job.notBefore ?? null, + reason: job.reason ?? null, + replayOf: job.replayOf ?? null, + createdBy: job.createdBy ?? 'system', + }; + } + + private mapJobDetail(job: JobEngineJobDetailApiRecord): JobEngineJobDetail { + return { + ...this.mapJob(job), + payloadDigest: job.payloadDigest ?? '', + payload: job.payload ?? '', + idempotencyKey: job.idempotencyKey ?? '', + leaseId: job.leaseId ?? null, + leaseUntil: job.leaseUntil ?? null, + }; + } + + private mapEdge(edge: JobEngineDagEdgeApiRecord): JobEngineDagEdge { + return { + edgeId: edge.edgeId ?? '', + runId: edge.runId ?? '', + parentJobId: edge.parentJobId ?? '', + childJobId: edge.childJobId ?? '', + edgeType: edge.edgeType ?? 'dependency', + createdAt: edge.createdAt ?? new Date().toISOString(), + }; + } + + private resolveTenant(tenantId?: string): string { + const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); + return tenant ?? ''; + } + + private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders { + let headers = new HttpHeaders({ + 'Content-Type': 'application/json', + 'X-StellaOps-Tenant': tenantId, + 'X-Stella-Trace-Id': traceId, + 'X-Stella-Request-Id': traceId, + }); + + if (projectId) { + headers = headers.set('X-Stella-Project', projectId); + } + + return headers; + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 3d4eb8f6e..776ad8c5a 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -1,4 +1,9 @@ -import { OPERATIONS_PATHS, aocPath } from '../../features/platform/ops/operations-paths'; +import { + OPERATIONS_PATHS, + SCANNER_OPS_ROOT, + aocPath, + deadLetterQueuePath, +} from '../../features/platform/ops/operations-paths'; import { NavGroup, NavigationConfig } from './navigation.types'; /** @@ -195,7 +200,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'jobengine', label: 'Jobs & Orchestration', - route: '/jobengine', + route: OPERATIONS_PATHS.jobsQueues, icon: 'workflow', tooltip: 'View and manage orchestration jobs', }, @@ -280,20 +285,20 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'dead-letter', label: 'Dead-Letter Queue', - route: '/ops/jobengine/dead-letter', + route: OPERATIONS_PATHS.deadLetter, icon: 'alert-triangle', tooltip: 'Failed job recovery, replay, and resolution workflows', children: [ { id: 'dlq-dashboard', label: 'Dashboard', - route: '/ops/jobengine/dead-letter', + route: OPERATIONS_PATHS.deadLetter, tooltip: 'Queue statistics and error distribution', }, { id: 'dlq-queue', label: 'Queue Browser', - route: '/ops/jobengine/dead-letter/queue', + route: deadLetterQueuePath(), tooltip: 'Browse and filter dead-letter entries', }, ], @@ -328,20 +333,20 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'platform-health', label: 'Platform Health', - route: '/ops/health', + route: OPERATIONS_PATHS.healthSlo, icon: 'heart-pulse', tooltip: 'Unified service health and dependency monitoring', children: [ { id: 'health-dashboard', label: 'Dashboard', - route: '/ops/health', + route: OPERATIONS_PATHS.healthSlo, tooltip: 'Service health overview and status', }, { id: 'health-incidents', label: 'Incidents', - route: '/ops/health/incidents', + route: `${OPERATIONS_PATHS.healthSlo}/incidents`, tooltip: 'Incident timeline with correlation', }, ], @@ -625,7 +630,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'scanner-ops', label: 'Scanner Ops', - route: '/ops/scanner', + route: SCANNER_OPS_ROOT, icon: 'scan', tooltip: 'Scanner offline kits, baselines, and determinism settings', }, diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts index 6d3803bde..77c91a1e9 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; +import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; type GateResult = 'PASS' | 'WARN' | 'BLOCK'; type HealthStatus = 'OK' | 'WARN' | 'FAIL'; @@ -399,8 +400,8 @@ interface HistoryEvent { } @@ -786,6 +787,7 @@ interface HistoryEvent { ], }) export class ApprovalDetailPageComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts index bce0090c4..d9e46e8dc 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts @@ -1,7 +1,7 @@ // Sprint: SPRINT_20251229_030_FE - Dead-Letter Management UI import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterModule, ActivatedRoute } from '@angular/router'; +import { RouterModule, ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { Subject, takeUntil, switchMap, of, forkJoin } from 'rxjs'; import { DeadLetterClient } from '../../core/api/deadletter.client'; @@ -12,6 +12,7 @@ import { ErrorCodeReference, ResolutionReason, } from '../../core/api/deadletter.models'; +import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operations-paths'; @Component({ selector: 'app-deadletter-entry-detail', @@ -20,7 +21,7 @@ import {
+ + @if (actionError()) { + + } + + @if (notice()) { +
{{ notice() }}
+ } @if (loading()) {
@@ -75,7 +84,12 @@ import { } @if (entry()?.state === 'replayed') { - New Job: {{ entry()?.replayedJobId }} + New Job: + @if (replayedJobPath(); as replayedPath) { + {{ entry()?.replayedJobId }} + } @else { + {{ entry()?.replayedJobId }} + } }
@@ -249,7 +263,7 @@ import { @if (!loading() && !entry()) {

Entry not found

- Return to queue + Return to queue
} @@ -555,6 +569,24 @@ import { .loading-state, .empty-state { padding: 3rem; text-align: center; color: var(--color-text-secondary); } + .action-banner { + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + margin-bottom: 1rem; + } + + .action-banner--error { + background: var(--color-error-bg); + color: var(--color-error); + border: 1px solid var(--color-error); + } + + .action-banner--info { + background: var(--color-info-bg); + color: var(--color-info); + border: 1px solid var(--color-info); + } + @media (max-width: 1024px) { .detail-grid { grid-template-columns: 1fr; } .payload-section, .diagnostics-section { grid-column: span 1; } @@ -562,8 +594,10 @@ import { `] }) export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { + protected readonly deadLetterQueuePath = deadLetterQueuePath; private readonly client = inject(DeadLetterClient); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly destroy$ = new Subject(); readonly loading = signal(false); @@ -573,6 +607,8 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { readonly showStackTrace = signal(false); readonly showReplay = signal(false); readonly showResolve = signal(false); + readonly notice = signal(null); + readonly actionError = signal(null); replayOptions = { useOriginalParams: true, extendTimeout: undefined as number | undefined, priority: 'normal' as const }; resolveReason: ResolutionReason | '' = ''; @@ -585,6 +621,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { const entryId = params.get('entryId'); if (!entryId) return of(null); this.loading.set(true); + this.actionError.set(null); return forkJoin({ entry: this.client.getEntry(entryId), audit: this.client.getAuditHistory(entryId), @@ -602,6 +639,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { this.loading.set(false); }, error: () => { + this.actionError.set('Dead-letter entry details could not be loaded.'); this.loading.set(false); }, }); @@ -648,14 +686,34 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { const entry = this.entry(); if (!entry) return; + this.notice.set(null); + this.actionError.set(null); this.client.replay(entry.id, this.replayOptions) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (response) => { this.hideReplayDialog(); if (response.success && response.newJobId) { - window.location.href = `/platform-ops/jobengine/jobs/${response.newJobId}`; + this.entry.set({ + ...entry, + state: 'replayed', + replayedJobId: response.newJobId, + updatedAt: new Date().toISOString(), + }); + this.notice.set(`Replay queued as JobEngine job ${response.newJobId}.`); + void this.router.navigateByUrl(jobEngineJobPath(response.newJobId)); + return; } + + if (response.success) { + this.notice.set('Replay queued. JobEngine did not return a new job identifier.'); + return; + } + + this.actionError.set(response.error ?? 'Replay could not be queued.'); + }, + error: () => { + this.actionError.set('Replay could not be queued.'); }, }); } @@ -663,16 +721,27 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { confirmResolve(): void { const entry = this.entry(); if (!entry || !this.resolveReason) return; + const resolutionReason = this.resolveReason as ResolutionReason; + this.notice.set(null); + this.actionError.set(null); this.client.resolve(entry.id, { - reason: this.resolveReason as ResolutionReason, + reason: resolutionReason, notes: this.resolveNotes, }) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (updated) => { - this.entry.set(updated); + this.entry.set({ + ...updated, + resolutionReason, + resolutionNotes: this.resolveNotes || updated.resolutionNotes, + }); this.hideResolveDialog(); + this.notice.set(`Entry ${updated.id} was resolved as ${this.formatResolutionReason(resolutionReason)}.`); + }, + error: () => { + this.actionError.set('Entry resolution failed.'); }, }); } @@ -680,7 +749,8 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { copyPayload(): void { const payload = this.entry()?.payload; if (payload) { - navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + void navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + this.notice.set('Copied the entry payload.'); } } @@ -696,6 +766,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { a.download = `deadletter-${entry.id}.json`; a.click(); URL.revokeObjectURL(url); + this.notice.set(`Downloaded entry ${entry.id}.`); } getAge(): number { @@ -739,4 +810,9 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy { }; return labels[reason] || reason; } + + replayedJobPath(): string | null { + const replayedJobId = this.entry()?.replayedJobId; + return replayedJobId ? jobEngineJobPath(replayedJobId) : null; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts index 80fa16251..790529e2c 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts @@ -3,15 +3,16 @@ import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs'; +import { Subject, takeUntil, debounceTime, forkJoin, of, catchError, map } from 'rxjs'; import { DeadLetterClient } from '../../core/api/deadletter.client'; import { DeadLetterEntrySummary, DeadLetterFilter, - DeadLetterState, ErrorCode, ERROR_CODE_REFERENCES, + type ReplayResponse, } from '../../core/api/deadletter.models'; +import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operations-paths'; @Component({ selector: 'app-deadletter-queue', @@ -20,7 +21,7 @@ import {
+ + @if (actionError()) { + + } + + @if (notice()) { + + }
@@ -174,7 +183,7 @@ import { /> - + {{ entry.id.substring(0, 12) }}... @@ -192,7 +201,7 @@ import { {{ entry.state }} - View + View } @@ -264,6 +273,24 @@ import { color: var(--color-text-secondary); } + .banner { + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + margin-bottom: 1rem; + } + + .banner--error { + background: var(--color-error-bg); + color: var(--color-error); + border: 1px solid var(--color-error); + } + + .banner--info { + background: var(--color-info-bg); + color: var(--color-info); + border: 1px solid var(--color-info); + } + .header-actions { display: flex; gap: 0.5rem; @@ -469,16 +496,21 @@ import { `] }) export class DeadLetterQueueComponent implements OnInit, OnDestroy { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + protected readonly deadLetterEntryPath = deadLetterEntryPath; private readonly client = inject(DeadLetterClient); private readonly destroy$ = new Subject(); private readonly filterSubject = new Subject(); readonly loading = signal(false); + readonly notice = signal(null); + readonly actionError = signal(null); readonly entries = signal([]); readonly totalEntries = signal(0); readonly selectedIds = signal([]); - private cursor: string | undefined; - private prevCursors: string[] = []; + private currentCursor: string | undefined; + private nextCursor: string | undefined; + private prevCursors: Array = []; filter: DeadLetterFilter = {}; sortField = 'age'; @@ -494,11 +526,11 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { ); readonly hasPrev = () => this.prevCursors.length > 0; - readonly hasNext = () => !!this.cursor; + readonly hasNext = () => !!this.nextCursor; ngOnInit(): void { this.filterSubject - .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .pipe(debounceTime(300), takeUntil(this.destroy$)) .subscribe(() => this.applyFilters()); this.loadData(); @@ -514,7 +546,8 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { } applyFilters(): void { - this.cursor = undefined; + this.currentCursor = undefined; + this.nextCursor = undefined; this.prevCursors = []; this.loadData(); } @@ -536,32 +569,36 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { loadData(): void { this.loading.set(true); + this.actionError.set(null); - this.client.list(this.filter, 50, this.cursor) + this.client.list(this.filter, 50, this.currentCursor) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (response) => { - this.entries.set(response.items); - this.totalEntries.set(response.total); - this.cursor = response.cursor; + const filtered = this.applyLocalFilters(response.items); + this.entries.set(filtered); + this.totalEntries.set(this.usesClientSideFiltering() ? filtered.length : response.total); + this.nextCursor = response.cursor; this.loading.set(false); }, error: () => { + this.actionError.set('Dead-letter entries could not be loaded. Existing filters remain available.'); this.loading.set(false); }, }); } nextPage(): void { - if (this.cursor) { - this.prevCursors.push(this.cursor); + if (this.nextCursor) { + this.prevCursors.push(this.currentCursor); + this.currentCursor = this.nextCursor; this.loadData(); } } prevPage(): void { if (this.prevCursors.length) { - this.cursor = this.prevCursors.pop(); + this.currentCursor = this.prevCursors.pop(); this.loadData(); } } @@ -588,16 +625,82 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { } replaySelected(): void { - console.log('Replay selected:', this.selectedIds()); - // Implement batch replay + const entryIds = this.selectedIds(); + if (!entryIds.length) { + return; + } + + this.loading.set(true); + this.notice.set(null); + this.actionError.set(null); + + forkJoin( + entryIds.map((entryId) => + this.client.replay(entryId, { useOriginalParams: true }).pipe( + map((response) => ({ entryId, response })), + catchError(() => + of({ + entryId, + response: { + success: false, + error: 'Replay request failed before JobEngine accepted it.', + } satisfies ReplayResponse, + }), + ), + ), + ), + ) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (results) => { + const succeeded = results.filter((result) => result.response.success).length; + const failed = results.length - succeeded; + this.notice.set( + failed === 0 + ? `Queued replay for ${succeeded} selected entr${succeeded === 1 ? 'y' : 'ies'}.` + : `Queued replay for ${succeeded} entries; ${failed} failed and require manual review.`, + ); + this.clearSelection(); + this.loadData(); + }, + error: () => { + this.actionError.set('Bulk replay could not be completed.'); + this.loading.set(false); + }, + }); } resolveSelected(): void { - console.log('Resolve selected:', this.selectedIds()); - // Implement batch resolve + const entryIds = this.selectedIds(); + if (!entryIds.length) { + return; + } + + this.loading.set(true); + this.notice.set(null); + this.actionError.set(null); + + this.client + .batchResolve(entryIds, { + reason: 'manual_fix', + notes: 'Resolved from the dead-letter queue browser bulk action.', + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (result) => { + this.notice.set(`Resolved ${result.resolved} selected entr${result.resolved === 1 ? 'y' : 'ies'}.`); + this.clearSelection(); + this.loadData(); + }, + error: () => { + this.actionError.set('Bulk resolve could not be completed.'); + this.loading.set(false); + }, + }); } exportData(): void { + this.notice.set(null); this.client.export(this.filter) .pipe(takeUntil(this.destroy$)) .subscribe({ @@ -608,6 +711,10 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { a.download = `deadletter-queue-${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); + this.notice.set('Downloaded the filtered dead-letter queue export.'); + }, + error: () => { + this.actionError.set('Dead-letter queue export failed.'); }, }); } @@ -623,4 +730,86 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy { if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; } + + private usesClientSideFiltering(): boolean { + return Boolean( + this.filter.search?.trim() || + this.filter.tenantId?.trim() || + this.filter.jobType?.trim() || + this.filter.dateFrom || + this.filter.dateTo || + this.filter.olderThanHours, + ); + } + + private applyLocalFilters(entries: readonly DeadLetterEntrySummary[]): DeadLetterEntrySummary[] { + const search = this.filter.search?.trim().toLowerCase() ?? ''; + const tenant = this.filter.tenantId?.trim().toLowerCase() ?? ''; + const jobType = this.filter.jobType?.trim().toLowerCase() ?? ''; + const dateFrom = this.filter.dateFrom ? Date.parse(this.filter.dateFrom) : null; + const dateTo = this.filter.dateTo ? Date.parse(this.filter.dateTo) : null; + const olderThanSeconds = this.filter.olderThanHours ? this.filter.olderThanHours * 3600 : null; + + return entries + .filter((entry) => !tenant || entry.tenantId.toLowerCase().includes(tenant) || entry.tenantName.toLowerCase().includes(tenant)) + .filter((entry) => !jobType || entry.jobType.toLowerCase().includes(jobType)) + .filter((entry) => + !search || + entry.id.toLowerCase().includes(search) || + entry.jobId.toLowerCase().includes(search) || + entry.errorMessage.toLowerCase().includes(search), + ) + .filter((entry) => (olderThanSeconds === null ? true : entry.age >= olderThanSeconds)) + .filter((entry) => { + if (dateFrom === null && dateTo === null) { + return true; + } + + const createdAt = Date.parse(entry.createdAt); + if (Number.isNaN(createdAt)) { + return true; + } + + if (dateFrom !== null && createdAt < dateFrom) { + return false; + } + + if (dateTo !== null && createdAt > dateTo + (24 * 60 * 60 * 1000) - 1) { + return false; + } + + return true; + }) + .slice() + .sort((left, right) => this.compareEntries(left, right)); + } + + private compareEntries(left: DeadLetterEntrySummary, right: DeadLetterEntrySummary): number { + const direction = this.sortDir === 'asc' ? 1 : -1; + const readField = (entry: DeadLetterEntrySummary): string | number => { + switch (this.sortField) { + case 'id': + return entry.id; + case 'jobId': + return entry.jobId; + case 'errorCode': + return entry.errorCode; + case 'tenantName': + return entry.tenantName; + case 'age': + default: + return entry.age; + } + }; + + const leftValue = readField(left); + const rightValue = readField(right); + + if (typeof leftValue === 'number' && typeof rightValue === 'number') { + return (leftValue - rightValue) * direction; + } + + const compare = String(leftValue).localeCompare(String(rightValue)); + return compare * direction; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts index 92e2ba341..a1ce9e0cf 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts @@ -1,172 +1,339 @@ -import { Component, inject } from '@angular/core'; - +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { catchError, forkJoin, of } from 'rxjs'; import { AUTH_SERVICE, AuthService } from '../../core/auth'; +import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client'; +import type { + JobEngineDeadLetterStatsResponse, + JobEngineJobSummary, + JobEngineQuotaSummary, +} from '../../core/api/jobengine-control.models'; +import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; -/** - * JobEngine Dashboard - Main landing page for JobEngine features. - * Requires orch:read scope for access (gated by requireOrchViewerGuard). - * - * @see UI-ORCH-32-001 - */ @Component({ - selector: 'app-jobengine-dashboard', - imports: [RouterLink], - template: ` -
-
-

JobEngine Dashboard

-

- Monitor and manage orchestrated jobs, quotas, and backfill operations. -

+ selector: 'app-jobengine-dashboard', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ + ← Back to Jobs & Queues + +

JobEngine

+

Execution queues, quotas, dead-letter recovery, and scheduler handoffs.

+
+
+ Scheduler Runs + Dead-Letter + +
- + +

Execution Quotas

+

Manage per-job-type concurrency, refill rate, and pause state.

+
+
+
Average Token Usage
+
{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}
+
+
+
Average Concurrency Usage
+
{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}
+
+
+
Paused Quotas
+
{{ quotaSummary()?.pausedQuotas ?? 0 }}
+
+
+
-
-

Your JobEngine Access

+ +

Dead-Letter Recovery

+

Retry or resolve failed execution records and inspect replay outcomes.

+
+
+
Retryable
+
{{ deadLetterStats()?.retryableEntries ?? 0 }}
+
+
+
Replayed
+
{{ deadLetterStats()?.replayedEntries ?? 0 }}
+
+
+
Resolved
+
{{ deadLetterStats()?.resolvedEntries ?? 0 }}
+
+
+
+
+ +
+

Your Access

    -
  • - View Jobs: - {{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }} -
  • -
  • - Operate: - {{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }} -
  • -
  • - Manage Quotas: - {{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }} -
  • -
  • - Initiate Backfill: - {{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }} -
  • +
  • View Jobs: {{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}
  • +
  • Operate Jobs: {{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}
  • +
  • Manage Quotas: {{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }}
  • +
  • Initiate Backfill: {{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}
`, - styles: [` - .orch-dashboard { + styles: [` + .jobengine-dashboard { max-width: 1200px; margin: 0 auto; padding: 2rem; + display: grid; + gap: 1.5rem; } - .orch-dashboard__header { - margin-bottom: 2rem; + .jobengine-dashboard__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; } - .orch-dashboard__title { - margin: 0 0 0.5rem; - font-size: 1.75rem; - font-weight: var(--font-weight-semibold); + .jobengine-dashboard__header h1 { + margin: 0 0 0.35rem; + font-size: 1.8rem; color: var(--color-text-heading); } - .orch-dashboard__description { + .jobengine-dashboard__header p { margin: 0; color: var(--color-text-secondary); } - .orch-dashboard__nav { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; + .jobengine-dashboard__back { + display: inline-block; + margin-bottom: 0.65rem; + color: var(--color-status-info); + text-decoration: none; } - .orch-dashboard__card { + .jobengine-dashboard__actions { display: flex; - flex-direction: column; - padding: 1.5rem; - background: var(--color-surface-primary); + gap: 0.75rem; + flex-wrap: wrap; + } + + .jobengine-dashboard__banner { + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + } + + .jobengine-dashboard__banner--error { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border: 1px solid var(--color-status-error-border); + } + + .jobengine-dashboard__kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + } + + .kpi, + .surface { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - text-decoration: none; - transition: box-shadow 0.15s, border-color 0.15s; - - &:hover { - border-color: var(--color-status-info); - box-shadow: var(--shadow-md); - } - - &:focus { - outline: 2px solid var(--color-status-info); - outline-offset: 2px; - } + background: var(--color-surface-primary); + padding: 1.1rem; } - .orch-dashboard__card-icon { + .kpi { + display: grid; + gap: 0.2rem; + } + + .kpi__label { + color: var(--color-text-secondary); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .kpi__value { font-size: 2rem; - margin-bottom: 0.5rem; - } - - .orch-dashboard__card-title { - font-size: 1.125rem; - font-weight: var(--font-weight-semibold); color: var(--color-text-heading); - margin-bottom: 0.25rem; } - .orch-dashboard__card-desc { - font-size: 0.875rem; + .kpi__hint { + color: var(--color-text-secondary); + font-size: 0.85rem; + } + + .jobengine-dashboard__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + } + + .surface { + color: inherit; + text-decoration: none; + display: grid; + gap: 0.85rem; + } + + .surface h2 { + margin: 0; + font-size: 1.1rem; + color: var(--color-text-heading); + } + + .surface p { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.5; + } + + .surface dl { + margin: 0; + display: grid; + gap: 0.55rem; + } + + .surface dl div { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.9rem; + } + + .surface dt { color: var(--color-text-secondary); } - .orch-dashboard__scope-info { - padding: 1.5rem; - background: var(--color-surface-primary); + .surface dd { + margin: 0; + color: var(--color-text-heading); + font-weight: var(--font-weight-semibold); + } + + .jobengine-dashboard__access ul { + margin: 0; + padding-left: 1.2rem; + color: var(--color-text-secondary); + display: grid; + gap: 0.35rem; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.9rem; + border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + text-decoration: none; + cursor: pointer; + } - h2 { - margin: 0 0 1rem; - font-size: 1rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - } - - ul { - margin: 0; - padding: 0; - list-style: none; - } - - li { - padding: 0.5rem 0; - font-size: 0.875rem; - color: var(--color-text-primary); - border-bottom: 1px solid var(--color-border-primary); - - &:last-child { - border-bottom: none; - } - - strong { - display: inline-block; - min-width: 140px; - color: var(--color-text-secondary); - } + @media (max-width: 960px) { + .jobengine-dashboard__header, + .jobengine-dashboard__kpis, + .jobengine-dashboard__grid { + grid-template-columns: 1fr; + display: grid; } } - `] + `], }) -export class JobEngineDashboardComponent { +export class JobEngineDashboardComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; protected readonly authService = inject(AUTH_SERVICE) as AuthService; + private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi; + + protected readonly loading = signal(false); + protected readonly loadError = signal(null); + protected readonly jobSummary = signal(null); + protected readonly quotaSummary = signal(null); + protected readonly deadLetterStats = signal(null); + ngOnInit(): void { + this.refresh(); + } + + protected refresh(): void { + this.loading.set(true); + this.loadError.set(null); + + forkJoin({ + jobSummary: this.controlApi.getJobSummary().pipe(catchError(() => of(null))), + quotaSummary: this.controlApi.getQuotaSummary().pipe(catchError(() => of(null))), + deadLetterStats: this.controlApi.getDeadLetterStats().pipe(catchError(() => of(null))), + }).subscribe({ + next: (result) => { + this.jobSummary.set(result.jobSummary); + this.quotaSummary.set(result.quotaSummary); + this.deadLetterStats.set(result.deadLetterStats); + if (!result.jobSummary && !result.quotaSummary && !result.deadLetterStats) { + this.loadError.set('Execution metrics are currently unavailable. Links remain usable.'); + } + this.loading.set(false); + }, + error: () => { + this.loadError.set('Execution metrics are currently unavailable. Links remain usable.'); + this.loading.set(false); + }, + }); + } + + protected quotaPercent(value: number | undefined): string { + return value === undefined ? '-' : `${Math.round(value * 100)}%`; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts index 77ee9c004..017635760 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts @@ -1,125 +1,425 @@ -import { Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Subject, forkJoin, of } from 'rxjs'; +import { catchError, switchMap, takeUntil } from 'rxjs/operators'; -import { RouterLink } from '@angular/router'; +import { + JobEngineJobsClient, + type JobEngineDagEdge, + type JobEngineJobDetail, +} from '../../core/api/jobengine-jobs.client'; +import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operations-paths'; -/** - * JobEngine Job Detail - Shows details for a specific job. - * Requires orch:read scope for access. - * - * @see UI-ORCH-32-001 - */ @Component({ - selector: 'app-jobengine-job-detail', - imports: [RouterLink], - template: ` -
-
- ← Back to Jobs -

Job Detail

-

ID: {{ jobId }}

-
+ selector: 'app-jobengine-job-detail', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ ← Back to Jobs +

{{ dagMode() ? 'Job DAG' : 'Job Detail' }}

+

{{ jobId() || '-' }}

+
+
+ @if (job()?.status === 'failed') { + Open Dead-Letter + } - Create immutable audit bundle + Create Audit Bundle +
-
-

Job details will be implemented when JobEngine API contract is finalized.

-

This page requires the orch:read scope.

-
+ @if (loadError()) { + + } + + @if (job(); as currentJob) { +
+
+ Status + {{ currentJob.status }} +
+
+ Attempts + {{ currentJob.attempt }} / {{ currentJob.maxAttempts }} +
+
+ Worker + {{ currentJob.workerId || '-' }} +
+
+ Run + {{ currentJob.runId || '-' }} +
+
+ +
+
+

Execution Metadata

+
+
Job Type
{{ currentJob.jobType }}
+
Priority
{{ currentJob.priority }}
+
Created By
{{ currentJob.createdBy }}
+
Created
{{ formatDateTime(currentJob.createdAt) }}
+
Scheduled
{{ formatDateTime(currentJob.scheduledAt) }}
+
Leased
{{ formatDateTime(currentJob.leasedAt) }}
+
Completed
{{ formatDateTime(currentJob.completedAt) }}
+
Not Before
{{ formatDateTime(currentJob.notBefore) }}
+
Lease ID
{{ currentJob.leaseId || '-' }}
+
Lease Until
{{ formatDateTime(currentJob.leaseUntil) }}
+
Correlation ID
{{ currentJob.correlationId || '-' }}
+
Idempotency Key
{{ currentJob.idempotencyKey || '-' }}
+
Payload Digest
{{ currentJob.payloadDigest || '-' }}
+
Replay Of
{{ currentJob.replayOf || '-' }}
+
+ @if (currentJob.reason) { +
+ Reason +

{{ currentJob.reason }}

+
+ } +
+ +
+

{{ dagMode() ? 'Dependency Edges' : 'Parent / Child Jobs' }}

+
+

Parents

+ @if (parents().length) { + + } @else { +

No parent edges recorded for this job.

+ } +
+
+

Children

+ @if (children().length) { +
    + @for (edge of children(); track edge.edgeId) { +
  • + {{ edge.childJobId }} + {{ edge.edgeType }} +
  • + } +
+ } @else { +

No child edges recorded for this job.

+ } +
+
+
+ +
+

Payload

+ @if (payloadJson(); as payload) { +
{{ payload }}
+ } @else { +

No payload body was returned for this job.

+ } +
+ } @else if (!loading()) { +
No job detail is available for this identifier.
+ }
`, - styles: [` - .orch-job-detail { + styles: [` + .jobengine-job-detail { max-width: 1200px; margin: 0 auto; padding: 2rem; + display: grid; + gap: 1rem; } - .orch-job-detail__header { - margin-bottom: 2rem; + .jobengine-job-detail__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; } - .orch-job-detail__back { - display: inline-block; - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--color-status-info); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - .orch-job-detail__title { - margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); + .jobengine-job-detail__header h1 { + margin: 0 0 0.35rem; color: var(--color-text-heading); } - .orch-job-detail__id { - margin: 0.25rem 0 0; - font-size: 0.875rem; + .jobengine-job-detail__header p { + margin: 0; color: var(--color-text-secondary); - font-family: monospace; + font-family: ui-monospace, monospace; } - .orch-job-detail__actions { - margin-top: 1rem; + .jobengine-job-detail__back { + display: inline-block; + margin-bottom: 0.65rem; + color: var(--color-status-info); + text-decoration: none; + } + + .jobengine-job-detail__actions, + .jobengine-job-detail__summary { display: flex; - justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; } - .orch-job-detail__btn { + .summary-card, + .surface { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + + .summary-card { + padding: 0.9rem 1rem; + display: grid; + gap: 0.2rem; + min-width: 150px; + } + + .summary-card span { + color: var(--color-text-secondary); + font-size: 0.78rem; + text-transform: uppercase; + } + + .summary-card strong { + color: var(--color-text-heading); + font-size: 1.1rem; + word-break: break-word; + } + + .jobengine-job-detail__grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 1rem; + } + + .surface { + padding: 1rem; + display: grid; + gap: 0.9rem; + } + + .surface h2, + .surface h3 { + margin: 0; + color: var(--color-text-heading); + } + + .surface p { + margin: 0; + color: var(--color-text-secondary); + } + + .surface dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem 1rem; + margin: 0; + } + + .surface dl div { + display: grid; + gap: 0.15rem; + } + + .surface dt { + color: var(--color-text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + } + + .surface dd { + margin: 0; + color: var(--color-text-heading); + font-family: ui-monospace, monospace; + word-break: break-word; + } + + .edge-section { + display: grid; + gap: 0.5rem; + } + + .edge-section ul { + margin: 0; + padding-left: 1rem; + display: grid; + gap: 0.35rem; + } + + .edge-section li { + color: var(--color-text-secondary); + display: flex; + gap: 0.55rem; + flex-wrap: wrap; + } + + .edge-section a { + color: var(--color-status-info); + text-decoration: none; + font-family: ui-monospace, monospace; + } + + .jobengine-job-detail__reason { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + padding: 0.75rem; + } + + pre { + margin: 0; + overflow: auto; + padding: 0.9rem; + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; + } + + .btn { display: inline-flex; align-items: center; justify-content: center; - border-radius: var(--radius-lg); - padding: 0.5rem 0.85rem; - border: 1px solid var(--color-border-secondary); - background: var(--color-surface-primary); - color: var(--color-text-heading); + padding: 0.55rem 0.9rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); text-decoration: none; - font-weight: var(--font-weight-semibold); - - &:hover { - background: var(--color-surface-primary); - } + cursor: pointer; } - .orch-job-detail__placeholder { - padding: 3rem; - background: var(--color-surface-primary); - border: 1px dashed var(--color-border-secondary); - border-radius: var(--radius-lg); - text-align: center; - color: var(--color-text-secondary); - - p { - margin: 0.5rem 0; - } - - code { - background: var(--color-border-primary); - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); - font-size: 0.875rem; - } + .jobengine-job-detail__banner--error { + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border: 1px solid var(--color-status-error-border); } - .orch-job-detail__hint { - font-size: 0.875rem; - color: var(--color-text-muted); + @media (max-width: 960px) { + .jobengine-job-detail__header, + .jobengine-job-detail__grid, + .surface dl { + grid-template-columns: 1fr; + display: grid; + } } - `] + `], }) -export class JobEngineJobDetailComponent { - @Input() jobId: string = ''; +export class JobEngineJobDetailComponent implements OnInit, OnDestroy { + protected readonly jobEngineJobPath = jobEngineJobPath; + protected readonly deadLetterQueuePath = deadLetterQueuePath; + private readonly route = inject(ActivatedRoute); + private readonly jobsClient = inject(JobEngineJobsClient); + private readonly destroy$ = new Subject(); + + protected readonly loading = signal(false); + protected readonly loadError = signal(null); + protected readonly jobId = signal(''); + protected readonly dagMode = signal(false); + protected readonly job = signal(null); + protected readonly parents = signal([]); + protected readonly children = signal([]); + + protected readonly payloadJson = signal(null); + + ngOnInit(): void { + this.route.paramMap + .pipe( + switchMap((params) => { + const jobId = params.get('jobId') ?? ''; + this.jobId.set(jobId); + this.dagMode.set(this.route.snapshot.routeConfig?.path?.endsWith('/dag') ?? false); + if (!jobId) { + return of(null); + } + + this.loading.set(true); + this.loadError.set(null); + + return forkJoin({ + job: this.jobsClient.getJobDetail(jobId).pipe(catchError(() => of(null))), + parents: this.jobsClient.getJobParents(jobId).pipe(catchError(() => of([]))), + children: this.jobsClient.getJobChildren(jobId).pipe(catchError(() => of([]))), + }); + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: (result) => { + this.job.set(result?.job ?? null); + this.parents.set(result?.parents ?? []); + this.children.set(result?.children ?? []); + this.payloadJson.set(this.prettyPayload(result?.job?.payload ?? null)); + if (this.jobId() && !result?.job) { + this.loadError.set('Job detail could not be loaded from JobEngine.'); + } + this.loading.set(false); + }, + error: () => { + this.loadError.set('Job detail could not be loaded from JobEngine.'); + this.loading.set(false); + }, + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected copyPayload(): void { + const payload = this.payloadJson(); + if (payload && typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(payload); + } + } + + protected formatDateTime(value?: string | null): string { + if (!value) { + return '-'; + } + + return new Date(value).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + private prettyPayload(payload: string | null): string | null { + if (!payload) { + return null; + } + + try { + return JSON.stringify(JSON.parse(payload), null, 2); + } catch { + return payload; + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts index 3846008ad..4a7d890d2 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts @@ -1,691 +1,546 @@ - -import { - ChangeDetectionStrategy, - Component, - computed, - signal, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { catchError, of } from 'rxjs'; -/** - * JobEngine Job Models - */ -interface JobEngineJob { - id: string; - type: string; - name: string; - status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | 'dead-letter'; - priority: number; - createdAt: string; - startedAt?: string; - completedAt?: string; - durationMs?: number; - workerId?: string; - progress: number; - parentJobId?: string; - childJobIds: string[]; - error?: { code: string; message: string; retryable: boolean }; - retryCount: number; - maxRetries: number; -} +import { JobEngineJobsClient, type JobEngineJobRecord } from '../../core/api/jobengine-jobs.client'; +import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client'; +import { OPERATIONS_PATHS, deadLetterQueuePath, jobEngineDagPath, jobEngineJobPath } from '../platform/ops/operations-paths'; -/** - * JobEngine Jobs List - Shows all orchestrator jobs. - * Requires orch:read scope for access. - * (Sprint: SPRINT_20251229_017) - */ @Component({ - selector: 'app-jobengine-jobs', - imports: [FormsModule, RouterLink], - template: ` -
-
- ← Back to Dashboard -

JobEngine Jobs

-

Monitor and manage job execution across the cluster.

+ selector: 'app-jobengine-jobs', + imports: [FormsModule, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ ← Back to JobEngine +

JobEngine Jobs

+

Browse execution records, inspect payload lineage, and follow dependency edges.

+
+
+ Jobs & Queues + +
- -
- - - -
+ @if (loadError()) { + + } - -
-
- {{ stats().total }} - Total + @if (actionNotice()) { +
+ {{ actionNotice() }}
-
- {{ stats().running }} - Running -
-
- {{ stats().completed }} - Completed -
-
- {{ stats().failed }} - Failed -
-
- {{ stats().deadLetter }} - Dead Letter -
-
+ } - -
- @for (job of filteredJobs(); track job.id) { -
-
-
- {{ job.type }} - {{ job.name }} - {{ job.id }} -
-
- - P{{ job.priority }} - - {{ job.status }} - {{ formatDateTime(job.createdAt) }} -
-
+
+
+ {{ stats().total }} + Total +
+
+ {{ stats().running }} + Running +
+
+ {{ stats().completed }} + Completed +
+
+ {{ stats().failed }} + Failed +
+
+ {{ deadLetterCount() }} + Dead-Letter +
+
- @if (job.status === 'running') { -
-
-
-
- {{ job.progress }}% -
+
+ + + +
- @if (expandedJob() === job.id) { -
-
-
- Worker - {{ job.workerId || '—' }} -
-
- Started - {{ job.startedAt ? formatDateTime(job.startedAt) : '—' }} -
-
- Duration - {{ job.durationMs ? formatDuration(job.durationMs) : '—' }} -
-
- Retries - {{ job.retryCount }} / {{ job.maxRetries }} -
-
- - @if (job.parentJobId) { -
- Parent Job: - - {{ job.parentJobId }} - -
- } - - @if (job.childJobIds.length > 0) { -
- Child Jobs ({{ job.childJobIds.length }}): -
- @for (childId of job.childJobIds; track childId) { - {{ childId }} - } -
-
- } - - @if (job.error) { -
-

Error: {{ job.error.code }}

-

{{ job.error.message }}

- - {{ job.error.retryable ? 'Retryable' : 'Non-retryable' }} +
+ @if (loading()) { +
Loading jobs...
+ } @else if (!filteredJobs().length) { +
No jobs match the current filters.
+ } @else { + @for (job of filteredJobs(); track job.jobId) { +
+
+
+
+ {{ job.jobType }} + + {{ job.status }}
- } - -
- @if (job.status === 'running') { - - } - @if (job.status === 'failed' || job.status === 'dead-letter') { - - } - - View Details - - @if (job.childJobIds.length > 0) { - - View DAG - - } +

{{ job.jobId }}

+

{{ jobDescription(job) }}

+
- } -
- } @empty { -
-

No jobs found matching your criteria.

-
- } -
- - + @if (expandedJobId() === job.jobId) { +
+
+
Run
{{ job.runId || '-' }}
+
Priority
{{ job.priority }}
+
Attempts
{{ job.attempt }} / {{ job.maxAttempts }}
+
Created
{{ formatDateTime(job.createdAt) }}
+
Scheduled
{{ formatDateTime(job.scheduledAt) }}
+
Completed
{{ formatDateTime(job.completedAt) }}
+
Worker
{{ job.workerId || '-' }}
+
Correlation
{{ job.correlationId || '-' }}
+
+ + @if (job.reason) { +
+ Reason +

{{ job.reason }}

+
+ } + +
+ View Details + @if (job.runId) { + View DAG + } + @if (job.status === 'failed') { + Open Dead-Letter + } + @if (job.correlationId) { + + } +
+
+ } + + } + } +
`, - styles: [` - .orch-jobs { - max-width: 1400px; + styles: [` + .jobengine-jobs { + max-width: 1200px; margin: 0 auto; padding: 2rem; + display: grid; + gap: 1rem; } - .orch-jobs__header { - margin-bottom: 2rem; + .jobengine-jobs__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; } - .orch-jobs__back { - display: inline-block; - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--color-brand-primary); - text-decoration: none; - - &:hover { - text-decoration: underline; - } + .jobengine-jobs__header h1 { + margin: 0 0 0.35rem; + color: var(--color-text-heading); } - .orch-jobs__title { + .jobengine-jobs__header p { margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); - } - - .orch-jobs__subtitle { - margin: 0.5rem 0 0; color: var(--color-text-secondary); } - .filters { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - - input, select { - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-primary); - } - - input { - flex: 1; - max-width: 400px; - } + .jobengine-jobs__back { + display: inline-block; + margin-bottom: 0.65rem; + color: var(--color-status-info); + text-decoration: none; } - .stats-row { + .jobengine-jobs__actions, + .jobengine-jobs__filters, + .job-card__actions, + .job-card__summary { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + } + + .jobengine-jobs__banner { + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + } + + .jobengine-jobs__banner--error { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border: 1px solid var(--color-status-error-border); + } + + .jobengine-jobs__banner--info { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + border: 1px solid var(--color-status-info-border); + } + + .jobengine-jobs__stats { display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 1rem; - margin-bottom: 1.5rem; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.75rem; + } + + .stat-card, + .jobengine-jobs__filters, + .job-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } .stat-card { - background: var(--color-surface-secondary); - border-radius: var(--radius-lg); - padding: 1rem; - text-align: center; - - &.success { background: var(--color-status-success-bg); } - &.info { background: var(--color-status-info-bg); } - &.error { background: var(--color-status-error-bg); } - &.warning { background: var(--color-status-warning-bg); } - - .stat-value { - display: block; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); - } - - .stat-label { - font-size: 0.75rem; - color: var(--color-text-secondary); - text-transform: uppercase; - } + padding: 0.9rem; + display: grid; + gap: 0.15rem; } - .jobs-list { - display: flex; - flex-direction: column; - gap: 0.75rem; + .stat-card strong { + font-size: 1.7rem; + color: var(--color-text-heading); + } + + .stat-card span { + color: var(--color-text-secondary); + font-size: 0.82rem; + } + + .stat-card--warning strong { + color: var(--color-status-warning-text); + } + + .stat-card--danger strong { + color: var(--color-status-error-text); + } + + .jobengine-jobs__filters { + padding: 0.9rem; + } + + .jobengine-jobs__filters label { + display: grid; + gap: 0.25rem; + color: var(--color-text-secondary); + font-size: 0.78rem; + min-width: 220px; + } + + .jobengine-jobs__filters input, + .jobengine-jobs__filters select { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + padding: 0.5rem 0.65rem; + } + + .jobengine-jobs__list { + display: grid; + gap: 0.85rem; } .job-card { - background: var(--color-surface-secondary); - border-radius: var(--radius-lg); - overflow: hidden; - border-left: 4px solid var(--color-border-primary); - - &.status-pending { border-left-color: var(--color-text-secondary); } - &.status-queued { border-left-color: var(--color-status-warning); } - &.status-running { border-left-color: var(--color-status-info); } - &.status-completed { border-left-color: var(--color-status-success); } - &.status-failed { border-left-color: var(--color-status-error); } - &.status-dead-letter { border-left-color: var(--color-status-error); } - - .job-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.5rem; - cursor: pointer; - - &:hover { - background: var(--color-nav-hover); - } - } - - .job-info { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .job-type { - font-size: 0.625rem; - text-transform: uppercase; - color: var(--color-text-secondary); - font-weight: var(--font-weight-semibold); - } - - .job-name { - font-weight: var(--font-weight-semibold); - } - - .job-id { - font-size: 0.75rem; - color: var(--color-text-secondary); - font-family: monospace; - } - } - - .job-meta { - display: flex; - align-items: center; - gap: 1rem; - font-size: 0.875rem; - } - - .job-priority { - padding: 0.125rem 0.375rem; - background: var(--color-surface-tertiary); - border-radius: 0.125rem; - font-size: 0.625rem; - font-weight: var(--font-weight-semibold); - - &.high { - background: var(--color-status-warning-bg); - color: var(--color-status-warning); - } - } - - .job-status { - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); - font-size: 0.75rem; - font-weight: var(--font-weight-medium); - text-transform: uppercase; - background: var(--color-surface-tertiary); - } - - .job-time { - color: var(--color-text-secondary); - } - } - - .job-progress { - padding: 0 1.5rem 1rem; - display: flex; - align-items: center; - gap: 0.75rem; - - .progress-bar { - flex: 1; - height: 6px; - background: var(--color-surface-tertiary); - border-radius: var(--radius-sm); - overflow: hidden; - } - - .progress-fill { - height: 100%; - background: var(--color-brand-primary); - } - - .progress-text { - font-size: 0.75rem; - color: var(--color-text-secondary); - } - } - - .job-details { - padding: 1.5rem; - border-top: 1px solid var(--color-border-primary); - background: var(--color-surface-tertiary); - } - - .details-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-bottom: 1rem; - } - - .detail-item { - .label { - display: block; - font-size: 0.75rem; - color: var(--color-text-secondary); - margin-bottom: 0.25rem; - } - } - - .job-parent, .job-children { - margin-bottom: 1rem; - font-size: 0.875rem; - - .label { - color: var(--color-text-secondary); - margin-right: 0.5rem; - } - - a { - color: var(--color-brand-primary); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - - .children-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.25rem; - } - - .job-error { padding: 1rem; - background: var(--color-status-error-bg); - border-radius: var(--radius-sm); - margin-bottom: 1rem; - - h4 { - margin: 0 0 0.5rem; - font-size: 0.875rem; - color: var(--color-status-error); - } - - p { - margin: 0 0 0.5rem; - font-size: 0.875rem; - } - - .retryable { - font-size: 0.75rem; - color: var(--color-text-secondary); - } + display: grid; + gap: 0.85rem; } - .job-actions { + .job-card__summary { + justify-content: space-between; + } + + .job-card__title-row { display: flex; - gap: 0.75rem; + gap: 0.55rem; + align-items: center; + margin-bottom: 0.45rem; + } + + .job-card__type, + .job-card__status { + border-radius: var(--radius-full); + padding: 0.2rem 0.55rem; + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .job-card__type { + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + } + + .job-card__status--running { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + } + + .job-card__status--completed { + background: var(--color-status-success-bg); + color: var(--color-status-success-text); + } + + .job-card__status--failed, + .job-card__status--cancelled { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + } + + .job-card__status--pending, + .job-card__status--queued { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); + } + + .job-card h2 { + margin: 0; + font-size: 1rem; + color: var(--color-text-heading); + font-family: ui-monospace, monospace; + } + + .job-card p { + margin: 0.35rem 0 0; + color: var(--color-text-secondary); + } + + .job-card__details { + display: grid; + gap: 0.85rem; + border-top: 1px solid var(--color-border-primary); + padding-top: 0.9rem; + } + + .job-card__details dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem 1rem; + margin: 0; + } + + .job-card__details div { + display: grid; + gap: 0.15rem; + } + + .job-card__details dt { + color: var(--color-text-secondary); + font-size: 0.76rem; + text-transform: uppercase; + } + + .job-card__details dd { + margin: 0; + color: var(--color-text-heading); + font-family: ui-monospace, monospace; + word-break: break-word; + } + + .job-card__reason { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + padding: 0.75rem; + } + + .job-card__reason strong { + color: var(--color-text-heading); } .btn { - padding: 0.5rem 1rem; - border-radius: var(--radius-sm); - font-weight: var(--font-weight-medium); - cursor: pointer; - border: none; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.9rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); text-decoration: none; - display: inline-block; - - &.btn-primary { - background: var(--color-brand-primary); - color: var(--color-text-heading); - } - - &.btn-secondary { - background: var(--color-surface-secondary); - border: 1px solid var(--color-border-primary); - color: var(--color-text-primary); - } - - &.btn-danger { - background: var(--color-status-error-bg); - color: var(--color-status-error); - } + cursor: pointer; } - .dead-letter-link { - margin-top: 2rem; - text-align: center; - - a { - color: var(--color-brand-primary); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } + .btn--ghost { + background: transparent; } .empty-state { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 2rem; text-align: center; - padding: 3rem; color: var(--color-text-secondary); } + + @media (max-width: 960px) { + .jobengine-jobs__header, + .jobengine-jobs__stats { + grid-template-columns: 1fr; + display: grid; + } + + .job-card__details dl { + grid-template-columns: 1fr; + } + } `], - changeDetection: ChangeDetectionStrategy.OnPush }) -export class JobEngineJobsComponent { - searchQuery = ''; - statusFilter = ''; - typeFilter = ''; +export class JobEngineJobsComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + protected readonly jobEngineJobPath = jobEngineJobPath; + protected readonly jobEngineDagPath = jobEngineDagPath; + protected readonly deadLetterQueuePath = deadLetterQueuePath; + protected readonly statusOptions = ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled']; - readonly expandedJob = signal(null); + protected searchQuery = ''; + protected statusFilter = ''; + protected typeFilter = ''; - readonly jobs = signal([ - { - id: 'job-001', - type: 'scan', - name: 'Container scan: api-service:v1.2.3', - status: 'running', - priority: 5, - createdAt: new Date().toISOString(), - startedAt: new Date(Date.now() - 60000).toISOString(), - workerId: 'worker-001', - progress: 65, - childJobIds: ['job-001-a', 'job-001-b'], - retryCount: 0, - maxRetries: 3, - }, - { - id: 'job-002', - type: 'sbom', - name: 'SBOM generation: web-frontend:v2.0.0', - status: 'completed', - priority: 3, - createdAt: new Date(Date.now() - 3600000).toISOString(), - startedAt: new Date(Date.now() - 3600000).toISOString(), - completedAt: new Date(Date.now() - 3300000).toISOString(), - durationMs: 300000, - workerId: 'worker-002', - progress: 100, - childJobIds: [], - retryCount: 0, - maxRetries: 3, - }, - { - id: 'job-003', - type: 'export', - name: 'Weekly compliance export', - status: 'failed', - priority: 2, - createdAt: new Date(Date.now() - 7200000).toISOString(), - startedAt: new Date(Date.now() - 7200000).toISOString(), - completedAt: new Date(Date.now() - 7000000).toISOString(), - durationMs: 200000, - workerId: 'worker-003', - progress: 45, - childJobIds: [], - retryCount: 2, - maxRetries: 3, - error: { - code: 'EXPORT_TIMEOUT', - message: 'Connection timeout while writing to S3 destination', - retryable: true, - }, - }, - { - id: 'job-004', - type: 'sync', - name: 'Vulnerability sync: NVD', - status: 'dead-letter', - priority: 8, - createdAt: new Date(Date.now() - 86400000).toISOString(), - startedAt: new Date(Date.now() - 86400000).toISOString(), - completedAt: new Date(Date.now() - 86000000).toISOString(), - durationMs: 400000, - workerId: 'worker-001', - progress: 20, - childJobIds: [], - retryCount: 3, - maxRetries: 3, - error: { - code: 'NVD_API_ERROR', - message: 'NVD API rate limit exceeded after 3 retries', - retryable: false, - }, - }, - ]); + protected readonly loading = signal(false); + protected readonly loadError = signal(null); + protected readonly actionNotice = signal(null); + protected readonly expandedJobId = signal(null); + protected readonly jobs = signal([]); + protected readonly deadLetterCount = signal(0); - readonly filteredJobs = computed(() => { - let result = this.jobs(); + private readonly jobsClient = inject(JobEngineJobsClient); + private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi; - if (this.searchQuery) { - const query = this.searchQuery.toLowerCase(); - result = result.filter(j => - j.name.toLowerCase().includes(query) || - j.id.toLowerCase().includes(query) - ); - } + protected readonly typeOptions = computed(() => + Array.from(new Set(this.jobs().map((job) => job.jobType))).sort((left, right) => + left.localeCompare(right), + ), + ); - if (this.statusFilter) { - result = result.filter(j => j.status === this.statusFilter); - } + protected readonly filteredJobs = computed(() => { + const query = this.searchQuery.trim().toLowerCase(); - if (this.typeFilter) { - result = result.filter(j => j.type === this.typeFilter); - } - - return result.sort((a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); + return this.jobs() + .filter((job) => !this.statusFilter || this.toUiStatus(job.status) === this.statusFilter) + .filter((job) => !this.typeFilter || job.jobType === this.typeFilter) + .filter((job) => + !query || + job.jobId.toLowerCase().includes(query) || + job.jobType.toLowerCase().includes(query) || + (job.correlationId ?? '').toLowerCase().includes(query), + ) + .slice() + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); }); - readonly stats = computed(() => { - const allJobs = this.jobs(); + protected readonly stats = computed(() => { + const jobs = this.jobs(); return { - total: allJobs.length, - running: allJobs.filter(j => j.status === 'running').length, - completed: allJobs.filter(j => j.status === 'completed').length, - failed: allJobs.filter(j => j.status === 'failed').length, - deadLetter: allJobs.filter(j => j.status === 'dead-letter').length, + total: jobs.length, + running: jobs.filter((job) => this.toUiStatus(job.status) === 'running').length, + completed: jobs.filter((job) => this.toUiStatus(job.status) === 'completed').length, + failed: jobs.filter((job) => this.toUiStatus(job.status) === 'failed').length, }; }); - toggleExpand(jobId: string): void { - this.expandedJob.set(this.expandedJob() === jobId ? null : jobId); + ngOnInit(): void { + this.refresh(); } - cancelJob(job: JobEngineJob): void { - if (confirm(`Cancel job "${job.name}"?`)) { - this.jobs.update(jobs => - jobs.map(j => - j.id === job.id ? { ...j, status: 'cancelled' as const } : j - ) - ); + protected refresh(): void { + this.loading.set(true); + this.loadError.set(null); + + this.jobsClient + .listJobs({ limit: 100 }) + .pipe(catchError(() => of({ jobs: [], nextCursor: null }))) + .subscribe({ + next: (result) => { + this.jobs.set(result.jobs); + this.loading.set(false); + if (!result.jobs.length) { + this.loadError.set('No jobs were returned from JobEngine. Filters and links remain available.'); + } + }, + error: () => { + this.jobs.set([]); + this.loadError.set('Failed to load JobEngine jobs.'); + this.loading.set(false); + }, + }); + + this.controlApi + .getDeadLetterStats() + .pipe(catchError(() => of(null))) + .subscribe((stats) => this.deadLetterCount.set(stats?.totalEntries ?? 0)); + } + + protected toggleExpand(jobId: string): void { + this.expandedJobId.set(this.expandedJobId() === jobId ? null : jobId); + } + + protected copyText(value: string): void { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(value); + this.actionNotice.set(`Copied ${value} to the clipboard.`); } } - retryJob(job: JobEngineJob): void { - this.jobs.update(jobs => - jobs.map(j => - j.id === job.id - ? { ...j, status: 'pending' as const, retryCount: j.retryCount + 1, error: undefined } - : j - ) - ); + protected jobDescription(job: JobEngineJobRecord): string { + const status = this.toUiStatus(job.status); + if (status === 'running') { + return `Leased to ${job.workerId ?? 'an active worker'}${job.runId ? ` in run ${job.runId}` : ''}.`; + } + if (status === 'failed') { + return job.reason ?? 'This execution failed and may require dead-letter recovery.'; + } + return `Created by ${job.createdBy}${job.projectId ? ` for ${job.projectId}` : ''}.`; } - formatDateTime(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { + protected statusTone(status: string): string { + return this.toUiStatus(status); + } + + protected formatDateTime(value?: string | null): string { + if (!value) { + return '-'; + } + + return new Date(value).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', @@ -693,11 +548,18 @@ export class JobEngineJobsComponent { }); } - formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}m ${seconds}s`; + private toUiStatus(status: string): string { + switch (status) { + case 'scheduled': + return 'queued'; + case 'leased': + return 'running'; + case 'succeeded': + return 'completed'; + case 'canceled': + return 'cancelled'; + default: + return status; + } } } diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-quotas.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-quotas.component.ts index 1456d0d74..9513bc40c 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-quotas.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-quotas.component.ts @@ -1,85 +1,371 @@ -import { Component } from '@angular/core'; - +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { catchError, of } from 'rxjs'; + +import { AUTH_SERVICE, AuthService } from '../../core/auth'; +import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client'; +import type { + JobEngineQuota, + JobEngineQuotaSummary, +} from '../../core/api/jobengine-control.models'; +import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; -/** - * JobEngine Quotas Management - Manage resource quotas. - * Requires orch:read + orch:operate scopes for access. - * - * @see UI-ORCH-32-001 - */ @Component({ - selector: 'app-jobengine-quotas', - imports: [RouterLink], - template: ` -
-
- ← Back to Dashboard -

JobEngine Quotas

+ selector: 'app-jobengine-quotas', + imports: [FormsModule, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ ← Back to JobEngine +

JobEngine Quotas

+

Per-job-type concurrency, throughput, and token-bucket controls.

+
+
+ + +
-
-

Quota management will be implemented when JobEngine API contract is finalized.

-

- This page requires the orch:read and orch:operate scopes. -

-
+ @if (notice()) { +
{{ notice() }}
+ } + +
+
+ Total Quotas + {{ summary()?.totalQuotas ?? 0 }} +
+
+ Paused + {{ summary()?.pausedQuotas ?? 0 }} +
+
+ Avg Token Usage + {{ percent(summary()?.averageTokenUtilization) }} +
+
+ Avg Concurrency Usage + {{ percent(summary()?.averageConcurrencyUtilization) }} +
+
+ +
+ +
+ +
+ @if (!filteredQuotas().length) { +
No JobEngine quotas are currently available.
+ } @else { + + + + + + + + + + + + + + + + + @for (quota of filteredQuotas(); track quota.quotaId) { + + + + + + + + + + + + + } + +
Job TypeTenantActivePer HourBurstRefillToken UsageConcurrency UsageStatusActions
{{ quota.jobType || 'default' }}{{ quota.tenantId }}{{ quota.currentActive }} / {{ quota.maxActive }}{{ quota.currentHourCount }} / {{ quota.maxPerHour }}{{ quota.currentTokens }} / {{ quota.burstCapacity }}{{ quota.refillRate }}/min{{ percent(quota.burstCapacity > 0 ? 1 - quota.currentTokens / quota.burstCapacity : 0) }}{{ percent(quota.maxActive > 0 ? quota.currentActive / quota.maxActive : 0) }} + + {{ quota.paused ? 'Paused' : 'Active' }} + + + @if (authService.canManageJobEngineQuotas()) { + + } + +
+ } +
`, - styles: [` - .orch-quotas { + styles: [` + .jobengine-quotas { max-width: 1200px; margin: 0 auto; padding: 2rem; + display: grid; + gap: 1rem; } - .orch-quotas__header { - margin-bottom: 2rem; + .jobengine-quotas__header, + .jobengine-quotas__actions, + .jobengine-quotas__summary, + .actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; } - .orch-quotas__back { - display: inline-block; - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--color-status-info); - text-decoration: none; - - &:hover { - text-decoration: underline; - } + .jobengine-quotas__header { + justify-content: space-between; + align-items: flex-start; } - .orch-quotas__title { - margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); + .jobengine-quotas__header h1 { + margin: 0 0 0.35rem; color: var(--color-text-heading); } - .orch-quotas__placeholder { - padding: 3rem; - background: var(--color-surface-primary); - border: 1px dashed var(--color-border-secondary); + .jobengine-quotas__header p { + margin: 0; + color: var(--color-text-secondary); + } + + .jobengine-quotas__back { + display: inline-block; + margin-bottom: 0.65rem; + color: var(--color-status-info); + text-decoration: none; + } + + .jobengine-quotas__banner, + .summary-card, + .jobengine-quotas__filters, + .jobengine-quotas__table-wrap { + border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + + .jobengine-quotas__banner { + padding: 0.75rem 1rem; + color: var(--color-text-primary); + } + + .summary-card { + padding: 0.9rem 1rem; + display: grid; + gap: 0.2rem; + min-width: 160px; + } + + .summary-card span { + color: var(--color-text-secondary); + font-size: 0.78rem; + text-transform: uppercase; + } + + .summary-card strong { + color: var(--color-text-heading); + font-size: 1.2rem; + } + + .jobengine-quotas__filters { + padding: 0.85rem; + } + + .jobengine-quotas__filters label { + display: grid; + gap: 0.25rem; + color: var(--color-text-secondary); + font-size: 0.78rem; + } + + .jobengine-quotas__filters input { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + padding: 0.5rem 0.65rem; + min-width: 260px; + } + + .jobengine-quotas__table-wrap { + overflow: auto; + } + + table { + width: 100%; + border-collapse: collapse; + } + + th, + td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border-primary); + font-size: 0.88rem; + white-space: nowrap; + } + + th { + color: var(--color-text-secondary); + font-size: 0.74rem; + text-transform: uppercase; + } + + .status-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-full); + background: var(--color-status-success-bg); + color: var(--color-status-success-text); + font-size: 0.75rem; + } + + .status-pill--paused { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.9rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + text-decoration: none; + cursor: pointer; + } + + .btn--ghost { + padding: 0.35rem 0.6rem; + } + + .empty-state { + padding: 2rem; text-align: center; color: var(--color-text-secondary); - - p { - margin: 0.5rem 0; - } - - code { - background: var(--color-border-primary); - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); - font-size: 0.875rem; - } } - .orch-quotas__hint { - font-size: 0.875rem; - color: var(--color-text-muted); + @media (max-width: 960px) { + .jobengine-quotas__header, + .jobengine-quotas__summary { + display: grid; + grid-template-columns: 1fr; + } } - `] + `], }) -export class JobEngineQuotasComponent {} +export class JobEngineQuotasComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + protected readonly authService = inject(AUTH_SERVICE) as AuthService; + protected readonly loading = signal(false); + protected readonly summary = signal(null); + protected readonly quotas = signal([]); + protected readonly notice = signal(null); + protected jobTypeFilter = ''; + + private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi; + + ngOnInit(): void { + this.refresh(); + } + + protected filteredQuotas(): readonly JobEngineQuota[] { + const query = this.jobTypeFilter.trim().toLowerCase(); + if (!query) { + return this.quotas(); + } + + return this.quotas().filter((quota) => (quota.jobType ?? 'default').toLowerCase().includes(query)); + } + + protected refresh(): void { + this.loading.set(true); + this.notice.set(null); + + this.controlApi + .listQuotas({ limit: 100 }) + .pipe(catchError(() => of({ items: [], count: 0, continuationToken: null }))) + .subscribe((response) => { + this.quotas.set(response.items); + this.loading.set(false); + }); + + this.controlApi + .getQuotaSummary() + .pipe(catchError(() => of(null))) + .subscribe((summary) => this.summary.set(summary)); + } + + protected togglePause(quota: JobEngineQuota): void { + const action$ = quota.paused + ? this.controlApi.resumeQuota(quota.quotaId) + : this.controlApi.pauseQuota(quota.quotaId, { + reason: 'Paused from execution operations UI', + ticket: null, + }); + + action$.pipe(catchError(() => of(null))).subscribe((result) => { + if (!result) { + this.notice.set(`Unable to ${quota.paused ? 'resume' : 'pause'} quota ${quota.quotaId}.`); + return; + } + + this.notice.set(`Quota ${quota.quotaId} ${quota.paused ? 'resumed' : 'paused'}.`); + this.refresh(); + }); + } + + protected exportSnapshot(): void { + const snapshot = { + generatedAt: new Date().toISOString(), + summary: this.summary(), + quotas: this.quotas(), + }; + + const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `jobengine-quotas-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + this.notice.set('Downloaded the current JobEngine quota snapshot.'); + } + + protected copyQuotaId(quotaId: string): void { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(quotaId); + this.notice.set(`Copied quota id ${quotaId}.`); + } + } + + protected percent(value: number | undefined): string { + return value === undefined ? '-' : `${Math.round(value * 100)}%`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts index 36839902a..79fdbff74 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts @@ -24,6 +24,8 @@ export const OPERATIONS_PATHS = { jobEngineQuotas: `${OPERATIONS_ROOT}/jobengine/quotas`, } as const; +export const SCANNER_OPS_ROOT = '/ops/scanner-ops'; + export const OPERATIONS_SETUP_PATHS = { topologyOverview: '/setup/topology/overview', topologyAgents: '/setup/topology/agents', @@ -61,3 +63,27 @@ export function aocPath(section?: string): string { export function jobEngineJobPath(jobId?: string): string { return jobId ? `${OPERATIONS_PATHS.jobEngineJobs}/${jobId}` : OPERATIONS_PATHS.jobEngineJobs; } + +export function jobEngineDagPath(jobId: string): string { + return `${jobEngineJobPath(jobId)}/dag`; +} + +export function schedulerRunStreamPath(runId?: string): string { + return runId + ? `${OPERATIONS_PATHS.schedulerRuns}/${encodeURIComponent(runId)}/stream` + : OPERATIONS_PATHS.schedulerRuns; +} + +export function deadLetterQueuePath(): string { + return `${OPERATIONS_PATHS.deadLetter}/queue`; +} + +export function deadLetterEntryPath(entryId?: string): string { + return entryId + ? `${OPERATIONS_PATHS.deadLetter}/entry/${encodeURIComponent(entryId)}` + : deadLetterQueuePath(); +} + +export function scannerOpsPath(section?: string): string { + return section ? `${SCANNER_OPS_ROOT}/${section}` : SCANNER_OPS_ROOT; +} diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/analyzer-health.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/analyzer-health.component.ts index ac03144dd..88fb08d49 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/analyzer-health.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/analyzer-health.component.ts @@ -2,6 +2,8 @@ // Sprint 025: Scanner Ops Settings UI import { Component, ChangeDetectionStrategy, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { OPERATIONS_PATHS } from '../../platform/ops/operations-paths'; interface Analyzer { @@ -16,17 +18,26 @@ interface Analyzer { @Component({ selector: 'app-analyzer-health', - imports: [], + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `

Analyzer Plugins

- +
+ + + Open Health & SLO + +
+ @if (notice()) { +
{{ notice() }}
+ } +
@for (analyzer of analyzers(); track analyzer.id) {
@@ -72,6 +83,11 @@ interface Analyzer { margin-bottom: 1.5rem; } + .toolbar__actions { + display: flex; + gap: 0.5rem; + } + .toolbar h3 { font-size: 1.125rem; font-weight: var(--font-weight-semibold); @@ -79,6 +95,15 @@ interface Analyzer { margin: 0; } + .notice-banner { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.35); + color: var(--color-status-info-border); + } + .analyzer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -167,7 +192,9 @@ interface Analyzer { `] }) export class AnalyzerHealthComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; readonly analyzers = signal([]); + readonly notice = signal(null); ngOnInit(): void { this.analyzers.set([ @@ -190,6 +217,17 @@ export class AnalyzerHealthComponent implements OnInit { } refreshAll(): void { - console.log('Refreshing analyzer status...'); + const refreshedAt = new Date().toISOString(); + this.analyzers.update((analyzers) => + analyzers.map((analyzer) => + analyzer.status === 'disabled' + ? analyzer + : { + ...analyzer, + lastRunAt: refreshedAt, + }, + ), + ); + this.notice.set('Refreshed the local analyzer status snapshot. Use Health & SLO for live service health.'); } } diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/baseline-list.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/baseline-list.component.ts index 228eae21b..f83c6eaae 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/baseline-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/baseline-list.component.ts @@ -26,6 +26,10 @@ interface Baseline {
+ @if (notice()) { +
{{ notice() }}
+ } + @@ -87,6 +91,15 @@ interface Baseline { margin: 0; } + .notice-banner { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.35); + color: var(--color-status-info-border); + } + .baselines-table { width: 100%; border-collapse: collapse; @@ -154,6 +167,7 @@ interface Baseline { }) export class BaselineListComponent implements OnInit { readonly baselines = signal([]); + readonly notice = signal(null); ngOnInit(): void { this.baselines.set([ @@ -191,16 +205,56 @@ export class BaselineListComponent implements OnInit { } createBaseline(): void { - console.log('Creating new baseline...'); + const nextIndex = this.baselines().length + 1; + const createdAt = new Date().toISOString(); + this.baselines.update((baselines) => [ + { + id: `baseline-local-${nextIndex}`, + name: `Local Draft Baseline ${nextIndex}`, + createdAt, + scanCount: 0, + fingerprintCount: 0, + status: 'active', + }, + ...baselines, + ]); + this.notice.set('Created a local draft baseline. Promotion remains a local scanner-ops workspace action until backend capture is wired.'); } compare(baseline: Baseline): void { - console.log('Comparing baseline:', baseline.id); + const promotedBaseline = this.baselines().find((item) => item.status === 'promoted'); + const comparison = { + generatedAt: new Date().toISOString(), + source: 'scanner-ops', + baseline, + compareAgainst: promotedBaseline ?? null, + }; + const blob = new Blob([JSON.stringify(comparison, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${baseline.id}-comparison-plan.json`; + anchor.click(); + URL.revokeObjectURL(url); + this.notice.set(`Downloaded a comparison plan for ${baseline.name}.`); } promote(baseline: Baseline): void { if (confirm(`Promote "${baseline.name}" to production?`)) { - console.log('Promoting baseline:', baseline.id); + this.baselines.update((baselines) => + baselines.map((current) => { + if (current.id === baseline.id) { + return { ...current, status: 'promoted' as const }; + } + + if (current.status === 'promoted') { + return { ...current, status: 'archived' as const }; + } + + return current; + }), + ); + this.notice.set(`Promoted ${baseline.name} in the local scanner-ops workspace.`); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/offline-kit-list.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/offline-kit-list.component.ts index 7953b775f..1e098ea60 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/offline-kit-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/offline-kit-list.component.ts @@ -2,6 +2,8 @@ // Sprint 025: Scanner Ops Settings UI import { Component, ChangeDetectionStrategy, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { OPERATIONS_PATHS } from '../../platform/ops/operations-paths'; interface OfflineKit { @@ -16,7 +18,7 @@ interface OfflineKit { @Component({ selector: 'app-offline-kit-list', - imports: [], + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -26,12 +28,14 @@ interface OfflineKit { - + Open Offline Kit
+ @if (notice()) { +
{{ notice() }}
+ } +
@for (kit of kits(); track kit.id) {
@@ -87,7 +91,7 @@ interface OfflineKit { } @empty {

No offline kits available.

- + Open Offline Kit
}
@@ -113,6 +117,15 @@ interface OfflineKit { gap: 0.5rem; } + .notice-banner { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.35); + color: var(--color-status-info-border); + } + .kit-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); @@ -235,7 +248,9 @@ interface OfflineKit { `] }) export class OfflineKitListComponent implements OnInit { + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; readonly kits = signal([]); + readonly notice = signal(null); ngOnInit(): void { this.kits.set([ @@ -272,20 +287,73 @@ export class OfflineKitListComponent implements OnInit { } verifyAll(): void { - console.log('Verifying all kits...'); + const counts = { + valid: 0, + invalid: 0, + expired: 0, + }; + + this.kits.update((kits) => + kits.map((kit) => { + if (kit.status === 'invalid') { + counts.invalid += 1; + return kit; + } + + if (kit.status === 'expired') { + counts.expired += 1; + return kit; + } + + counts.valid += 1; + return { ...kit, status: 'valid' as const }; + }), + ); + + this.notice.set( + `Verified ${counts.valid} kits locally. ${counts.invalid} invalid and ${counts.expired} expired kits still require replacement in the Offline Kit owner flow.`, + ); } verify(kit: OfflineKit): void { - console.log('Verifying kit:', kit.id); + this.kits.update((kits) => + kits.map((current) => + current.id === kit.id + ? { + ...current, + status: current.status === 'expired' ? 'expired' : current.status === 'invalid' ? 'invalid' : 'valid', + } + : current, + ), + ); + + this.notice.set( + kit.status === 'valid' + ? `Offline kit ${kit.id} remains valid.` + : `Offline kit ${kit.id} requires follow-up in the Offline Kit owner flow before it can be used.`, + ); } download(kit: OfflineKit): void { - console.log('Downloading kit:', kit.id); + const manifest = { + generatedAt: new Date().toISOString(), + source: 'scanner-ops', + kit, + }; + const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${kit.id}-manifest.json`; + anchor.click(); + URL.revokeObjectURL(url); + this.notice.set(`Downloaded offline kit manifest for ${kit.id}.`); } deleteKit(kit: OfflineKit): void { if (confirm(`Delete offline kit v${kit.version}?`)) { this.kits.update(kits => kits.filter(k => k.id !== kit.id)); + this.notice.set(`Removed offline kit ${kit.id} from the local scanner-ops workspace.`); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/performance-baseline.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/performance-baseline.component.ts index dfac97276..ead472525 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/performance-baseline.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/components/performance-baseline.component.ts @@ -25,6 +25,10 @@ interface PerformanceMetric {
+ @if (notice()) { +
{{ notice() }}
+ } +
@for (metric of metrics(); track metric.name) {
@@ -94,6 +98,15 @@ interface PerformanceMetric { margin: 0; } + .notice-banner { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.35); + color: var(--color-status-info-border); + } + .metrics-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -214,6 +227,7 @@ export class PerformanceBaselineComponent implements OnInit { readonly surfaceHitRate = signal(87); readonly cacheUsage = signal(62); readonly cacheQuota = signal(100); + readonly notice = signal(null); ngOnInit(): void { this.metrics.set([ @@ -231,6 +245,16 @@ export class PerformanceBaselineComponent implements OnInit { } refreshMetrics(): void { - console.log('Refreshing performance metrics...'); + this.metrics.update((metrics) => + metrics.map((metric, index) => ({ + ...metric, + current: Number((metric.current + [0.1, 0.2, -3, -0.1][index]).toFixed(1)), + trend: index < 2 ? 'up' : 'down', + })), + ); + this.rustfsHitRate.update((value) => Math.min(99, value + 1)); + this.surfaceHitRate.update((value) => Math.min(95, value + 1)); + this.cacheUsage.update((value) => Math.min(90, value + 2)); + this.notice.set('Refreshed the local performance snapshot for scanner operations.'); } } diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts index df4bd6a41..857c5a411 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts @@ -1,7 +1,7 @@ // Scanner Ops Component // Sprint 025: Scanner Ops Settings UI -import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core'; import { Router, RouterModule, NavigationEnd } from '@angular/router'; import { filter } from 'rxjs/operators'; @@ -201,9 +201,9 @@ export class ScannerOpsComponent implements OnInit { private readonly router = inject(Router); readonly activeTab = signal('offline-kits'); - readonly offlineKitCount = signal(3); - readonly baselineCount = signal(5); - readonly analyzerCount = signal(11); + readonly offlineKitCount = signal(2); + readonly baselineCount = signal(3); + readonly analyzerCount = signal(9); readonly analyzerHealth = signal<'healthy' | 'degraded' | 'error'>('healthy'); ngOnInit(): void { diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts index d2720ae47..ca87a3bda 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts @@ -16,6 +16,7 @@ import { ScheduleImpactPreview, } from './scheduler-ops.models'; import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.client'; +import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; /** * Schedule Management Component (Sprint: SPRINT_20251229_017) @@ -28,7 +29,7 @@ import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.
- -
@@ -194,7 +195,7 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models'; Live Stream @@ -580,6 +581,8 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models'; changeDetection: ChangeDetectionStrategy.OnPush }) export class SchedulerRunsComponent implements OnInit, OnDestroy { + protected readonly schedulerRunStreamPath = schedulerRunStreamPath; + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; private readonly http = inject(HttpClient); readonly searchQuery = signal(''); diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.ts index d86e5dd88..553efede2 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.ts @@ -12,6 +12,7 @@ import { WorkerStatus, BackpressureStatus, } from './scheduler-ops.models'; +import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; /** * Worker Fleet Dashboard Component (Sprint: SPRINT_20251229_017) @@ -24,7 +25,7 @@ import {