feat(ui): ship execution operations cutover

This commit is contained in:
master
2026-03-08 09:33:05 +02:00
parent 80257a4538
commit 8b1fe49f35
39 changed files with 3849 additions and 978 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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`

View File

@@ -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.

View File

@@ -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: [

View File

@@ -196,9 +196,9 @@ export const ERROR_CODE_REFERENCES: Record<ErrorCode, ErrorCodeReference> = {
'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: [

View File

@@ -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<JobEngineJobListResult> {
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<JobEngineJobListResponse>(`${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<JobEngineJobsQuery, 'tenantId' | 'projectId' | 'traceId'> = {}): Observable<JobEngineJobRecord> {
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<JobEngineJobApiRecord>(`${this.baseUrl}/jobengine/jobs/${encodeURIComponent(jobId)}`, {
headers: this.buildHeaders(tenant, traceId, query.projectId),
})
.pipe(map((response) => this.mapJob(response)));
}
getJobDetail(jobId: string, query: Pick<JobEngineJobsQuery, 'tenantId' | 'projectId' | 'traceId'> = {}): Observable<JobEngineJobDetail> {
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<JobEngineJobDetailApiRecord>(`${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<JobEngineJobsQuery, 'tenantId' | 'projectId' | 'traceId'> = {}): Observable<readonly JobEngineDagEdge[]> {
return this.getJobEdges(`job/${encodeURIComponent(jobId)}/parents`, query);
}
getJobChildren(jobId: string, query: Pick<JobEngineJobsQuery, 'tenantId' | 'projectId' | 'traceId'> = {}): Observable<readonly JobEngineDagEdge[]> {
return this.getJobEdges(`job/${encodeURIComponent(jobId)}/children`, query);
}
private getJobEdges(
relativePath: string,
query: Pick<JobEngineJobsQuery, 'tenantId' | 'projectId' | 'traceId'>,
): Observable<readonly JobEngineDagEdge[]> {
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<JobEngineDagEdgeListResponse>(`${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;
}
}

View File

@@ -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',
},

View File

@@ -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 {
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
<a routerLink="/integrations">Open Integrations</a>
<a routerLink="/platform-ops/scheduler/runs">Open Scheduler Runs</a>
<a routerLink="/platform-ops/dead-letter">Open DLQ</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns">Open Scheduler Runs</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter">Open DLQ</a>
</div>
</section>
}
@@ -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);

View File

@@ -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 {
<div class="entry-detail-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/jobengine/dead-letter" class="back-link">&larr; Back to Queue</a>
<a [routerLink]="deadLetterQueuePath()" class="back-link">&larr; Back to Queue</a>
<h1>Dead-Letter Entry</h1>
@if (entry()) {
<p class="entry-id">{{ entry()?.id }}</p>
@@ -52,6 +53,14 @@ import {
}
</header>
@if (actionError()) {
<div class="action-banner action-banner--error" role="alert">{{ actionError() }}</div>
}
@if (notice()) {
<div class="action-banner action-banner--info" role="status">{{ notice() }}</div>
}
@if (loading()) {
<div class="loading-state">
<p>Loading entry details...</p>
@@ -75,7 +84,12 @@ import {
}
@if (entry()?.state === 'replayed') {
<span class="status-detail">
New Job: <a [href]="'/platform-ops/jobengine/jobs/' + entry()?.replayedJobId">{{ entry()?.replayedJobId }}</a>
New Job:
@if (replayedJobPath(); as replayedPath) {
<a [routerLink]="replayedPath">{{ entry()?.replayedJobId }}</a>
} @else {
<span>{{ entry()?.replayedJobId }}</span>
}
</span>
}
</div>
@@ -249,7 +263,7 @@ import {
@if (!loading() && !entry()) {
<div class="empty-state">
<p>Entry not found</p>
<a routerLink="/ops/jobengine/dead-letter">Return to queue</a>
<a [routerLink]="deadLetterQueuePath()">Return to queue</a>
</div>
}
@@ -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<void>();
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<string | null>(null);
readonly actionError = signal<string | null>(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;
}
}

View File

@@ -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 {
<div class="queue-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/jobengine/dead-letter" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter" class="back-link">&larr; Back to Dashboard</a>
<h1>Dead-Letter Queue</h1>
<p class="subtitle">Full queue browser with advanced filtering</p>
</div>
@@ -35,6 +36,14 @@ import {
</div>
</header>
@if (actionError()) {
<div class="banner banner--error" role="alert">{{ actionError() }}</div>
}
@if (notice()) {
<div class="banner banner--info" role="status">{{ notice() }}</div>
}
<!-- Filters -->
<section class="filters-section">
<div class="filters-grid">
@@ -174,7 +183,7 @@ import {
/>
</td>
<td>
<a [routerLink]="['..', 'entry', entry.id]" class="entry-link">
<a [routerLink]="deadLetterEntryPath(entry.id)" class="entry-link">
{{ entry.id.substring(0, 12) }}...
</a>
</td>
@@ -192,7 +201,7 @@ import {
<span class="status-badge" [class]="'status-' + entry.state">{{ entry.state }}</span>
</td>
<td class="actions-col">
<a [routerLink]="['..', 'entry', entry.id]" class="btn btn-sm">View</a>
<a [routerLink]="deadLetterEntryPath(entry.id)" class="btn btn-sm">View</a>
</td>
</tr>
}
@@ -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<void>();
private readonly filterSubject = new Subject<void>();
readonly loading = signal(false);
readonly notice = signal<string | null>(null);
readonly actionError = signal<string | null>(null);
readonly entries = signal<DeadLetterEntrySummary[]>([]);
readonly totalEntries = signal(0);
readonly selectedIds = signal<string[]>([]);
private cursor: string | undefined;
private prevCursors: string[] = [];
private currentCursor: string | undefined;
private nextCursor: string | undefined;
private prevCursors: Array<string | undefined> = [];
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;
}
}

View File

@@ -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: `
<div class="orch-dashboard">
<header class="orch-dashboard__header">
<h1 class="orch-dashboard__title">JobEngine Dashboard</h1>
<p class="orch-dashboard__description">
Monitor and manage orchestrated jobs, quotas, and backfill operations.
</p>
selector: 'app-jobengine-dashboard',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jobengine-dashboard">
<header class="jobengine-dashboard__header">
<div>
<a [routerLink]="OPERATIONS_PATHS.jobsQueues" class="jobengine-dashboard__back">
&larr; Back to Jobs & Queues
</a>
<h1>JobEngine</h1>
<p>Execution queues, quotas, dead-letter recovery, and scheduler handoffs.</p>
</div>
<div class="jobengine-dashboard__actions">
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.schedulerRuns">Scheduler Runs</a>
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.deadLetter">Dead-Letter</a>
<button class="btn btn--secondary" type="button" (click)="refresh()" [disabled]="loading()">
Refresh
</button>
</div>
</header>
<nav class="orch-dashboard__nav">
<a routerLink="/platform-ops/jobengine/jobs" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/><line x1="8" y1="18" x2="12" y2="18"/></svg></span>
<span class="orch-dashboard__card-title">Jobs</span>
<span class="orch-dashboard__card-desc">View job status and history</span>
@if (loadError()) {
<div class="jobengine-dashboard__banner jobengine-dashboard__banner--error" role="alert">
{{ loadError() }}
</div>
}
<section class="jobengine-dashboard__kpis">
<article class="kpi">
<span class="kpi__label">Total Jobs</span>
<strong class="kpi__value">{{ jobSummary()?.totalJobs ?? 0 }}</strong>
<span class="kpi__hint">{{ jobSummary()?.leasedJobs ?? 0 }} running</span>
</article>
<article class="kpi">
<span class="kpi__label">Failed Jobs</span>
<strong class="kpi__value">{{ jobSummary()?.failedJobs ?? 0 }}</strong>
<span class="kpi__hint">{{ deadLetterStats()?.totalEntries ?? 0 }} dead-letter entries</span>
</article>
<article class="kpi">
<span class="kpi__label">Quota Policies</span>
<strong class="kpi__value">{{ quotaSummary()?.totalQuotas ?? 0 }}</strong>
<span class="kpi__hint">{{ quotaSummary()?.pausedQuotas ?? 0 }} paused</span>
</article>
</section>
<section class="jobengine-dashboard__grid">
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineJobs">
<h2>Jobs</h2>
<p>Browse execution records, inspect payload digests, and follow DAG relationships.</p>
<dl>
<div>
<dt>Pending</dt>
<dd>{{ jobSummary()?.pendingJobs ?? 0 }}</dd>
</div>
<div>
<dt>Scheduled</dt>
<dd>{{ jobSummary()?.scheduledJobs ?? 0 }}</dd>
</div>
<div>
<dt>Completed</dt>
<dd>{{ jobSummary()?.succeededJobs ?? 0 }}</dd>
</div>
</dl>
</a>
@if (authService.canOperateOrchestrator()) {
<a routerLink="/platform-ops/jobengine/quotas" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="orch-dashboard__card-title">Quotas</span>
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
</a>
}
</nav>
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
<h2>Execution Quotas</h2>
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
</a>
<section class="orch-dashboard__scope-info">
<h2>Your JobEngine Access</h2>
<a class="surface" [routerLink]="OPERATIONS_PATHS.deadLetter">
<h2>Dead-Letter Recovery</h2>
<p>Retry or resolve failed execution records and inspect replay outcomes.</p>
<dl>
<div>
<dt>Retryable</dt>
<dd>{{ deadLetterStats()?.retryableEntries ?? 0 }}</dd>
</div>
<div>
<dt>Replayed</dt>
<dd>{{ deadLetterStats()?.replayedEntries ?? 0 }}</dd>
</div>
<div>
<dt>Resolved</dt>
<dd>{{ deadLetterStats()?.resolvedEntries ?? 0 }}</dd>
</div>
</dl>
</a>
</section>
<section class="jobengine-dashboard__access surface">
<h2>Your Access</h2>
<ul>
<li>
<strong>View Jobs:</strong>
{{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Operate:</strong>
{{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Manage Quotas:</strong>
{{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Initiate Backfill:</strong>
{{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}
</li>
<li>View Jobs: {{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}</li>
<li>Operate Jobs: {{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}</li>
<li>Manage Quotas: {{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }}</li>
<li>Initiate Backfill: {{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}</li>
</ul>
</section>
</div>
`,
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<string | null>(null);
protected readonly jobSummary = signal<JobEngineJobSummary | null>(null);
protected readonly quotaSummary = signal<JobEngineQuotaSummary | null>(null);
protected readonly deadLetterStats = signal<JobEngineDeadLetterStatsResponse | null>(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)}%`;
}
}

View File

@@ -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: `
<div class="orch-job-detail">
<header class="orch-job-detail__header">
<a routerLink="/platform-ops/jobengine/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
<div class="orch-job-detail__actions">
selector: 'app-jobengine-job-detail',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jobengine-job-detail">
<header class="jobengine-job-detail__header">
<div>
<a [routerLink]="jobEngineJobPath()" class="jobengine-job-detail__back">&larr; Back to Jobs</a>
<h1>{{ dagMode() ? 'Job DAG' : 'Job Detail' }}</h1>
<p>{{ jobId() || '-' }}</p>
</div>
<div class="jobengine-job-detail__actions">
@if (job()?.status === 'failed') {
<a class="btn btn--secondary" [routerLink]="deadLetterQueuePath()">Open Dead-Letter</a>
}
<a
class="btn btn--secondary"
routerLink="/triage/audit-bundles/new"
[queryParams]="{ jobId: jobId }"
class="orch-job-detail__btn"
[queryParams]="{ jobId: jobId() }"
>
Create immutable audit bundle
Create Audit Bundle
</a>
<button class="btn btn--secondary" type="button" (click)="copyPayload()" [disabled]="!job()?.payload">
Copy Payload
</button>
</div>
</header>
<div class="orch-job-detail__placeholder">
<p>Job details will be implemented when JobEngine API contract is finalized.</p>
<p class="orch-job-detail__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
@if (loadError()) {
<div class="jobengine-job-detail__banner jobengine-job-detail__banner--error" role="alert">
{{ loadError() }}
</div>
}
@if (job(); as currentJob) {
<section class="jobengine-job-detail__summary">
<article class="summary-card">
<span>Status</span>
<strong>{{ currentJob.status }}</strong>
</article>
<article class="summary-card">
<span>Attempts</span>
<strong>{{ currentJob.attempt }} / {{ currentJob.maxAttempts }}</strong>
</article>
<article class="summary-card">
<span>Worker</span>
<strong>{{ currentJob.workerId || '-' }}</strong>
</article>
<article class="summary-card">
<span>Run</span>
<strong>{{ currentJob.runId || '-' }}</strong>
</article>
</section>
<section class="jobengine-job-detail__grid">
<article class="surface">
<h2>Execution Metadata</h2>
<dl>
<div><dt>Job Type</dt><dd>{{ currentJob.jobType }}</dd></div>
<div><dt>Priority</dt><dd>{{ currentJob.priority }}</dd></div>
<div><dt>Created By</dt><dd>{{ currentJob.createdBy }}</dd></div>
<div><dt>Created</dt><dd>{{ formatDateTime(currentJob.createdAt) }}</dd></div>
<div><dt>Scheduled</dt><dd>{{ formatDateTime(currentJob.scheduledAt) }}</dd></div>
<div><dt>Leased</dt><dd>{{ formatDateTime(currentJob.leasedAt) }}</dd></div>
<div><dt>Completed</dt><dd>{{ formatDateTime(currentJob.completedAt) }}</dd></div>
<div><dt>Not Before</dt><dd>{{ formatDateTime(currentJob.notBefore) }}</dd></div>
<div><dt>Lease ID</dt><dd>{{ currentJob.leaseId || '-' }}</dd></div>
<div><dt>Lease Until</dt><dd>{{ formatDateTime(currentJob.leaseUntil) }}</dd></div>
<div><dt>Correlation ID</dt><dd>{{ currentJob.correlationId || '-' }}</dd></div>
<div><dt>Idempotency Key</dt><dd>{{ currentJob.idempotencyKey || '-' }}</dd></div>
<div><dt>Payload Digest</dt><dd>{{ currentJob.payloadDigest || '-' }}</dd></div>
<div><dt>Replay Of</dt><dd>{{ currentJob.replayOf || '-' }}</dd></div>
</dl>
@if (currentJob.reason) {
<div class="jobengine-job-detail__reason">
<strong>Reason</strong>
<p>{{ currentJob.reason }}</p>
</div>
}
</article>
<article class="surface">
<h2>{{ dagMode() ? 'Dependency Edges' : 'Parent / Child Jobs' }}</h2>
<div class="edge-section">
<h3>Parents</h3>
@if (parents().length) {
<ul>
@for (edge of parents(); track edge.edgeId) {
<li>
<a [routerLink]="jobEngineJobPath(edge.parentJobId)">{{ edge.parentJobId }}</a>
<span>{{ edge.edgeType }}</span>
</li>
}
</ul>
} @else {
<p>No parent edges recorded for this job.</p>
}
</div>
<div class="edge-section">
<h3>Children</h3>
@if (children().length) {
<ul>
@for (edge of children(); track edge.edgeId) {
<li>
<a [routerLink]="jobEngineJobPath(edge.childJobId)">{{ edge.childJobId }}</a>
<span>{{ edge.edgeType }}</span>
</li>
}
</ul>
} @else {
<p>No child edges recorded for this job.</p>
}
</div>
</article>
</section>
<section class="surface">
<h2>Payload</h2>
@if (payloadJson(); as payload) {
<pre>{{ payload }}</pre>
} @else {
<p>No payload body was returned for this job.</p>
}
</section>
} @else if (!loading()) {
<div class="surface">No job detail is available for this identifier.</div>
}
</div>
`,
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<void>();
protected readonly loading = signal(false);
protected readonly loadError = signal<string | null>(null);
protected readonly jobId = signal('');
protected readonly dagMode = signal(false);
protected readonly job = signal<JobEngineJobDetail | null>(null);
protected readonly parents = signal<readonly JobEngineDagEdge[]>([]);
protected readonly children = signal<readonly JobEngineDagEdge[]>([]);
protected readonly payloadJson = signal<string | null>(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;
}
}
}

View File

@@ -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: `
<div class="orch-quotas">
<header class="orch-quotas__header">
<a routerLink="/jobengine" class="orch-quotas__back">&larr; Back to Dashboard</a>
<h1 class="orch-quotas__title">JobEngine Quotas</h1>
selector: 'app-jobengine-quotas',
imports: [FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jobengine-quotas">
<header class="jobengine-quotas__header">
<div>
<a [routerLink]="OPERATIONS_PATHS.jobEngine" class="jobengine-quotas__back">&larr; Back to JobEngine</a>
<h1>JobEngine Quotas</h1>
<p>Per-job-type concurrency, throughput, and token-bucket controls.</p>
</div>
<div class="jobengine-quotas__actions">
<button class="btn btn--secondary" type="button" (click)="refresh()" [disabled]="loading()">
Refresh
</button>
<button class="btn btn--secondary" type="button" (click)="exportSnapshot()">
Export Snapshot
</button>
</div>
</header>
<div class="orch-quotas__placeholder">
<p>Quota management will be implemented when JobEngine API contract is finalized.</p>
<p class="orch-quotas__hint">
This page requires the <code>orch:read</code> and <code>orch:operate</code> scopes.
</p>
</div>
@if (notice()) {
<div class="jobengine-quotas__banner" role="status">{{ notice() }}</div>
}
<section class="jobengine-quotas__summary">
<article class="summary-card">
<span>Total Quotas</span>
<strong>{{ summary()?.totalQuotas ?? 0 }}</strong>
</article>
<article class="summary-card">
<span>Paused</span>
<strong>{{ summary()?.pausedQuotas ?? 0 }}</strong>
</article>
<article class="summary-card">
<span>Avg Token Usage</span>
<strong>{{ percent(summary()?.averageTokenUtilization) }}</strong>
</article>
<article class="summary-card">
<span>Avg Concurrency Usage</span>
<strong>{{ percent(summary()?.averageConcurrencyUtilization) }}</strong>
</article>
</section>
<section class="jobengine-quotas__filters">
<label>
Job Type
<input type="search" [(ngModel)]="jobTypeFilter" placeholder="scan, export, advisory-sync" />
</label>
</section>
<section class="jobengine-quotas__table-wrap">
@if (!filteredQuotas().length) {
<div class="empty-state">No JobEngine quotas are currently available.</div>
} @else {
<table>
<thead>
<tr>
<th>Job Type</th>
<th>Tenant</th>
<th>Active</th>
<th>Per Hour</th>
<th>Burst</th>
<th>Refill</th>
<th>Token Usage</th>
<th>Concurrency Usage</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (quota of filteredQuotas(); track quota.quotaId) {
<tr>
<td>{{ quota.jobType || 'default' }}</td>
<td>{{ quota.tenantId }}</td>
<td>{{ quota.currentActive }} / {{ quota.maxActive }}</td>
<td>{{ quota.currentHourCount }} / {{ quota.maxPerHour }}</td>
<td>{{ quota.currentTokens }} / {{ quota.burstCapacity }}</td>
<td>{{ quota.refillRate }}/min</td>
<td>{{ percent(quota.burstCapacity > 0 ? 1 - quota.currentTokens / quota.burstCapacity : 0) }}</td>
<td>{{ percent(quota.maxActive > 0 ? quota.currentActive / quota.maxActive : 0) }}</td>
<td>
<span class="status-pill" [class.status-pill--paused]="quota.paused">
{{ quota.paused ? 'Paused' : 'Active' }}
</span>
</td>
<td class="actions">
@if (authService.canManageJobEngineQuotas()) {
<button class="btn btn--ghost" type="button" (click)="togglePause(quota)">
{{ quota.paused ? 'Resume' : 'Pause' }}
</button>
}
<button class="btn btn--ghost" type="button" (click)="copyQuotaId(quota.quotaId)">
Copy ID
</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
`,
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<JobEngineQuotaSummary | null>(null);
protected readonly quotas = signal<readonly JobEngineQuota[]>([]);
protected readonly notice = signal<string | null>(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)}%`;
}
}

View File

@@ -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;
}

View File

@@ -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: `
<div class="analyzer-health">
<div class="toolbar">
<h3>Analyzer Plugins</h3>
<button class="btn btn--secondary" (click)="refreshAll()">
Refresh Status
</button>
<div class="toolbar__actions">
<button class="btn btn--secondary" (click)="refreshAll()">
Refresh Status
</button>
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.healthSlo">
Open Health & SLO
</a>
</div>
</div>
@if (notice()) {
<div class="notice-banner" role="status">{{ notice() }}</div>
}
<div class="analyzer-grid">
@for (analyzer of analyzers(); track analyzer.id) {
<div class="analyzer-card" [class.analyzer-card--error]="analyzer.status === 'error'">
@@ -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<Analyzer[]>([]);
readonly notice = signal<string | null>(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.');
}
}

View File

@@ -26,6 +26,10 @@ interface Baseline {
</button>
</div>
@if (notice()) {
<div class="notice-banner" role="status">{{ notice() }}</div>
}
<table class="baselines-table">
<thead>
<tr>
@@ -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<Baseline[]>([]);
readonly notice = signal<string | null>(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.`);
}
}
}

View File

@@ -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: `
<div class="offline-kit-list">
@@ -26,12 +28,14 @@ interface OfflineKit {
<button class="btn btn--secondary" (click)="verifyAll()">
Verify All
</button>
<button class="btn btn--primary">
Upload Kit
</button>
<a class="btn btn--primary" [routerLink]="OPERATIONS_PATHS.offlineKit">Open Offline Kit</a>
</div>
</div>
@if (notice()) {
<div class="notice-banner" role="status">{{ notice() }}</div>
}
<div class="kit-grid">
@for (kit of kits(); track kit.id) {
<div class="kit-card">
@@ -87,7 +91,7 @@ interface OfflineKit {
} @empty {
<div class="empty-state">
<p>No offline kits available.</p>
<button class="btn btn--primary">Upload First Kit</button>
<a class="btn btn--primary" [routerLink]="OPERATIONS_PATHS.offlineKit">Open Offline Kit</a>
</div>
}
</div>
@@ -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<OfflineKit[]>([]);
readonly notice = signal<string | null>(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.`);
}
}
}

View File

@@ -25,6 +25,10 @@ interface PerformanceMetric {
</button>
</div>
@if (notice()) {
<div class="notice-banner" role="status">{{ notice() }}</div>
}
<div class="metrics-grid">
@for (metric of metrics(); track metric.name) {
<div class="metric-card">
@@ -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<string | null>(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.');
}
}

View File

@@ -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<TabType>('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 {

View File

@@ -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.
<div class="schedule-management">
<header class="page-header">
<div class="header-content">
<a routerLink="/platform-ops/scheduler/runs" class="back-link">&larr; Back to Runs</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" class="back-link">&larr; Back to Runs</a>
<h1>Schedule Management</h1>
<p>Create, edit, and manage scheduled tasks.</p>
</div>
@@ -707,6 +708,7 @@ import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ScheduleManagementComponent implements OnInit {
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
private readonly schedulerApi = inject(SCHEDULER_API);
readonly showModal = signal(false);

View File

@@ -23,6 +23,14 @@ export const schedulerOpsRoutes: Routes = [
),
data: { title: 'Scheduler Runs' },
},
{
path: 'runs/:runId/stream',
loadComponent: () =>
import('./scheduler-run-stream.component').then(
(m) => m.SchedulerRunStreamComponent
),
data: { title: 'Scheduler Run Stream' },
},
{
path: 'schedules',
loadComponent: () =>

View File

@@ -0,0 +1,364 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, of, switchMap } from 'rxjs';
import type { SchedulerRun } from './scheduler-ops.models';
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
interface SchedulerRunEvent {
timestamp: string;
level: 'info' | 'warning' | 'error';
message: string;
}
@Component({
selector: 'app-scheduler-run-stream',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="scheduler-run-stream">
<header class="scheduler-run-stream__header">
<div>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" class="scheduler-run-stream__back">
&larr; Back to Scheduler Runs
</a>
<h1>Scheduler Run Stream</h1>
<p>{{ runId() || 'Unknown run' }}</p>
</div>
<div class="scheduler-run-stream__actions">
<button class="btn btn--secondary" type="button" (click)="refresh()">
Refresh
</button>
<button class="btn btn--secondary" type="button" (click)="copyRunId()" [disabled]="!runId()">
Copy Run ID
</button>
<button class="btn btn--secondary" type="button" (click)="exportStream()" [disabled]="!events().length">
Export Stream
</button>
</div>
</header>
@if (loadError()) {
<div class="banner banner--error" role="alert">{{ loadError() }}</div>
}
@if (notice()) {
<div class="banner banner--info" role="status">{{ notice() }}</div>
}
@if (run(); as currentRun) {
<section class="scheduler-run-stream__summary">
<article class="summary-card">
<span>Status</span>
<strong>{{ currentRun.status }}</strong>
</article>
<article class="summary-card">
<span>Schedule</span>
<strong>{{ currentRun.scheduleName }}</strong>
</article>
<article class="summary-card">
<span>Triggered</span>
<strong>{{ formatDateTime(currentRun.triggeredAt) }}</strong>
</article>
<article class="summary-card">
<span>Items</span>
<strong>{{ currentRun.itemsProcessed }} / {{ currentRun.itemsTotal }}</strong>
</article>
</section>
}
<section class="surface">
<h2>Run Events</h2>
@if (!events().length) {
<div class="empty-state">No scheduler stream events are available for this run.</div>
} @else {
<div class="event-list">
@for (event of events(); track event.timestamp + event.message) {
<article class="event-card" [class]="'event-card--' + event.level">
<span class="event-card__time">{{ formatDateTime(event.timestamp) }}</span>
<span class="event-card__message">{{ event.message }}</span>
</article>
}
</div>
}
</section>
</div>
`,
styles: [`
.scheduler-run-stream {
max-width: 1100px;
margin: 0 auto;
padding: 2rem;
display: grid;
gap: 1rem;
}
.scheduler-run-stream__header,
.scheduler-run-stream__actions,
.scheduler-run-stream__summary {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.scheduler-run-stream__header {
justify-content: space-between;
align-items: flex-start;
}
.scheduler-run-stream__back {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--color-status-info);
text-decoration: none;
}
.scheduler-run-stream__header h1 {
margin: 0 0 0.35rem;
color: var(--color-text-heading);
}
.scheduler-run-stream__header p {
margin: 0;
color: var(--color-text-secondary);
font-family: ui-monospace, monospace;
}
.banner,
.summary-card,
.surface {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.banner {
padding: 0.75rem 1rem;
}
.banner--error {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border-color: var(--color-status-error-border);
}
.banner--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
border-color: var(--color-status-info-border);
}
.summary-card {
min-width: 160px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.2rem;
}
.summary-card span {
color: var(--color-text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
}
.summary-card strong {
color: var(--color-text-heading);
word-break: break-word;
}
.surface {
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.surface h2 {
margin: 0;
color: var(--color-text-heading);
}
.event-list {
display: grid;
gap: 0.75rem;
}
.event-card {
display: grid;
gap: 0.25rem;
padding: 0.9rem;
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
border-left: 4px solid var(--color-status-info-border);
}
.event-card--warning {
border-left-color: var(--color-status-warning-text);
}
.event-card--error {
border-left-color: var(--color-status-error-text);
}
.event-card__time {
color: var(--color-text-secondary);
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
.event-card__message {
color: var(--color-text-primary);
}
.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);
cursor: pointer;
}
.empty-state {
color: var(--color-text-secondary);
padding: 1rem 0;
}
`],
})
export class SchedulerRunStreamComponent implements OnInit {
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
protected readonly runId = signal('');
protected readonly run = signal<SchedulerRun | null>(null);
protected readonly events = signal<readonly SchedulerRunEvent[]>([]);
protected readonly notice = signal<string | null>(null);
protected readonly loadError = signal<string | null>(null);
ngOnInit(): void {
this.route.paramMap
.pipe(
switchMap((params) => {
const runId = params.get('runId') ?? '';
this.runId.set(runId);
this.notice.set(null);
this.loadError.set(null);
if (!runId) {
return of([] as SchedulerRun[]);
}
return this.http
.get<SchedulerRun[] | { items?: SchedulerRun[]; runs?: SchedulerRun[] }>('/scheduler/api/v1/scheduler/runs')
.pipe(
catchError(() => {
this.loadError.set('Scheduler live stream is unavailable. Showing a local run summary instead.');
return of([] as SchedulerRun[]);
}),
);
}),
)
.subscribe((response) => {
const runs = Array.isArray(response) ? response : (response.items ?? response.runs ?? []);
const run = runs.find((item) => item.id === this.runId()) ?? null;
this.run.set(run);
this.events.set(this.buildEvents(run));
if (!run && !this.loadError()) {
this.loadError.set('This scheduler run no longer exists in the active run list.');
}
});
}
protected refresh(): void {
const run = this.run();
this.events.set(this.buildEvents(run));
this.notice.set(
run
? `Refreshed the run stream snapshot for ${run.id}.`
: 'Refreshed the local scheduler run stream snapshot.',
);
}
protected copyRunId(): void {
const runId = this.runId();
if (runId && typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(runId);
this.notice.set(`Copied scheduler run id ${runId}.`);
}
}
protected exportStream(): void {
const snapshot = {
exportedAt: new Date().toISOString(),
runId: this.runId(),
run: this.run(),
events: this.events(),
};
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 = `${this.runId() || 'scheduler-run'}-stream.json`;
anchor.click();
URL.revokeObjectURL(url);
this.notice.set(`Downloaded the scheduler stream snapshot for ${this.runId() || 'this run'}.`);
}
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 buildEvents(run: SchedulerRun | null): SchedulerRunEvent[] {
if (!run) {
return [];
}
const events: SchedulerRunEvent[] = [
{
timestamp: run.triggeredAt,
level: 'info',
message: `Run triggered by ${run.triggeredBy}.`,
},
{
timestamp: run.startedAt ?? run.triggeredAt,
level: run.status === 'queued' ? 'warning' : 'info',
message: run.startedAt ? 'Worker lease established.' : 'Run is waiting for worker capacity.',
},
];
if (run.completedAt) {
events.push({
timestamp: run.completedAt,
level: run.status === 'failed' ? 'error' : 'info',
message:
run.status === 'failed'
? run.error ?? 'Run failed during scheduler execution.'
: 'Run completed successfully.',
});
} else if (run.status === 'running') {
events.push({
timestamp: new Date().toISOString(),
level: 'info',
message: `Live progress snapshot: ${run.itemsProcessed}/${run.itemsTotal} items processed.`,
});
}
return events.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
}
}

View File

@@ -12,6 +12,7 @@ import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
import { OPERATIONS_PATHS, schedulerRunStreamPath } from '../platform/ops/operations-paths';
/**
* Scheduler Runs Component (Sprint: SPRINT_20251229_017)
@@ -28,10 +29,10 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
<p>Monitor and manage scheduled task executions.</p>
</div>
<div class="header-actions">
<button class="btn btn-secondary" routerLink="/platform-ops/scheduler/schedules">
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerSchedules">
Manage Schedules
</button>
<button class="btn btn-secondary" routerLink="/platform-ops/scheduler/workers">
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerWorkers">
Worker Fleet
</button>
</div>
@@ -194,7 +195,7 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
</button>
<a
class="btn btn-secondary"
[routerLink]="['/platform-ops/scheduler/runs', run.id, 'stream']"
[routerLink]="schedulerRunStreamPath(run.id)"
(click)="$event.stopPropagation()"
>
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('');

View File

@@ -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 {
<div class="worker-fleet">
<header class="page-header">
<div class="header-content">
<a routerLink="/platform-ops/scheduler/runs" class="back-link"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Back to Runs</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" class="back-link"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Back to Runs</a>
<h1>Worker Fleet</h1>
<p>Monitor worker status, load distribution, and health.</p>
</div>
@@ -581,6 +582,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkerFleetComponent {
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
readonly actionNotice = signal<string | null>(null);
readonly workers = signal<Worker[]>([

View File

@@ -10,6 +10,7 @@ import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@ang
import { Router, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
import { scannerOpsPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-security-overview-page',
@@ -341,7 +342,7 @@ export class SecurityOverviewPageComponent implements OnInit {
}
runScan(): void {
void this.router.navigateByUrl('/ops/scanner');
void this.router.navigateByUrl(scannerOpsPath());
}
}

View File

@@ -4,6 +4,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { SloClient } from '../../core/api/slo.client';
import { deadLetterQueuePath } from '../platform/ops/operations-paths';
import {
SloDefinition,
SloState,
@@ -56,7 +57,7 @@ import {
Configure Alerts
</button>
<a
routerLink="/ops/jobengine/dead-letter"
[routerLink]="deadLetterQueuePath()"
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50"
>
View Dead-Letter
@@ -378,6 +379,7 @@ import {
`]
})
export class SloDetailComponent implements OnInit {
protected readonly deadLetterQueuePath = deadLetterQueuePath;
private readonly route = inject(ActivatedRoute);
private readonly sloClient = inject(SloClient);

View File

@@ -47,6 +47,16 @@ export const OPS_ROUTES: Routes = [
loadChildren: () =>
import('../features/scanner-ops/scanner-ops.routes').then((m) => m.scannerOpsRoutes),
},
{
path: 'scanner',
redirectTo: 'scanner-ops',
pathMatch: 'full',
},
{
path: 'scanner/:page',
redirectTo: preserveOpsRedirect('/ops/scanner-ops/:page'),
pathMatch: 'full',
},
{
path: 'agents',
title: 'Agents',
@@ -89,6 +99,16 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/feeds-airgap',
pathMatch: 'full',
},
{
path: 'health',
redirectTo: 'operations/health-slo',
pathMatch: 'full',
},
{
path: 'health/incidents',
redirectTo: 'operations/health-slo/incidents',
pathMatch: 'full',
},
{
path: 'health-slo',
redirectTo: 'operations/health-slo',
@@ -104,6 +124,56 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/health-slo/incidents',
pathMatch: 'full',
},
{
path: 'jobengine',
redirectTo: 'operations/jobengine',
pathMatch: 'full',
},
{
path: 'jobengine/jobs',
redirectTo: 'operations/jobengine/jobs',
pathMatch: 'full',
},
{
path: 'jobengine/jobs/:jobId',
redirectTo: preserveOpsRedirect('/ops/operations/jobengine/jobs/:jobId'),
pathMatch: 'full',
},
{
path: 'jobengine/jobs/:jobId/dag',
redirectTo: preserveOpsRedirect('/ops/operations/jobengine/jobs/:jobId/dag'),
pathMatch: 'full',
},
{
path: 'jobengine/quotas',
redirectTo: 'operations/jobengine/quotas',
pathMatch: 'full',
},
{
path: 'jobengine/dead-letter',
redirectTo: 'operations/dead-letter',
pathMatch: 'full',
},
{
path: 'jobengine/dead-letter/queue',
redirectTo: 'operations/dead-letter/queue',
pathMatch: 'full',
},
{
path: 'jobengine/dead-letter/entry/:entryId',
redirectTo: preserveOpsRedirect('/ops/operations/dead-letter/entry/:entryId'),
pathMatch: 'full',
},
{
path: 'jobengine/slo',
loadChildren: () =>
import('../features/slo-monitoring/slo.routes').then((m) => m.sloRoutes),
},
{
path: 'jobengine/slo/:rest',
redirectTo: preserveOpsRedirect('/ops/jobengine/slo/:rest'),
pathMatch: 'full',
},
{
path: 'signals',
redirectTo: 'operations/signals',
@@ -114,6 +184,31 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/scheduler',
pathMatch: 'full',
},
{
path: 'scheduler/runs',
redirectTo: 'operations/scheduler/runs',
pathMatch: 'full',
},
{
path: 'scheduler/runs/:runId',
redirectTo: preserveOpsRedirect('/ops/operations/scheduler/runs/:runId/stream'),
pathMatch: 'full',
},
{
path: 'scheduler/runs/:runId/stream',
redirectTo: preserveOpsRedirect('/ops/operations/scheduler/runs/:runId/stream'),
pathMatch: 'full',
},
{
path: 'scheduler/schedules',
redirectTo: 'operations/scheduler/schedules',
pathMatch: 'full',
},
{
path: 'scheduler/workers',
redirectTo: 'operations/scheduler/workers',
pathMatch: 'full',
},
{
path: 'offline-kit',
redirectTo: 'operations/offline-kit',

View File

@@ -4,6 +4,7 @@ import { Params, Router, Routes } from '@angular/router';
import {
OPERATIONS_PATHS,
OPERATIONS_SETUP_PATHS,
jobEngineDagPath,
} from '../features/platform/ops/operations-paths';
interface LegacyPlatformOpsRedirect {
@@ -64,7 +65,7 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
{ path: 'jobengine/jobs', redirectTo: OPERATIONS_PATHS.jobEngineJobs },
{
path: 'jobengine/jobs/:jobId/dag',
redirectTo: `${OPERATIONS_PATHS.jobEngineJobs}/:jobId`,
redirectTo: jobEngineDagPath(':jobId'),
},
{
path: 'jobengine/jobs/:jobId',
@@ -73,10 +74,10 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
{ path: 'jobengine/quotas', redirectTo: OPERATIONS_PATHS.jobEngineQuotas },
{ path: 'scheduler', redirectTo: OPERATIONS_PATHS.schedulerRuns },
{ path: 'scheduler/runs', redirectTo: OPERATIONS_PATHS.schedulerRuns },
{ path: 'scheduler/runs/:runId', redirectTo: OPERATIONS_PATHS.schedulerRuns },
{ path: 'scheduler/runs/:runId', redirectTo: `${OPERATIONS_PATHS.schedulerRuns}/:runId/stream` },
{
path: 'scheduler/runs/:runId/stream',
redirectTo: OPERATIONS_PATHS.schedulerRuns,
redirectTo: `${OPERATIONS_PATHS.schedulerRuns}/:runId/stream`,
},
{
path: 'scheduler/schedules',
@@ -84,6 +85,8 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
},
{ path: 'scheduler/workers', redirectTo: OPERATIONS_PATHS.schedulerWorkers },
{ path: 'dead-letter', redirectTo: OPERATIONS_PATHS.deadLetter },
{ path: 'dead-letter/queue', redirectTo: `${OPERATIONS_PATHS.deadLetter}/queue` },
{ path: 'dead-letter/entry/:entryId', redirectTo: `${OPERATIONS_PATHS.deadLetter}/entry/:entryId` },
];
export const PLATFORM_OPS_ROUTES: Routes = [

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, provideRouter, ActivatedRoute } from '@angular/router';
import { convertToParamMap, provideRouter, ActivatedRoute, Router } from '@angular/router';
import { of } from 'rxjs';
import { DeadLetterEntryDetailComponent } from '../../app/features/deadletter/deadletter-entry-detail.component';
@@ -99,4 +99,29 @@ describe('DeadLetterEntryDetailComponent (deadletter)', () => {
).toBe('Manual Fix - Processed manually outside system');
expect(component.formatResolutionReason(undefined)).toBe('-');
});
it('navigates to the canonical JobEngine detail route after a successful replay', () => {
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
component.confirmReplay();
expect(clientSpy.replay).toHaveBeenCalledWith('dlq-1', component.replayOptions);
expect(navigateSpy).toHaveBeenCalledWith('/ops/operations/jobengine/jobs/job-2');
expect(component.notice()).toContain('Replay queued as JobEngine job job-2.');
});
it('stores the chosen resolution reason after resolving an entry', () => {
component.resolveReason = 'manual_fix';
component.resolveNotes = 'handled by operator';
component.confirmResolve();
expect(clientSpy.resolve).toHaveBeenCalledWith('dlq-1', {
reason: 'manual_fix',
notes: 'handled by operator',
});
expect(component.entry()?.resolutionReason).toBe('manual_fix');
expect(component.notice()).toContain('Manual Fix');
});
});

View File

@@ -46,9 +46,13 @@ describe('DeadLetterQueueComponent (deadletter)', () => {
clientSpy = jasmine.createSpyObj('DeadLetterClient', [
'list',
'export',
'replay',
'batchResolve',
]) as jasmine.SpyObj<DeadLetterClient>;
clientSpy.list.and.returnValue(of({ items: entries, total: 2, cursor: 'next-1' }));
clientSpy.export.and.returnValue(of(new Blob(['x'], { type: 'text/csv' })));
clientSpy.replay.and.returnValue(of({ success: true, newJobId: 'job-rerun' }));
clientSpy.batchResolve.and.returnValue(of({ resolved: 2 }));
await TestBed.configureTestingModule({
imports: [DeadLetterQueueComponent],
@@ -83,9 +87,41 @@ describe('DeadLetterQueueComponent (deadletter)', () => {
it('supports select-all and clear-selection flows', () => {
component.toggleSelectAll();
expect(component.selectedIds()).toEqual(['dlq-1', 'dlq-2']);
expect(component.selectedIds()).toEqual(['dlq-2', 'dlq-1']);
component.clearSelection();
expect(component.selectedIds()).toEqual([]);
});
it('applies client-side search filters for queue browsing', () => {
component.filter.search = 'job-2';
component.applyFilters();
expect(component.entries().map((entry) => entry.id)).toEqual(['dlq-2']);
expect(component.totalEntries()).toBe(1);
});
it('replays selected entries via the dead-letter client and surfaces an operator notice', () => {
component.toggleSelectAll();
component.replaySelected();
fixture.detectChanges();
expect(clientSpy.replay).toHaveBeenCalledTimes(2);
expect(component.notice()).toContain('Queued replay for 2 selected entries.');
expect(component.selectedIds()).toEqual([]);
});
it('resolves selected entries through the batch resolve flow', () => {
component.toggleSelectAll();
component.resolveSelected();
fixture.detectChanges();
expect(clientSpy.batchResolve).toHaveBeenCalledWith(['dlq-2', 'dlq-1'], {
reason: 'manual_fix',
notes: 'Resolved from the dead-letter queue browser bulk action.',
});
expect(component.notice()).toContain('Resolved 2 selected entries.');
});
});

View File

@@ -0,0 +1,143 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { ORCHESTRATOR_CONTROL_API } from '../../app/core/api/jobengine-control.client';
import { JobEngineJobsClient } from '../../app/core/api/jobengine-jobs.client';
import { AUTH_SERVICE } from '../../app/core/auth';
import { JobEngineJobsComponent } from '../../app/features/jobengine/jobengine-jobs.component';
import { JobEngineQuotasComponent } from '../../app/features/jobengine/jobengine-quotas.component';
describe('jobengine execution ops behavior', () => {
it('keeps jobs actions on canonical JobEngine and dead-letter routes', async () => {
await TestBed.configureTestingModule({
imports: [JobEngineJobsComponent],
providers: [
provideRouter([]),
{
provide: JobEngineJobsClient,
useValue: {
listJobs: () =>
of({
jobs: [
{
jobId: 'job-001',
jobType: 'scan',
status: 'failed',
priority: 'high',
attempt: 2,
maxAttempts: 3,
createdAt: '2026-03-08T10:00:00Z',
scheduledAt: '2026-03-08T10:00:00Z',
completedAt: '2026-03-08T10:05:00Z',
workerId: 'worker-1',
correlationId: 'corr-001',
createdBy: 'operator',
projectId: 'proj-a',
runId: 'run-001',
reason: 'dead-letter candidate',
},
],
nextCursor: null,
}),
getJobDetail: () => of(null),
getJobParents: () => of([]),
getJobChildren: () => of([]),
},
},
{
provide: ORCHESTRATOR_CONTROL_API,
useValue: {
getDeadLetterStats: () => of({ totalEntries: 4 }),
},
},
],
}).compileComponents();
const fixture: ComponentFixture<JobEngineJobsComponent> = TestBed.createComponent(JobEngineJobsComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
component['toggleExpand']('job-001');
fixture.detectChanges();
const links = Array.from(
fixture.nativeElement.querySelectorAll('.job-card__actions a') as NodeListOf<HTMLAnchorElement>
).map((anchor) => anchor.getAttribute('ng-reflect-router-link') ?? anchor.getAttribute('href') ?? '');
expect(links).toContain('/ops/operations/jobengine/jobs/job-001');
expect(links).toContain('/ops/operations/jobengine/jobs/job-001/dag');
expect(links).toContain('/ops/operations/dead-letter/queue');
});
it('pauses quotas through the control API and exports a snapshot', async () => {
const pauseQuotaSpy = jasmine.createSpy('pauseQuota').and.returnValue(of({}));
await TestBed.configureTestingModule({
imports: [JobEngineQuotasComponent],
providers: [
provideRouter([]),
{
provide: AUTH_SERVICE,
useValue: {
canManageJobEngineQuotas: () => true,
},
},
{
provide: ORCHESTRATOR_CONTROL_API,
useValue: {
listQuotas: () =>
of({
items: [
{
quotaId: 'quota-001',
tenantId: 'tenant-a',
jobType: 'scan',
currentActive: 1,
maxActive: 4,
currentHourCount: 10,
maxPerHour: 100,
currentTokens: 50,
burstCapacity: 100,
refillRate: 5,
paused: false,
},
],
count: 1,
continuationToken: null,
}),
getQuotaSummary: () =>
of({
totalQuotas: 1,
pausedQuotas: 0,
averageTokenUtilization: 0.5,
averageConcurrencyUtilization: 0.25,
}),
pauseQuota: pauseQuotaSpy,
resumeQuota: () => of({}),
},
},
],
}).compileComponents();
const fixture: ComponentFixture<JobEngineQuotasComponent> = TestBed.createComponent(JobEngineQuotasComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:quotas');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.callFake(() => {});
component['togglePause'](component['quotas']()[0] as any);
component['exportSnapshot']();
expect(pauseQuotaSpy).toHaveBeenCalledWith('quota-001', {
reason: 'Paused from execution operations UI',
ticket: null,
});
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:quotas');
expect(anchorClickSpy).toHaveBeenCalled();
expect(component['notice']()).toContain('Downloaded the current JobEngine quota snapshot.');
});
});

View File

@@ -27,17 +27,35 @@ describe('Platform and Operations route contracts', () => {
it('keeps shorthand ops aliases pointed at the operations shell', () => {
const aliases = OPS_ROUTES.filter((route) => route.redirectTo);
expect(aliases.map((route) => route.path)).toEqual([
'scanner',
'scanner/:page',
'feeds',
'feeds/version-locks',
'feeds/airgap/import',
'feeds/airgap/export',
'feeds-airgap',
'airgap',
'health',
'health/incidents',
'health-slo',
'health-slo/services/:serviceName',
'health-slo/incidents',
'jobengine',
'jobengine/jobs',
'jobengine/jobs/:jobId',
'jobengine/jobs/:jobId/dag',
'jobengine/quotas',
'jobengine/dead-letter',
'jobengine/dead-letter/queue',
'jobengine/dead-letter/entry/:entryId',
'jobengine/slo/:rest',
'signals',
'scheduler',
'scheduler/runs',
'scheduler/runs/:runId',
'scheduler/runs/:runId/stream',
'scheduler/schedules',
'scheduler/workers',
'offline-kit',
'offline-kit/:page',
'quotas',

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AnalyzerHealthComponent } from '../../app/features/scanner-ops/components/analyzer-health.component';
import { BaselineListComponent } from '../../app/features/scanner-ops/components/baseline-list.component';
import { OfflineKitListComponent } from '../../app/features/scanner-ops/components/offline-kit-list.component';
import { PerformanceBaselineComponent } from '../../app/features/scanner-ops/components/performance-baseline.component';
describe('scanner-ops supporting flows', () => {
it('verifies and exports offline kit manifests without console placeholders', async () => {
await TestBed.configureTestingModule({
imports: [OfflineKitListComponent],
providers: [provideRouter([])],
}).compileComponents();
const fixture: ComponentFixture<OfflineKitListComponent> = TestBed.createComponent(OfflineKitListComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:offline-kit');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.callFake(() => {});
component.verifyAll();
component.download(component.kits()[0]);
expect(component.notice()).toContain('Downloaded offline kit manifest');
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:offline-kit');
expect(anchorClickSpy).toHaveBeenCalled();
});
it('creates and promotes local scanner baselines as an honest fallback workflow', async () => {
await TestBed.configureTestingModule({
imports: [BaselineListComponent],
}).compileComponents();
const fixture: ComponentFixture<BaselineListComponent> = TestBed.createComponent(BaselineListComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
component.createBaseline();
expect(component.baselines()[0].name).toContain('Local Draft Baseline');
spyOn(window, 'confirm').and.returnValue(true);
const activeBaseline = component.baselines().find((baseline) => baseline.status === 'active');
expect(activeBaseline).toBeTruthy();
component.promote(activeBaseline!);
expect(component.baselines().find((baseline) => baseline.status === 'promoted')?.id).toBe(activeBaseline!.id);
expect(component.notice()).toContain('Promoted');
});
it('refreshes analyzer and performance snapshots with operator-visible notices', async () => {
await TestBed.configureTestingModule({
imports: [AnalyzerHealthComponent, PerformanceBaselineComponent],
providers: [provideRouter([])],
}).compileComponents();
const analyzerFixture: ComponentFixture<AnalyzerHealthComponent> = TestBed.createComponent(AnalyzerHealthComponent);
const analyzerComponent = analyzerFixture.componentInstance;
analyzerFixture.detectChanges();
analyzerComponent.refreshAll();
expect(analyzerComponent.notice()).toContain('local analyzer status snapshot');
expect(analyzerComponent.analyzers().find((item) => item.status === 'healthy')?.lastRunAt).toBeTruthy();
const performanceFixture: ComponentFixture<PerformanceBaselineComponent> = TestBed.createComponent(PerformanceBaselineComponent);
const performanceComponent = performanceFixture.componentInstance;
performanceFixture.detectChanges();
const before = performanceComponent.metrics()[0].current;
performanceComponent.refreshMetrics();
expect(performanceComponent.metrics()[0].current).not.toBe(before);
expect(performanceComponent.notice()).toContain('local performance snapshot');
});
});

View File

@@ -1,11 +1,13 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { ActivatedRoute, convertToParamMap, provideRouter, RouterLink } from '@angular/router';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { ScheduleManagementComponent } from '../../app/features/scheduler-ops/schedule-management.component';
import { SchedulerRunsComponent } from '../../app/features/scheduler-ops/scheduler-runs.component';
import { Schedule, SchedulerRun, Worker } from '../../app/features/scheduler-ops/scheduler-ops.models';
import { SchedulerRunStreamComponent } from '../../app/features/scheduler-ops/scheduler-run-stream.component';
import { WorkerFleetComponent } from '../../app/features/scheduler-ops/worker-fleet.component';
import { SCHEDULER_API, type SchedulerApi, type CreateScheduleDto, type UpdateScheduleDto } from '../../app/core/api/scheduler.client';
@@ -194,28 +196,40 @@ describe('scheduler-orchestrator-ops-ui behavior', () => {
expect(component.actionNotice()).toContain('reconnected');
});
it('keeps scheduler action links within /platform-ops/scheduler subtree', () => {
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.header-actions .btn.btn-secondary') as NodeListOf<HTMLButtonElement>
it('keeps scheduler action links within the canonical operations subtree', () => {
const directives = fixture.debugElement
.queryAll(By.directive(RouterLink))
.map((debugElement) => ({
text: debugElement.nativeElement.textContent ?? '',
routerLink: debugElement.injector.get(RouterLink),
}));
const manageLink = directives.find((item) =>
item.text.includes('Manage Schedules')
);
const manageButton = buttons.find((button) =>
(button.textContent ?? '').includes('Manage Schedules')
);
const workerButton = buttons.find((button) =>
(button.textContent ?? '').includes('Worker Fleet')
const workerLink = directives.find((item) =>
item.text.includes('Worker Fleet')
);
const manageLink =
manageButton?.getAttribute('ng-reflect-router-link') ??
manageButton?.getAttribute('routerLink') ??
'';
const workerLink =
workerButton?.getAttribute('ng-reflect-router-link') ??
workerButton?.getAttribute('routerLink') ??
expect(manageLink?.routerLink.urlTree?.toString()).toContain('/ops/operations/scheduler/schedules');
expect(workerLink?.routerLink.urlTree?.toString()).toContain('/ops/operations/scheduler/workers');
});
it('renders live stream links inside the canonical operations subtree', () => {
component.runs.set([createRun()]);
component.toggleExpand('run-001');
fixture.detectChanges();
const liveStreamLink = Array.from(
fixture.nativeElement.querySelectorAll('.run-actions a.btn.btn-secondary') as NodeListOf<HTMLAnchorElement>
).find((anchor) => (anchor.textContent ?? '').includes('Live Stream'));
const reflectedLink =
liveStreamLink?.getAttribute('ng-reflect-router-link') ??
liveStreamLink?.getAttribute('href') ??
'';
expect(manageLink).toContain('/platform-ops/scheduler/schedules');
expect(workerLink).toContain('/platform-ops/scheduler/workers');
expect(reflectedLink).toContain('/ops/operations/scheduler/runs/run-001/stream');
});
});
@@ -266,12 +280,12 @@ describe('scheduler-orchestrator-ops-ui behavior', () => {
expect(created?.tags).toEqual(['alpha', 'beta', 'prod']);
});
it('uses canonical back link to platform ops scheduler runs', () => {
it('uses canonical back link to operations scheduler runs', () => {
const backLink = fixture.nativeElement.querySelector('.back-link') as HTMLAnchorElement | null;
expect(backLink).not.toBeNull();
const reflectedLink = backLink?.getAttribute('ng-reflect-router-link') ?? backLink?.getAttribute('href') ?? '';
expect(reflectedLink).toContain('/platform-ops/scheduler/runs');
expect(reflectedLink).toContain('/ops/operations/scheduler/runs');
});
});
@@ -300,12 +314,71 @@ describe('scheduler-orchestrator-ops-ui behavior', () => {
expect(entries.map((entry) => entry.version)).toEqual(['2.0.0', '2.1.0']);
});
it('uses canonical back link to platform ops scheduler runs', () => {
it('uses canonical back link to operations scheduler runs', () => {
const backLink = fixture.nativeElement.querySelector('.back-link') as HTMLAnchorElement | null;
expect(backLink).not.toBeNull();
const reflectedLink = backLink?.getAttribute('ng-reflect-router-link') ?? backLink?.getAttribute('href') ?? '';
expect(reflectedLink).toContain('/platform-ops/scheduler/runs');
expect(reflectedLink).toContain('/ops/operations/scheduler/runs');
});
});
describe('SchedulerRunStreamComponent', () => {
let fixture: ComponentFixture<SchedulerRunStreamComponent>;
let component: SchedulerRunStreamComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SchedulerRunStreamComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ runId: 'run-001' })),
},
},
{
provide: HttpClient,
useValue: {
get: () => of([createRun()]),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(SchedulerRunStreamComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads run stream data for the requested run id', () => {
expect(component['runId']()).toBe('run-001');
expect(component['run']()?.scheduleName).toBe('Daily Sync');
expect(component['events']().length).toBeGreaterThan(0);
});
it('exports and copies the scheduler run stream snapshot from canonical route state', async () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:stream');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.callFake(() => {});
const clipboard = navigator.clipboard ?? { writeText: (_text: string) => Promise.resolve() };
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: clipboard,
});
}
const clipboardWriteSpy = spyOn(clipboard, 'writeText').and.returnValue(Promise.resolve());
component['copyRunId']();
component['exportStream']();
fixture.detectChanges();
expect(clipboardWriteSpy).toHaveBeenCalledWith('run-001');
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:stream');
expect(anchorClickSpy).toHaveBeenCalled();
expect(component['notice']()).toContain('Downloaded the scheduler stream snapshot');
});
});
});

View File

@@ -0,0 +1,367 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const operatorSession: StubAuthSession = {
subjectId: 'ops-e2e-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'orch:read',
'orch:operate',
'orch:quota',
'orch:backfill',
'health:read',
'scheduler:read',
'scheduler:trigger',
'policy:read',
'release:read',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const jobList = {
jobs: [
{
tenantId: 'tenant-default',
projectId: 'proj-a',
jobId: 'job-001',
runId: 'run-001',
jobType: 'scan',
status: 'failed',
priority: 10,
attempt: 2,
maxAttempts: 3,
correlationId: 'corr-001',
workerId: 'worker-1',
taskRunnerId: 'runner-a',
createdAt: '2026-03-08T08:00:00Z',
scheduledAt: '2026-03-08T08:00:00Z',
leasedAt: '2026-03-08T08:01:00Z',
completedAt: '2026-03-08T08:05:00Z',
notBefore: '2026-03-08T08:00:00Z',
reason: 'Dead-letter candidate',
replayOf: null,
createdBy: 'operator',
},
],
nextCursor: null,
};
const jobDetail = {
...jobList.jobs[0],
payloadDigest: 'sha256:payload-001',
payload: JSON.stringify({ artifact: 'sha256:artifact-001', action: 'scan' }),
idempotencyKey: 'idem-001',
leaseId: 'lease-001',
leaseUntil: '2026-03-08T08:06:00Z',
};
const quotaList = {
items: [
{
quotaId: 'quota-001',
tenantId: 'tenant-default',
jobType: 'scan',
maxActive: 4,
maxPerHour: 100,
burstCapacity: 100,
refillRate: 5,
currentTokens: 40,
currentActive: 1,
currentHourCount: 10,
paused: false,
pauseReason: null,
quotaTicket: null,
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-08T07:30:00Z',
updatedBy: 'ops@example.com',
},
],
count: 1,
continuationToken: null,
};
const quotaSummary = {
totalQuotas: 1,
pausedQuotas: 0,
averageTokenUtilization: 0.6,
averageConcurrencyUtilization: 0.25,
};
const jobSummary = {
totalJobs: 12,
pendingJobs: 2,
scheduledJobs: 3,
leasedJobs: 1,
succeededJobs: 5,
failedJobs: 1,
canceledJobs: 1,
timedOutJobs: 0,
};
const deadLetterEntries = [
{
id: 'dlq-001',
jobId: 'job-001',
jobType: 'scan',
tenantId: 'tenant-default',
tenantName: 'Default Tenant',
state: 'failed',
errorCode: 'DLQ_TIMEOUT',
errorMessage: 'Scanner timed out',
retryCount: 2,
maxRetries: 3,
age: 7200,
createdAt: '2026-03-08T06:00:00Z',
},
];
const schedulerRuns = [
{
id: 'run-001',
scheduleId: 'sch-001',
scheduleName: 'Nightly Sync',
status: 'running',
triggeredAt: '2026-03-08T07:00:00Z',
startedAt: '2026-03-08T07:01:00Z',
completedAt: null,
durationMs: null,
triggeredBy: 'schedule',
progress: 48,
itemsProcessed: 48,
itemsTotal: 100,
retryCount: 0,
output: { metrics: { scanned: 48, failed: 1 } },
},
];
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, operatorSession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {}));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/branding**', (route) =>
fulfillJson(route, {
tenantId: operatorSession.tenant,
appName: 'Stella Ops',
logoUrl: null,
cssVariables: {},
}),
);
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: operatorSession.subjectId,
username: 'ops-e2e',
displayName: 'Ops E2E',
tenant: operatorSession.tenant,
roles: ['ops-operator'],
scopes: operatorSession.scopes,
}),
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: operatorSession.tenant,
subject: operatorSession.subjectId,
scopes: operatorSession.scopes,
}),
);
await page.route('**/authority/console/tenants**', (route) =>
fulfillJson(route, {
tenants: [
{
tenantId: operatorSession.tenant,
displayName: 'Default Tenant',
isDefault: true,
isActive: true,
},
],
}),
);
// Register the generic API fallback before exact mocks so the exact handlers win.
await page.route('**/api/**', (route) => fulfillJson(route, {}));
await page.route('**/api/v2/context/regions**', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod',
sortOrder: 1,
enabled: true,
},
]),
);
await page.route('**/api/v2/context/preferences**', (route) =>
fulfillJson(route, {
tenantId: operatorSession.tenant,
actorId: operatorSession.subjectId,
regions: ['eu-west'],
environments: ['prod'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-08T07:00:00Z',
updatedBy: operatorSession.subjectId,
}),
);
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/telemetry/ttfs', (route) => fulfillJson(route, { accepted: true }, 202));
await page.route(/\/gateway\/jobengine\/jobs\/summary(?:\?.*)?$/, (route) => fulfillJson(route, jobSummary));
await page.route(/\/gateway\/jobengine\/jobs(?:\?.*)?$/, (route) => fulfillJson(route, jobList));
await page.route(/\/gateway\/jobengine\/jobs\/job-001\/detail(?:\?.*)?$/, (route) => fulfillJson(route, jobDetail));
await page.route(/\/gateway\/jobengine\/dag\/job\/job-001\/parents(?:\?.*)?$/, (route) =>
fulfillJson(route, { edges: [] }),
);
await page.route(/\/gateway\/jobengine\/dag\/job\/job-001\/children(?:\?.*)?$/, (route) =>
fulfillJson(route, {
edges: [
{
edgeId: 'edge-001',
runId: 'run-001',
parentJobId: 'job-001',
childJobId: 'job-002',
edgeType: 'dependency',
createdAt: '2026-03-08T08:02:00Z',
},
],
}),
);
await page.route(/\/gateway\/jobengine\/quotas\/summary(?:\?.*)?$/, (route) => fulfillJson(route, quotaSummary));
await page.route(/\/gateway\/jobengine\/quotas(?:\?.*)?$/, (route) => fulfillJson(route, quotaList));
await page.route(/\/gateway\/jobengine\/quotas\/quota-001\/pause(?:\?.*)?$/, (route) =>
fulfillJson(route, { ...quotaList.items[0], paused: true, pauseReason: 'Paused from execution operations UI' }),
);
await page.route(/\/gateway\/jobengine\/deadletter\/stats(?:\?.*)?$/, (route) =>
fulfillJson(route, {
totalEntries: 1,
pendingEntries: 0,
replayingEntries: 0,
replayedEntries: 0,
resolvedEntries: 0,
exhaustedEntries: 1,
expiredEntries: 0,
retryableEntries: 1,
topErrorCodes: { DLQ_TIMEOUT: 1 },
}),
);
await page.route(/\/api\/v1\/jobengine\/deadletter\/stats(?:\?.*)?$/, (route) =>
fulfillJson(route, {
totalEntries: 1,
pendingEntries: 0,
replayingEntries: 0,
replayedEntries: 0,
resolvedEntries: 0,
exhaustedEntries: 1,
expiredEntries: 0,
retryableEntries: 1,
topErrorCodes: { DLQ_TIMEOUT: 1 },
}),
);
await page.route(/\/api\/v1\/jobengine\/deadletter\/summary(?:\?.*)?$/, (route) =>
fulfillJson(route, { summaries: [{ errorCode: 'DLQ_TIMEOUT', entryCount: 1 }] }),
);
await page.route(/\/api\/v1\/jobengine\/deadletter\/resolve\/batch(?:\?.*)?$/, (route) =>
fulfillJson(route, { resolvedCount: 1 }),
);
await page.route(/\/api\/v1\/jobengine\/deadletter\/dlq-001\/replay(?:\?.*)?$/, (route) =>
fulfillJson(route, { success: true, newJobId: 'job-replay-001' }),
);
await page.route(/\/api\/v1\/jobengine\/deadletter(?:\?.*)?$/, (route) =>
fulfillJson(route, { items: deadLetterEntries, total: deadLetterEntries.length }),
);
await page.route('**/scheduler/api/v1/scheduler/runs**', (route) =>
fulfillJson(route, schedulerRuns),
);
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('execution operations cutover keeps canonical jobengine, dead-letter, scheduler, and scanner flows usable', async ({
page,
}) => {
await page.goto('/ops/operations/jobengine/jobs', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'JobEngine Jobs' })).toBeVisible();
await page.locator('.job-card').first().getByRole('button', { name: 'Expand', exact: true }).click();
await page.getByRole('link', { name: 'View Details' }).click();
await expect(page).toHaveURL(/\/ops\/operations\/jobengine\/jobs\/job-001$/);
await expect(page.getByText('Execution Metadata')).toBeVisible();
await page.goto('/ops/operations/dead-letter/queue', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Dead-Letter Queue' })).toBeVisible();
await page.locator('tbody input[type="checkbox"]').first().check();
await page.getByRole('button', { name: 'Replay Selected' }).click();
await expect(page.getByText('Queued replay for 1 selected entry.')).toBeVisible();
await page.goto('/ops/operations/scheduler/runs', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Scheduler Runs' })).toBeVisible();
await page.getByText('Nightly Sync').click();
await page.getByRole('link', { name: 'Live Stream' }).click();
await expect(page).toHaveURL(/\/ops\/operations\/scheduler\/runs\/run-001\/stream$/);
await expect(page.getByRole('heading', { name: 'Scheduler Run Stream' })).toBeVisible();
await page.goto('/ops/scanner-ops/offline-kits', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Scanner Operations' })).toBeVisible();
await page.getByRole('button', { name: 'Verify All' }).click();
await expect(page.getByText('Verified 2 kits locally.')).toBeVisible();
});