Orchestrator decomposition: replace JobEngine with release-orchestrator + workflow services

- Remove jobengine and jobengine-worker containers from docker-compose
- Create release-orchestrator service (120 endpoints) with full auth, tenant, and infrastructure DI
- Wire workflow engine to PostgreSQL with definition store (wf_definitions table)
- Deploy 4 canonical workflow definitions on startup (release-promotion, scan-execution, advisory-refresh, compliance-sweep)
- Fix workflow definition JSON to match canonical contract schema (set-state, call-transport, decision)
- Add WorkflowClient to release-orchestrator for starting workflow instances on promotion
- Add WorkflowTriggerClient + endpoint to scheduler for triggering workflows from system schedules
- Update gateway routes from jobengine.stella-ops.local to release-orchestrator.stella-ops.local
- Remove Platform.Database dependency on JobEngine.Infrastructure
- Fix workflow csproj duplicate Content items (EmbeddedResource + SDK default)
- System-managed schedules with source column, SystemScheduleBootstrap, inline edit UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 09:57:42 +03:00
parent 1b11e4aafc
commit 9d47cabc37
72 changed files with 7781 additions and 4480 deletions

View File

@@ -1,477 +1,109 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, forkJoin, of } from 'rxjs';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
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';
import { SchedulerRunsComponent } from '../scheduler-ops/scheduler-runs.component';
import { SchedulerSchedulesPanelComponent } from './scheduler-schedules-panel.component';
import { SchedulerWorkersPanelComponent } from './scheduler-workers-panel.component';
type SchedulerView = 'runs' | 'schedules' | 'workers';
@Component({
selector: 'app-jobengine-dashboard',
imports: [RouterLink],
imports: [
SchedulerRunsComponent,
SchedulerSchedulesPanelComponent,
SchedulerWorkersPanelComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jobengine-dashboard">
<header class="jobengine-dashboard__header">
<div>
<h1>Scheduled Jobs</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--primary" type="button" (click)="refresh()" [disabled]="loading()">
Refresh
</button>
</div>
</header>
@if (loadError()) {
<div class="jobengine-dashboard__banner jobengine-dashboard__banner--error" role="alert">
{{ loadError() }}
</div>
}
@if (loading()) {
<section class="jobengine-dashboard__kpis">
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
</section>
} @else {
<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>
}
@if (allCountsZero()) {
<p class="jobengine-dashboard__empty-hint">
No jobs have been submitted yet. Jobs are created automatically when releases are promoted, scans are triggered, or scheduled tasks run.
</p>
}
<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.canManageJobEngineQuotas()) {
<a class="surface" data-testid="jobengine-quotas-card" [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>
} @else {
<article class="surface surface--restricted" data-testid="jobengine-quotas-card" aria-disabled="true">
<h2>Execution Quotas</h2>
<p>Quota metrics are visible, but management stays locked until the session has quota-admin scope.</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>
<span class="surface__notice">Access required to manage quotas.</span>
</article>
<div class="schedules">
<div class="schedules__segmented" role="radiogroup" aria-label="Scheduler view">
@for (opt of schedulerViews; track opt.id) {
<button
type="button"
role="radio"
class="schedules__seg-btn"
[class.schedules__seg-btn--active]="activeView() === opt.id"
[attr.aria-checked]="activeView() === opt.id"
(click)="activeView.set(opt.id)"
>{{ opt.label }}</button>
}
<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>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>
@switch (activeView()) {
@case ('runs') { <app-scheduler-runs /> }
@case ('schedules') { <app-scheduler-schedules-panel /> }
@case ('workers') { <app-scheduler-workers-panel /> }
}
</div>
`,
styles: [`
.jobengine-dashboard {
.schedules {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
display: grid;
gap: 1.5rem;
}
.jobengine-dashboard__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.jobengine-dashboard__header h1 {
margin: 0 0 0.35rem;
font-size: 1.8rem;
color: var(--color-text-heading);
}
.jobengine-dashboard__header p {
margin: 0;
color: var(--color-text-secondary);
}
.jobengine-dashboard__actions {
display: flex;
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);
background: var(--color-surface-primary);
padding: 1.1rem;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.kpi:hover,
a.surface:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.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;
color: var(--color-text-heading);
}
.kpi__hint {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.jobengine-dashboard__empty-hint {
margin: 0;
padding: 0.75rem 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
border-left: 3px solid var(--color-border-primary);
}
.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;
}
a.surface:hover {
border-color: var(--color-brand-primary);
}
.surface h2 {
margin: 0;
font-size: 1.1rem;
color: var(--color-text-heading);
}
.surface--restricted {
cursor: default;
opacity: 0.85;
}
.surface--restricted:hover {
transform: none;
box-shadow: none;
}
.surface p {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.5;
font-size: 0.88rem;
}
.surface dl {
margin: 0;
display: grid;
gap: 0.55rem;
}
.surface dl div {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.88rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--color-border-primary);
}
.surface dl div:last-child {
border-bottom: none;
}
.surface dt {
color: var(--color-text-secondary);
}
.surface dd {
margin: 0;
color: var(--color-text-heading);
font-weight: var(--font-weight-semibold);
}
.surface__notice {
.schedules__segmented {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border-radius: 9999px;
background: var(--color-status-warning-bg);
color: var(--color-status-warning-border);
font-size: 0.78rem;
font-weight: var(--font-weight-medium);
}
.jobengine-dashboard__access ul {
margin: 0;
padding-left: 1.2rem;
color: var(--color-text-secondary);
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.9rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
text-decoration: none;
cursor: pointer;
font-size: 0.85rem;
overflow: hidden;
align-self: start;
}
.schedules__seg-btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.85rem;
border: none;
background: transparent;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 500;
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
.schedules__seg-btn:hover:not(.schedules__seg-btn--active) {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
.schedules__seg-btn--active {
background: var(--color-surface-primary);
border-color: var(--color-brand-primary);
color: var(--color-text-heading);
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.schedules__seg-btn:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
.btn:active:not(:disabled) {
transform: translateY(1px);
/* Hide redundant headers from embedded sub-components */
.schedules ::ng-deep .page-header,
.schedules ::ng-deep .back-link {
display: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.schedules ::ng-deep .stats-row,
.schedules ::ng-deep .stat-card,
.schedules ::ng-deep .connection-banner {
display: none;
}
.btn--primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border-color: var(--color-btn-primary-bg);
}
.btn--primary:hover:not(:disabled) {
filter: brightness(1.1);
background: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-bg);
}
.shimmer {
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-surface-secondary) 25%, var(--color-surface-tertiary, rgba(255,255,255,0.08)) 50%, var(--color-surface-secondary) 75%);
background-size: 200% 100%;
animation: shimmer-move 1.5s infinite ease-in-out;
}
.shimmer--lg {
height: 2rem;
width: 60%;
}
.shimmer--sm {
height: 0.85rem;
width: 80%;
margin-top: 0.5rem;
}
@keyframes shimmer-move {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 960px) {
.jobengine-dashboard__header,
.jobengine-dashboard__kpis,
.jobengine-dashboard__grid {
grid-template-columns: 1fr;
display: grid;
}
.schedules ::ng-deep .filter-bar__select,
.schedules ::ng-deep .filter-bar__btn {
flex: 0 0 auto !important;
width: auto !important;
}
`],
})
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;
export class JobEngineDashboardComponent {
protected readonly activeView = signal<SchedulerView>('runs');
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);
protected readonly allCountsZero = computed(() => {
const jobs = this.jobSummary();
const quotas = this.quotaSummary();
const dl = this.deadLetterStats();
return !this.loading()
&& (jobs?.totalJobs ?? 0) === 0
&& (quotas?.totalQuotas ?? 0) === 0
&& (dl?.totalEntries ?? 0) === 0;
});
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)}%`;
}
protected readonly schedulerViews: { id: SchedulerView; label: string }[] = [
{ id: 'runs', label: 'Runs' },
{ id: 'schedules', label: 'Schedules' },
{ id: 'workers', label: 'Workers' },
];
}

View File

@@ -6,422 +6,202 @@ import { catchError, of } from 'rxjs';
import { JobEngineJobsClient, type JobEngineJobRecord } from '../../core/api/jobengine-jobs.client';
import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client';
import { OPERATIONS_PATHS, deadLetterQueuePath, jobEngineDagPath, jobEngineJobPath } from '../platform/ops/operations-paths';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
import { DateFormatService } from '../../core/i18n/date-format.service';
@Component({
selector: 'app-jobengine-jobs',
imports: [FormsModule, RouterLink],
imports: [FormsModule, RouterLink, StellaFilterMultiComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jobengine-jobs">
<header class="jobengine-jobs__header">
<div>
<a [routerLink]="OPERATIONS_PATHS.jobEngine" class="jobengine-jobs__back">&larr; Back to JobEngine</a>
<h1>JobEngine Jobs</h1>
<p>Browse execution records, inspect payload lineage, and follow dependency edges.</p>
<div class="je-jobs">
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="search" class="filter-bar__input" [(ngModel)]="searchQuery" placeholder="Job ID, type, correlation ID..." />
</div>
<div class="jobengine-jobs__actions">
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.jobsQueues">Jobs & Queues</a>
<button class="btn btn--secondary" type="button" (click)="refresh()" [disabled]="loading()">
Refresh
</button>
</div>
</header>
@if (loadError()) {
<div class="jobengine-jobs__banner jobengine-jobs__banner--error" role="alert">
{{ loadError() }}
</div>
}
@if (actionNotice()) {
<div class="jobengine-jobs__banner jobengine-jobs__banner--info" role="status">
{{ actionNotice() }}
</div>
}
<section class="jobengine-jobs__stats">
<article class="stat-card">
<strong>{{ stats().total }}</strong>
<span>Total</span>
</article>
<article class="stat-card">
<strong>{{ stats().running }}</strong>
<span>Running</span>
</article>
<article class="stat-card">
<strong>{{ stats().completed }}</strong>
<span>Completed</span>
</article>
<article class="stat-card stat-card--warning">
<strong>{{ stats().failed }}</strong>
<span>Failed</span>
</article>
<article class="stat-card stat-card--danger">
<strong>{{ deadLetterCount() }}</strong>
<span>Dead-Letter</span>
</article>
</section>
<section class="jobengine-jobs__filters">
<label>
Search
<input type="search" [(ngModel)]="searchQuery" placeholder="Job ID, type, correlation ID" />
</label>
<label>
Status
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
@for (status of statusOptions; track status) {
<option [value]="status">{{ status }}</option>
}
</select>
</label>
<label>
Type
<select [(ngModel)]="typeFilter">
<option value="">All types</option>
@for (jobType of typeOptions(); track jobType) {
<option [value]="jobType">{{ jobType }}</option>
}
</select>
</label>
</section>
<section class="jobengine-jobs__list">
@if (loading()) {
<div class="empty-state">Loading jobs...</div>
} @else if (!filteredJobs().length) {
<div class="empty-state">No jobs match the current filters.</div>
} @else {
@for (job of filteredJobs(); track job.jobId) {
<article class="job-card">
<div class="job-card__summary">
<div>
<div class="job-card__title-row">
<span class="job-card__type">{{ job.jobType }}</span>
<span class="job-card__status" [class]="'job-card__status--' + statusTone(job.status)">
{{ job.status }}
</span>
</div>
<h2>{{ job.jobId }}</h2>
<p>{{ jobDescription(job) }}</p>
</div>
<button class="btn btn--ghost" type="button" (click)="toggleExpand(job.jobId)">
{{ expandedJobId() === job.jobId ? 'Collapse' : 'Expand' }}
</button>
</div>
@if (expandedJobId() === job.jobId) {
<div class="job-card__details">
<dl>
<div><dt>Run</dt><dd>{{ job.runId || '-' }}</dd></div>
<div><dt>Priority</dt><dd>{{ job.priority }}</dd></div>
<div><dt>Attempts</dt><dd>{{ job.attempt }} / {{ job.maxAttempts }}</dd></div>
<div><dt>Created</dt><dd>{{ formatDateTime(job.createdAt) }}</dd></div>
<div><dt>Scheduled</dt><dd>{{ formatDateTime(job.scheduledAt) }}</dd></div>
<div><dt>Completed</dt><dd>{{ formatDateTime(job.completedAt) }}</dd></div>
<div><dt>Worker</dt><dd>{{ job.workerId || '-' }}</dd></div>
<div><dt>Correlation</dt><dd>{{ job.correlationId || '-' }}</dd></div>
</dl>
@if (job.reason) {
<div class="job-card__reason">
<strong>Reason</strong>
<p>{{ job.reason }}</p>
</div>
}
<div class="job-card__actions">
<a class="btn btn--secondary" [routerLink]="jobEngineJobPath(job.jobId)">View Details</a>
@if (job.runId) {
<a class="btn btn--secondary" [routerLink]="jobEngineDagPath(job.jobId)">View DAG</a>
}
@if (job.status === 'failed') {
<a class="btn btn--secondary" [routerLink]="deadLetterQueuePath()">Open Dead-Letter</a>
}
@if (job.correlationId) {
<button class="btn btn--secondary" type="button" (click)="copyText(job.correlationId!)">
Copy CorrID
</button>
}
</div>
</div>
}
</article>
}
<stella-filter-multi
label="Status"
[options]="statusMultiOptions()"
(optionsChange)="onStatusFilterChange($event)"
/>
@if (typeMultiOptions().length > 1) {
<stella-filter-multi
label="Type"
[options]="typeMultiOptions()"
(optionsChange)="onTypeFilterChange($event)"
/>
}
</section>
<button class="filter-bar__btn" type="button" (click)="refresh()" [disabled]="loading()">Refresh</button>
</div>
@if (loading()) {
<div class="empty-state">Loading jobs...</div>
} @else if (!filteredJobs().length) {
<div class="empty-state">No jobs match the current filters.</div>
} @else {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th class="sortable" (click)="toggleSort('jobType')">Type {{ sortIcon('jobType') }}</th>
<th class="sortable" (click)="toggleSort('jobId')">Job ID {{ sortIcon('jobId') }}</th>
<th class="sortable" (click)="toggleSort('status')">Status {{ sortIcon('status') }}</th>
<th class="sortable" (click)="toggleSort('priority')">Priority {{ sortIcon('priority') }}</th>
<th>Attempts</th>
<th class="sortable" (click)="toggleSort('createdAt')">Created {{ sortIcon('createdAt') }}</th>
<th>Worker</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (job of paginatedJobs(); track job.jobId) {
<tr>
<td><span class="mono">{{ job.jobType }}</span></td>
<td><a class="id-link" [routerLink]="jobEngineJobPath(job.jobId)">{{ shortId(job.jobId) }}</a></td>
<td><span class="status-pill" [class]="'status-pill--' + statusTone(job.status)">{{ toUiStatus(job.status) }}</span></td>
<td>{{ job.priority }}</td>
<td>{{ job.attempt }}/{{ job.maxAttempts }}</td>
<td>{{ formatDateTime(job.createdAt) }}</td>
<td class="mono">{{ job.workerId || '-' }}</td>
<td class="actions-cell">
<a class="stella-table-action" [routerLink]="jobEngineJobPath(job.jobId)">Detail</a>
@if (job.runId) {
<a class="stella-table-action" [routerLink]="jobEngineDagPath(job.jobId)">DAG</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pager -->
@if (totalPages() > 1) {
<div class="pager">
<span class="pager__info">{{ filteredJobs().length }} jobs</span>
<div class="pager__controls">
<button class="pager__btn" [disabled]="page() <= 1" (click)="page.set(page() - 1)">&lsaquo; Prev</button>
<span class="pager__current">{{ page() }} / {{ totalPages() }}</span>
<button class="pager__btn" [disabled]="page() >= totalPages()" (click)="page.set(page() + 1)">Next &rsaquo;</button>
</div>
</div>
}
}
</div>
`,
styles: [`
.jobengine-jobs {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
display: grid;
gap: 1rem;
}
.je-jobs { display: grid; gap: 0.75rem; }
.jobengine-jobs__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.jobengine-jobs__header h1 {
margin: 0 0 0.35rem;
color: var(--color-text-heading);
}
.jobengine-jobs__header p {
margin: 0;
color: var(--color-text-secondary);
}
.jobengine-jobs__back {
display: inline-block;
margin-bottom: 0.65rem;
color: var(--color-status-info);
text-decoration: none;
}
.jobengine-jobs__actions,
.jobengine-jobs__filters,
.job-card__actions,
.job-card__summary {
.filter-bar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.jobengine-jobs__banner {
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
}
.jobengine-jobs__banner--error {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
}
.jobengine-jobs__banner--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
border: 1px solid var(--color-status-info-border);
}
.jobengine-jobs__stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
}
.stat-card,
.jobengine-jobs__filters,
.job-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
align-items: center;
}
.stat-card {
padding: 0.9rem;
display: grid;
gap: 0.15rem;
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
.filter-bar__search-icon {
position: absolute; left: 0.75rem; top: 50%;
transform: translateY(-50%); color: var(--color-text-secondary);
}
.stat-card strong {
font-size: 1.7rem;
color: var(--color-text-heading);
.filter-bar__input { width: 100%; padding: 0.5rem 0.75rem 0.5rem 2.25rem; }
.filter-bar__select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.stat-card span {
color: var(--color-text-secondary);
font-size: 0.82rem;
.filter-bar__select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.stat-card--warning strong {
color: var(--color-status-warning-text);
}
.stat-card--danger strong {
color: var(--color-status-error-text);
}
.jobengine-jobs__filters {
padding: 0.9rem;
}
.jobengine-jobs__filters label {
display: grid;
gap: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.78rem;
min-width: 220px;
}
.jobengine-jobs__filters input,
.jobengine-jobs__filters select {
.filter-bar__btn {
padding: 0.5rem 0.85rem;
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;
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
white-space: nowrap;
}
.filter-bar__btn:hover:not(:disabled) { border-color: var(--color-brand-primary); }
.filter-bar__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.jobengine-jobs__list {
display: grid;
gap: 0.85rem;
.table-container {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.job-card {
padding: 1rem;
display: grid;
gap: 0.85rem;
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
font-size: 0.8125rem;
}
.job-card__summary {
justify-content: space-between;
}
.job-card__title-row {
display: flex;
gap: 0.55rem;
align-items: center;
margin-bottom: 0.45rem;
}
.job-card__type,
.job-card__status {
border-radius: var(--radius-full);
padding: 0.2rem 0.55rem;
font-size: 0.74rem;
.data-table th {
background: var(--color-surface-secondary);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky; top: 0; z-index: 1;
white-space: nowrap;
}
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.job-card__type {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { color: var(--color-text-heading); }
.job-card__status--running {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
.id-link { color: var(--color-brand-primary); text-decoration: none; font-family: ui-monospace, monospace; font-size: 0.78rem; }
.id-link:hover { text-decoration: underline; }
.job-card__status--completed {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
.status-pill {
display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px;
font-size: 0.72rem; font-weight: 600; text-transform: capitalize;
}
.status-pill--running { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.status-pill--completed { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-pill--failed, .status-pill--cancelled { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.status-pill--pending, .status-pill--queued { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.job-card__status--failed,
.job-card__status--cancelled {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
.actions-cell { white-space: nowrap; }
.stella-table-action {
display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary); background: var(--color-surface-primary);
color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer;
margin-right: 0.35rem;
}
.stella-table-action:hover { border-color: var(--color-brand-primary); }
.job-card__status--pending,
.job-card__status--queued {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
.pager {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0.75rem; font-size: 0.82rem; color: var(--color-text-secondary);
}
.job-card h2 {
margin: 0;
font-size: 1rem;
color: var(--color-text-heading);
font-family: ui-monospace, monospace;
}
.job-card p {
margin: 0.35rem 0 0;
color: var(--color-text-secondary);
}
.job-card__details {
display: grid;
gap: 0.85rem;
border-top: 1px solid var(--color-border-primary);
padding-top: 0.9rem;
}
.job-card__details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin: 0;
}
.job-card__details div {
display: grid;
gap: 0.15rem;
}
.job-card__details dt {
color: var(--color-text-secondary);
font-size: 0.76rem;
text-transform: uppercase;
}
.job-card__details dd {
margin: 0;
color: var(--color-text-heading);
font-family: ui-monospace, monospace;
word-break: break-word;
}
.job-card__reason {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.75rem;
}
.job-card__reason strong {
color: var(--color-text-heading);
}
.btn {
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 {
background: transparent;
.pager__controls { display: flex; gap: 0.5rem; align-items: center; }
.pager__btn {
padding: 0.3rem 0.6rem; border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md); background: var(--color-surface-secondary);
color: var(--color-text-primary); cursor: pointer; font-size: 0.78rem;
}
.pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
.pager__current { font-weight: 600; }
.empty-state {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.jobengine-jobs__header,
.jobengine-jobs__stats {
grid-template-columns: 1fr;
display: grid;
}
.job-card__details dl {
grid-template-columns: 1fr;
}
padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem;
}
`],
})
@@ -432,52 +212,76 @@ export class JobEngineJobsComponent implements OnInit {
protected readonly jobEngineJobPath = jobEngineJobPath;
protected readonly jobEngineDagPath = jobEngineDagPath;
protected readonly deadLetterQueuePath = deadLetterQueuePath;
protected readonly statusOptions = ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled'];
private readonly statusList = ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled'];
protected searchQuery = '';
protected statusFilter = '';
protected typeFilter = '';
protected readonly selectedStatuses = signal<Set<string>>(new Set(this.statusList));
protected readonly selectedTypes = signal<Set<string>>(new Set());
protected readonly loading = signal(false);
protected readonly loadError = signal<string | null>(null);
protected readonly actionNotice = signal<string | null>(null);
protected readonly expandedJobId = signal<string | null>(null);
protected readonly jobs = signal<readonly JobEngineJobRecord[]>([]);
protected readonly deadLetterCount = signal(0);
protected readonly page = signal(1);
protected readonly pageSize = 25;
protected readonly sortField = signal<string>('createdAt');
protected readonly sortDir = signal<'asc' | 'desc'>('desc');
private readonly jobsClient = inject(JobEngineJobsClient);
private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi;
protected readonly typeOptions = computed(() =>
Array.from(new Set(this.jobs().map((job) => job.jobType))).sort((left, right) =>
left.localeCompare(right),
),
);
protected readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedStatuses();
return this.statusList.map(s => ({ id: s, label: s, checked: sel.has(s) }));
});
protected readonly typeMultiOptions = computed<FilterMultiOption[]>(() => {
const types = Array.from(new Set(this.jobs().map(j => j.jobType))).sort((a, b) => a.localeCompare(b));
const sel = this.selectedTypes();
return types.map(t => ({ id: t, label: t, checked: sel.size === 0 || sel.has(t) }));
});
protected onStatusFilterChange(opts: FilterMultiOption[]): void {
this.selectedStatuses.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
this.page.set(1);
}
protected onTypeFilterChange(opts: FilterMultiOption[]): void {
const checked = opts.filter(o => o.checked);
this.selectedTypes.set(checked.length === opts.length ? new Set() : new Set(checked.map(o => o.id)));
this.page.set(1);
}
protected readonly filteredJobs = computed(() => {
const query = this.searchQuery.trim().toLowerCase();
const field = this.sortField();
const dir = this.sortDir() === 'asc' ? 1 : -1;
const statuses = this.selectedStatuses();
const types = this.selectedTypes();
return this.jobs()
.filter((job) => !this.statusFilter || this.toUiStatus(job.status) === this.statusFilter)
.filter((job) => !this.typeFilter || job.jobType === this.typeFilter)
.filter((job) =>
.filter(job => statuses.size === 0 || statuses.has(this.toUiStatus(job.status)))
.filter(job => types.size === 0 || types.has(job.jobType))
.filter(job =>
!query ||
job.jobId.toLowerCase().includes(query) ||
job.jobType.toLowerCase().includes(query) ||
(job.correlationId ?? '').toLowerCase().includes(query),
)
.slice()
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
.sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[field] ?? '';
const bv = (b as unknown as Record<string, unknown>)[field] ?? '';
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
});
protected readonly stats = computed(() => {
const jobs = this.jobs();
return {
total: jobs.length,
running: jobs.filter((job) => this.toUiStatus(job.status) === 'running').length,
completed: jobs.filter((job) => this.toUiStatus(job.status) === 'completed').length,
failed: jobs.filter((job) => this.toUiStatus(job.status) === 'failed').length,
};
protected readonly totalPages = computed(() => Math.max(1, Math.ceil(this.filteredJobs().length / this.pageSize)));
protected readonly paginatedJobs = computed(() => {
const start = (this.page() - 1) * this.pageSize;
return this.filteredJobs().slice(start, start + this.pageSize);
});
ngOnInit(): void {
@@ -489,49 +293,48 @@ export class JobEngineJobsComponent implements OnInit {
this.loadError.set(null);
this.jobsClient
.listJobs({ limit: 100 })
.listJobs({ limit: 200 })
.pipe(catchError(() => of({ jobs: [], nextCursor: null })))
.subscribe({
next: (result) => {
this.jobs.set(result.jobs);
this.loading.set(false);
if (!result.jobs.length) {
this.loadError.set('No jobs were returned from JobEngine. Filters and links remain available.');
}
},
error: () => {
this.jobs.set([]);
this.loadError.set('Failed to load JobEngine jobs.');
this.loadError.set('Failed to load jobs.');
this.loading.set(false);
},
});
this.controlApi
.getDeadLetterStats()
.pipe(catchError(() => of(null)))
.subscribe((stats) => this.deadLetterCount.set(stats?.totalEntries ?? 0));
}
protected toggleExpand(jobId: string): void {
this.expandedJobId.set(this.expandedJobId() === jobId ? null : jobId);
protected toggleSort(field: string): void {
if (this.sortField() === field) {
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortDir.set('asc');
}
this.page.set(1);
}
protected copyText(value: string): void {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(value);
this.actionNotice.set(`Copied ${value} to the clipboard.`);
}
protected sortIcon(field: string): string {
if (this.sortField() !== field) return '';
return this.sortDir() === 'asc' ? '\u2191' : '\u2193';
}
protected jobDescription(job: JobEngineJobRecord): string {
const status = this.toUiStatus(job.status);
if (status === 'running') {
return `Leased to ${job.workerId ?? 'an active worker'}${job.runId ? ` in run ${job.runId}` : ''}.`;
protected shortId(id: string): string {
return id.length > 20 ? id.slice(0, 8) + '\u2026' + id.slice(-6) : id;
}
protected toUiStatus(status: string): string {
switch (status) {
case 'scheduled': return 'queued';
case 'leased': return 'running';
case 'succeeded': return 'completed';
case 'canceled': return 'cancelled';
default: return status;
}
if (status === 'failed') {
return job.reason ?? 'This execution failed and may require dead-letter recovery.';
}
return `Created by ${job.createdBy}${job.projectId ? ` for ${job.projectId}` : ''}.`;
}
protected statusTone(status: string): string {
@@ -539,30 +342,16 @@ export class JobEngineJobsComponent implements OnInit {
}
protected formatDateTime(value?: string | null): string {
if (!value) {
return '-';
}
if (!value) return '-';
return new Date(value).toLocaleString(this.dateFmt.locale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
private toUiStatus(status: string): string {
switch (status) {
case 'scheduled':
return 'queued';
case 'leased':
return 'running';
case 'succeeded':
return 'completed';
case 'canceled':
return 'cancelled';
default:
return status;
protected copyText(value: string): void {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(value);
this.actionNotice.set(`Copied ${value} to clipboard.`);
}
}
}

View File

@@ -56,12 +56,12 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
</article>
</section>
<section class="jobengine-quotas__filters">
<label>
Job Type
<input type="search" [(ngModel)]="jobTypeFilter" placeholder="scan, export, advisory-sync" />
</label>
</section>
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="search" class="filter-bar__input" [(ngModel)]="jobTypeFilter" placeholder="Filter by job type..." />
</div>
</div>
<section class="jobengine-quotas__table-wrap">
@if (!filteredQuotas().length) {
@@ -189,24 +189,23 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
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 {
.filter-bar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
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;
border-radius: var(--radius-lg);
align-items: center;
}
.filter-bar__search { position: relative; flex: 1; }
.filter-bar__search-icon {
position: absolute; left: 0.75rem; top: 50%;
transform: translateY(-50%); color: var(--color-text-secondary);
}
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
}
.jobengine-quotas__table-wrap {

View File

@@ -0,0 +1,459 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { catchError, of } from 'rxjs';
import { SCHEDULER_API } from '../../core/api/scheduler.client';
import type { Schedule } from '../../features/scheduler-ops/scheduler-ops.models';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
import { DateFormatService } from '../../core/i18n/date-format.service';
import type { SchedulerApi, UpdateScheduleDto } from '../../core/api/scheduler.client';
interface CadencePreset { id: string; label: string; cron: string; }
const CADENCE_PRESETS: CadencePreset[] = [
{ id: 'hourly', label: 'Every hour', cron: '0 * * * *' },
{ id: '4h', label: 'Every 4 hours', cron: '0 */4 * * *' },
{ id: '2h', label: 'Every 2 hours', cron: '0 */2 * * *' },
{ id: 'nightly', label: 'Nightly (02:00)', cron: '0 2 * * *' },
{ id: '6am', label: 'Nightly (06:00)', cron: '0 6 * * *' },
{ id: 'weekday', label: 'Weekdays (05:00)', cron: '0 5 * * 1-5' },
{ id: 'weekly', label: 'Weekly (Sun 03:00)', cron: '0 3 * * 0' },
];
interface EditState {
id: string;
cronExpression: string;
presetId: string;
parallelism: number;
}
@Component({
selector: 'app-scheduler-schedules-panel',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, StellaFilterMultiComponent],
template: `
<!-- Notice banner -->
@if (notice(); as msg) {
<div class="notice">{{ msg }}</div>
}
<!-- Filter bar -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
class="filter-bar__input"
type="text"
placeholder="Search schedules..."
[(ngModel)]="searchQuery"
/>
<button
class="filter-bar__clear"
[class.filter-bar__clear--visible]="searchQuery"
(click)="searchQuery = ''"
type="button"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
<stella-filter-multi
label="Status"
[options]="statusMultiOptions()"
(optionsChange)="onStatusFilterChange($event)"
/>
</div>
<!-- Data table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
@for (col of columns; track col.field) {
<th
class="sortable"
(click)="toggleSort(col.field)"
>{{ col.label }}
@if (sortField() === col.field) {
<span>{{ sortDir() === 'asc' ? ' \u25B2' : ' \u25BC' }}</span>
}
</th>
}
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (s of filteredSchedules(); track s.id) {
<tr [class.row--editing]="editing()?.id === s.id">
<td>
{{ s.name }}
@if (s.source === 'system') {
<span class="source-badge">System</span>
}
</td>
<td class="mono">{{ s.cronExpression }}</td>
<td>{{ s.mode === 'analysis-only' ? 'Scan' : 'Refresh' }}</td>
<td>{{ s.selection.scope }}</td>
<td>
<span class="status-pill" [class.status-pill--enabled]="s.enabled" [class.status-pill--disabled]="!s.enabled">
{{ s.enabled ? 'enabled' : 'disabled' }}
</span>
</td>
<td>{{ s.lastRunAt ? formatDateTime(s.lastRunAt) : '\u2014' }}</td>
<td class="actions-cell">
<button class="stella-table-action" (click)="triggerNow(s)">Run Now</button>
<button class="stella-table-action" (click)="editing()?.id === s.id ? cancelEdit() : startEdit(s)">
{{ editing()?.id === s.id ? 'Cancel' : 'Edit' }}
</button>
<button class="stella-table-action" (click)="toggleEnabled(s)">
{{ s.enabled ? 'Disable' : 'Enable' }}
</button>
</td>
</tr>
<!-- Inline edit row -->
@if (editing()?.id === s.id) {
<tr class="edit-row">
<td [attr.colspan]="columns.length + 1">
<div class="edit-panel">
<div class="edit-section">
<label class="edit-label">Cadence</label>
<div class="preset-chips">
@for (p of cadencePresets; track p.id) {
<button
type="button"
class="preset-chip"
[class.preset-chip--active]="editing()?.presetId === p.id"
(click)="selectPreset(p)"
>{{ p.label }}</button>
}
<button
type="button"
class="preset-chip"
[class.preset-chip--active]="editing()?.presetId === 'custom'"
(click)="selectPreset({ id: 'custom', label: 'Custom', cron: editing()?.cronExpression ?? '' })"
>Custom</button>
</div>
@if (editing()?.presetId === 'custom') {
<input class="edit-input mono" type="text" [ngModel]="editing()?.cronExpression" (ngModelChange)="patchEdit('cronExpression', $event)" placeholder="0 2 * * *" />
} @else {
<span class="edit-hint">Cron: <code>{{ editing()?.cronExpression }}</code></span>
}
</div>
<div class="edit-row-fields">
<div class="edit-section">
<label class="edit-label">Parallelism</label>
<input type="number" min="1" max="16" [ngModel]="editing()?.parallelism" (ngModelChange)="patchEdit('parallelism', $event)" />
</div>
</div>
<div class="edit-actions">
<button class="stella-table-action" (click)="cancelEdit()">Cancel</button>
<button class="save-btn" (click)="saveEdit()">Save Changes</button>
</div>
</div>
</td>
</tr>
}
} @empty {
<tr>
<td [attr.colspan]="columns.length + 1" class="empty-state">No schedules configured.</td>
</tr>
}
</tbody>
</table>
</div>
`,
styles: [`
:host { display: block; }
.filter-bar {
display: flex; gap: 0.75rem; padding: 0.75rem 1rem;
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); align-items: center; margin-bottom: 0.75rem;
}
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
.filter-bar__search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-text-secondary); }
.filter-bar__input { width: 100%; padding: 0.5rem 2rem 0.5rem 2.25rem; }
.filter-bar__clear { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--color-text-muted); padding: 0.15rem; border-radius: 50%; display: flex; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
.filter-bar__clear--visible { opacity: 1; pointer-events: auto; }
.filter-bar__clear:hover { color: var(--color-text-primary); background: var(--color-surface-tertiary); }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.8125rem; }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.sortable { cursor: pointer; user-select: none; }
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
.status-pill { display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.72rem; font-weight: 600; text-transform: capitalize; }
.status-pill--enabled { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-pill--disabled { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
.actions-cell { white-space: nowrap; }
.stella-table-action { display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer; margin-right: 0.25rem; }
.stella-table-action:hover { border-color: var(--color-brand-primary); }
.source-badge {
display: inline-flex;
margin-left: 0.4rem;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--color-brand-soft);
color: var(--color-brand-primary);
vertical-align: middle;
}
.empty-state { padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem; }
.notice { padding: 0.55rem 0.85rem; border-radius: 8px; background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); font-size: 0.82rem; margin-bottom: 0.75rem; }
/* Inline edit */
.row--editing { background: var(--color-brand-soft) !important; }
.edit-row td { padding: 0 !important; border-bottom: 1px solid var(--color-border-primary); }
.edit-panel { padding: 0.85rem 1rem; display: grid; gap: 0.75rem; background: var(--color-surface-secondary); border-top: 1px dashed var(--color-border-primary); }
.edit-section { display: grid; gap: 0.25rem; }
.edit-label { font-size: 0.72rem; color: var(--color-text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.edit-row-fields { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.edit-row-fields .edit-section { flex: 1; min-width: 140px; }
.edit-row-fields select, .edit-row-fields input { padding: 0.4rem 0.65rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.82rem; }
.edit-input { padding: 0.4rem 0.65rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.82rem; margin-top: 0.25rem; width: 200px; }
.edit-hint { font-size: 0.78rem; color: var(--color-text-muted); margin-top: 0.15rem; }
.edit-hint code { font-family: ui-monospace, monospace; background: var(--color-surface-tertiary); padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); }
.edit-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.save-btn { padding: 0.4rem 0.85rem; border: 1px solid var(--color-btn-primary-border, var(--color-border-primary)); border-radius: var(--radius-md); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); cursor: pointer; font-size: 0.82rem; font-weight: 600; }
.save-btn:hover { filter: brightness(1.05); }
/* Cadence preset chips */
.preset-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.preset-chip { padding: 0.3rem 0.6rem; border: 1px solid var(--color-border-primary); border-radius: 9999px; background: var(--color-surface-primary); color: var(--color-text-secondary); font-size: 0.75rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.preset-chip:hover { border-color: var(--color-brand-primary); }
.preset-chip--active { border-color: var(--color-brand-primary); background: var(--color-brand-soft); color: var(--color-text-heading); font-weight: 600; }
`],
})
export class SchedulerSchedulesPanelComponent implements OnInit {
private readonly api = inject<SchedulerApi>(SCHEDULER_API);
private readonly dateFmt = inject(DateFormatService);
// --- State signals ---
readonly schedules = signal<Schedule[]>([]);
readonly loading = signal(false);
readonly notice = signal<string | null>(null);
readonly selectedStatuses = signal<Set<string>>(new Set(['enabled', 'disabled']));
readonly sortField = signal<string>('name');
readonly sortDir = signal<'asc' | 'desc'>('asc');
readonly editing = signal<EditState | null>(null);
readonly cadencePresets = CADENCE_PRESETS;
searchQuery = '';
readonly columns = [
{ field: 'name', label: 'Name' },
{ field: 'cronExpression', label: 'Cadence' },
{ field: 'mode', label: 'Mode' },
{ field: 'scope', label: 'Scope' },
{ field: 'enabled', label: 'Status' },
{ field: 'lastRunAt', label: 'Last Run' },
];
// --- Computed ---
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedStatuses();
return [
{ id: 'enabled', label: 'Enabled', checked: sel.has('enabled') },
{ id: 'disabled', label: 'Disabled', checked: sel.has('disabled') },
];
});
readonly filteredSchedules = computed(() => {
let list = this.schedules();
const query = this.searchQuery.toLowerCase().trim();
const statuses = this.selectedStatuses();
// Filter by status
if (statuses.size < 2) {
list = list.filter((s) => {
const key = s.enabled ? 'enabled' : 'disabled';
return statuses.has(key);
});
}
// Filter by search
if (query) {
list = list.filter((s) =>
s.name.toLowerCase().includes(query) ||
s.cronExpression.toLowerCase().includes(query) ||
s.mode.toLowerCase().includes(query),
);
}
// Sort
const field = this.sortField();
const dir = this.sortDir() === 'asc' ? 1 : -1;
list = [...list].sort((a, b) => {
const av = this.sortValue(a, field);
const bv = this.sortValue(b, field);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return list;
});
// --- Lifecycle ---
ngOnInit(): void {
this.loadSchedules();
}
// --- Data loading ---
loadSchedules(): void {
this.loading.set(true);
this.api.listSchedules().pipe(
catchError((err) => {
this.notice.set('Failed to load schedules.');
console.error('loadSchedules error', err);
return of([] as Schedule[]);
}),
).subscribe((data) => {
this.schedules.set(data);
this.loading.set(false);
});
}
// --- Actions ---
toggleEnabled(schedule: Schedule): void {
const action$ = schedule.enabled
? this.api.pauseSchedule(schedule.id)
: this.api.resumeSchedule(schedule.id);
action$.pipe(
catchError((err) => {
this.notice.set(`Failed to ${schedule.enabled ? 'disable' : 'enable'} schedule.`);
console.error('toggleEnabled error', err);
return of(null);
}),
).subscribe(() => {
this.notice.set(`Schedule "${schedule.name}" ${schedule.enabled ? 'disabled' : 'enabled'}.`);
this.loadSchedules();
});
}
triggerNow(schedule: Schedule): void {
this.api.triggerSchedule(schedule.id).pipe(
catchError((err) => {
this.notice.set('Failed to trigger schedule.');
console.error('triggerNow error', err);
return of(null);
}),
).subscribe(() => {
this.notice.set(`Schedule "${schedule.name}" triggered.`);
});
}
// --- Inline edit ---
startEdit(schedule: Schedule): void {
const matchingPreset = CADENCE_PRESETS.find(p => p.cron === schedule.cronExpression);
this.editing.set({
id: schedule.id,
cronExpression: schedule.cronExpression,
presetId: matchingPreset?.id ?? 'custom',
parallelism: schedule.limits.parallelism ?? 1,
});
}
cancelEdit(): void {
this.editing.set(null);
}
selectPreset(preset: CadencePreset): void {
const current = this.editing();
if (!current) return;
this.editing.set({ ...current, presetId: preset.id, cronExpression: preset.cron || current.cronExpression });
}
patchEdit(field: keyof EditState, value: unknown): void {
const current = this.editing();
if (!current) return;
this.editing.set({ ...current, [field]: value });
}
saveEdit(): void {
const edit = this.editing();
if (!edit) return;
const dto: UpdateScheduleDto = {
cronExpression: edit.cronExpression,
limits: { parallelism: edit.parallelism },
};
this.api.updateSchedule(edit.id, dto).pipe(
catchError((err) => {
this.notice.set('Failed to save changes.');
console.error('saveEdit error', err);
return of(null);
}),
).subscribe((result) => {
if (result) {
this.notice.set(`Schedule "${result.name}" updated.`);
this.editing.set(null);
this.loadSchedules();
}
});
}
// --- Filter callback ---
onStatusFilterChange(options: FilterMultiOption[]): void {
const next = new Set<string>();
for (const opt of options) {
if (opt.checked) next.add(opt.id);
}
this.selectedStatuses.set(next);
}
// --- Sort ---
toggleSort(field: string): void {
if (this.sortField() === field) {
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortDir.set('asc');
}
}
// --- Formatting ---
formatDateTime(iso: string): string {
return this.dateFmt.format(iso, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
// --- Helpers ---
private sortValue(schedule: Schedule, field: string): string {
switch (field) {
case 'name': return schedule.name.toLowerCase();
case 'cronExpression': return schedule.cronExpression;
case 'mode': return schedule.mode;
case 'scope': return schedule.selection.scope;
case 'enabled': return schedule.enabled ? 'a' : 'z';
case 'lastRunAt': return schedule.lastRunAt ?? '';
default: return '';
}
}
}

View File

@@ -0,0 +1,245 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Worker, WorkerStatus, BackpressureStatus } from '../../features/scheduler-ops/scheduler-ops.models';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
import { DateFormatService } from '../../core/i18n/date-format.service';
type SortField = 'hostname' | 'version' | 'status' | 'currentLoad' | 'completedJobs' | 'failedJobs' | 'lastHeartbeat';
type SortDir = 'asc' | 'desc';
@Component({
selector: 'app-scheduler-workers-panel',
standalone: true,
imports: [FormsModule, StellaFilterMultiComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (backpressure().isActive) {
<div class="backpressure">
<strong>Backpressure Detected</strong>
<span>&mdash; Queue depth: {{ backpressure().queueDepth }}/{{ backpressure().queueThreshold }}
| Worker utilization: {{ backpressure().workerUtilization }}%</span>
</div>
}
@if (notice()) {
<div class="notice">{{ notice() }}</div>
}
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input class="filter-bar__input" type="text" placeholder="Search workers..."
[ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" />
<button class="filter-bar__clear" [class.filter-bar__clear--visible]="searchQuery()"
(click)="searchQuery.set('')" type="button">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<stella-filter-multi label="Status"
[options]="statusMultiOptions()"
(optionsChange)="onStatusFilterChange($event)" />
<button class="filter-bar__btn" (click)="refreshFleet()" type="button">Refresh</button>
</div>
<div class="table-container">
@if (filteredWorkers().length === 0 && !loading()) {
<div class="empty-state">No workers registered.</div>
} @else {
<table class="data-table">
<thead>
<tr>
<th class="sortable" (click)="toggleSort('hostname')">Hostname {{ sortIndicator('hostname') }}</th>
<th class="sortable" (click)="toggleSort('version')">Version {{ sortIndicator('version') }}</th>
<th class="sortable" (click)="toggleSort('status')">Status {{ sortIndicator('status') }}</th>
<th class="sortable" (click)="toggleSort('currentLoad')">Load {{ sortIndicator('currentLoad') }}</th>
<th class="sortable" (click)="toggleSort('completedJobs')">Completed {{ sortIndicator('completedJobs') }}</th>
<th class="sortable" (click)="toggleSort('failedJobs')">Failed {{ sortIndicator('failedJobs') }}</th>
<th class="sortable" (click)="toggleSort('lastHeartbeat')">Last Heartbeat {{ sortIndicator('lastHeartbeat') }}</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (w of filteredWorkers(); track w.id) {
<tr>
<td class="mono">{{ w.hostname }}</td>
<td class="mono">{{ w.version }}</td>
<td><span class="status-pill" [class]="'status-pill--' + w.status">{{ w.status }}</span></td>
<td class="mono">{{ w.currentLoad }}/{{ w.maxLoad }}</td>
<td>{{ w.completedJobs }}</td>
<td>{{ w.failedJobs }}</td>
<td>{{ formatRelative(w.lastHeartbeat) }}</td>
<td class="actions-cell">
@if (w.status === 'active') {
<button class="stella-table-action" (click)="drainWorker(w)" type="button">Drain</button>
}
@if (w.status === 'draining') {
<button class="stella-table-action" (click)="cancelDrain(w)" type="button">Cancel Drain</button>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
`,
styles: [`
:host { display: block; }
.backpressure { display: flex; align-items: center; gap: 1rem; padding: 0.65rem 1rem; border-radius: var(--radius-lg); margin-bottom: 0.75rem; background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); font-size: 0.85rem; }
.backpressure strong { font-weight: 600; }
.filter-bar { display: flex; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); align-items: center; margin-bottom: 0.75rem; }
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
.filter-bar__search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-text-secondary); }
.filter-bar__input { width: 100%; padding: 0.5rem 2rem 0.5rem 2.25rem; }
.filter-bar__clear { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--color-text-muted); padding: 0.15rem; border-radius: 50%; display: flex; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
.filter-bar__clear--visible { opacity: 1; pointer-events: auto; }
.filter-bar__clear:hover { color: var(--color-text-primary); background: var(--color-surface-tertiary); }
.filter-bar__btn { padding: 0.5rem 0.85rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-secondary); color: var(--color-text-primary); cursor: pointer; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
.filter-bar__btn:hover { border-color: var(--color-brand-primary); }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.8125rem; }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.sortable { cursor: pointer; user-select: none; }
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
.status-pill { display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.72rem; font-weight: 600; text-transform: capitalize; }
.status-pill--active { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-pill--draining { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.status-pill--unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.status-pill--offline { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
.actions-cell { white-space: nowrap; }
.stella-table-action { display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer; margin-right: 0.25rem; }
.stella-table-action:hover { border-color: var(--color-brand-primary); }
.empty-state { padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem; }
.notice { padding: 0.55rem 0.85rem; border-radius: 8px; background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); font-size: 0.82rem; margin-bottom: 0.75rem; }
`],
})
export class SchedulerWorkersPanelComponent implements OnInit {
private readonly dateFmt = inject(DateFormatService);
readonly workers = signal<Worker[]>([]);
readonly backpressure = signal<BackpressureStatus>({
isActive: false, severity: 'none', queueDepth: 0, queueThreshold: 500,
workerUtilization: 0, workerThreshold: 90, recommendations: [],
});
readonly loading = signal(false);
readonly notice = signal('');
readonly searchQuery = signal('');
readonly selectedStatuses = signal(new Set<string>(['active', 'draining', 'unhealthy', 'offline']));
sortField = signal<SortField>('hostname');
sortDir = signal<SortDir>('asc');
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedStatuses();
return (['active', 'draining', 'unhealthy', 'offline'] as WorkerStatus[]).map(s => ({
id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: sel.has(s),
}));
});
readonly filteredWorkers = computed(() => {
const q = this.searchQuery().toLowerCase();
const statuses = this.selectedStatuses();
const field = this.sortField();
const dir = this.sortDir();
let list = this.workers().filter(w => {
if (!statuses.has(w.status)) return false;
if (q && !w.hostname.toLowerCase().includes(q) && !w.version.toLowerCase().includes(q)) return false;
return true;
});
list = [...list].sort((a, b) => {
const av = a[field], bv = b[field];
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return dir === 'asc' ? cmp : -cmp;
});
return list;
});
ngOnInit(): void {
this.loadWorkers();
}
loadWorkers(): void {
this.loading.set(true);
const now = Date.now();
const mk = (id: string, host: string, ver: string, st: WorkerStatus, hbAgo: number,
load: number, max: number, done: number, fail: number, startAgo: number): Worker => ({
id, hostname: host, version: ver, status: st,
startedAt: new Date(now - startAgo).toISOString(),
lastHeartbeat: new Date(now - hbAgo).toISOString(),
currentLoad: load, maxLoad: max, completedJobs: done, failedJobs: fail,
activeJobs: [], capabilities: ['scan', 'export'], labels: {},
});
this.workers.set([
mk('w-001', 'worker-01.stella-ops.local', '1.0.0-alpha', 'active', 5_000, 3, 8, 1247, 12, 86_400_000),
mk('w-002', 'worker-02.stella-ops.local', '1.0.0-alpha', 'draining', 120_000, 1, 8, 3891, 27, 172_800_000),
mk('w-003', 'worker-03.stella-ops.local', '0.9.8', 'unhealthy', 300_000, 0, 4, 562, 89, 604_800_000),
mk('w-004', 'worker-04.stella-ops.local', '1.0.0-alpha', 'active', 2_000, 6, 8, 410, 3, 43_200_000),
]);
this.backpressure.set({
isActive: true, severity: 'medium', queueDepth: 342, queueThreshold: 500,
workerUtilization: 72, workerThreshold: 90, recommendations: ['Consider adding workers.'],
});
this.loading.set(false);
}
onStatusFilterChange(options: FilterMultiOption[]): void {
this.selectedStatuses.set(new Set(options.filter(o => o.checked).map(o => o.id)));
}
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortDir.set('asc');
}
}
sortIndicator(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortDir() === 'asc' ? '\u25B2' : '\u25BC';
}
drainWorker(w: Worker): void {
this.workers.update(list => list.map(x => x.id === w.id ? { ...x, status: 'draining' as WorkerStatus } : x));
this.notice.set(`Draining ${w.hostname}...`);
}
cancelDrain(w: Worker): void {
this.workers.update(list => list.map(x => x.id === w.id ? { ...x, status: 'active' as WorkerStatus } : x));
this.notice.set(`Cancelled drain for ${w.hostname}.`);
}
refreshFleet(): void {
this.notice.set('');
this.loadWorkers();
}
formatRelative(dateStr: string): string {
const diff = Math.max(0, Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000));
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
}

View File

@@ -933,7 +933,7 @@ export class PlatformJobsQueuesPageComponent {
{ label: 'Manage Schedules', route: OPERATIONS_PATHS.schedulerSchedules },
{
label: row.lastStatus === 'FAIL' ? 'Review Dead-Letter Queue' : 'Open Worker Fleet',
route: row.lastStatus === 'FAIL' ? deadLetterQueuePath() : OPERATIONS_PATHS.schedulerWorkers,
route: row.lastStatus === 'FAIL' ? deadLetterQueuePath() : OPERATIONS_PATHS.jobEngine,
},
];
}
@@ -950,7 +950,7 @@ export class PlatformJobsQueuesPageComponent {
workerActions(row: WorkerRow): readonly TabAction[] {
return [
{ label: 'Open Worker Fleet', route: OPERATIONS_PATHS.schedulerWorkers },
{ label: 'Open Worker Fleet', route: OPERATIONS_PATHS.jobEngine },
{
label: row.state === 'DEGRADED' ? 'Inspect Scheduler Runs' : 'Open Scheduler Runs',
route: OPERATIONS_PATHS.schedulerRuns,

View File

@@ -1,275 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { ScheduleManagementComponent } from './schedule-management.component';
import { Schedule } from './scheduler-ops.models';
import { SCHEDULER_API, SchedulerApi } from '../../core/api/scheduler.client';
describe('ScheduleManagementComponent', () => {
let fixture: ComponentFixture<ScheduleManagementComponent>;
let component: ScheduleManagementComponent;
let mockApi: jasmine.SpyObj<SchedulerApi>;
const mockSchedule: Schedule = {
id: 'sch-test-001',
name: 'Test Schedule',
cronExpression: '0 6 * * *',
timezone: 'UTC',
enabled: true,
mode: 'analysis-only',
selection: { scope: 'all-images' },
limits: { parallelism: 1 },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'test@example.com',
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj<SchedulerApi>('SchedulerApi', [
'listSchedules', 'getSchedule', 'createSchedule', 'updateSchedule',
'deleteSchedule', 'pauseSchedule', 'resumeSchedule', 'triggerSchedule',
'previewImpact', 'listRuns', 'cancelRun', 'retryRun',
]);
mockApi.listSchedules.and.returnValue(of([mockSchedule]));
mockApi.createSchedule.and.returnValue(of(mockSchedule));
mockApi.updateSchedule.and.returnValue(of(mockSchedule));
mockApi.deleteSchedule.and.returnValue(of(void 0));
mockApi.pauseSchedule.and.returnValue(of(void 0));
mockApi.resumeSchedule.and.returnValue(of(void 0));
mockApi.triggerSchedule.and.returnValue(of(void 0));
mockApi.previewImpact.and.returnValue(of({
total: 42,
usageOnly: true,
generatedAt: new Date().toISOString(),
sample: [],
warnings: [],
}));
await TestBed.configureTestingModule({
imports: [FormsModule, ScheduleManagementComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: SCHEDULER_API, useValue: mockApi },
],
}).compileComponents();
fixture = TestBed.createComponent(ScheduleManagementComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display page header', () => {
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.page-header h1');
expect(header.textContent).toBe('Schedule Management');
});
describe('Schedule Cards', () => {
beforeEach(() => {
component.schedules.set([mockSchedule]);
fixture.detectChanges();
});
it('should display schedule cards', () => {
const cards = fixture.nativeElement.querySelectorAll('.schedule-card');
expect(cards.length).toBe(1);
});
it('should display schedule name', () => {
const card = fixture.nativeElement.querySelector('.schedule-card');
expect(card.textContent).toContain('Test Schedule');
});
it('should display enabled indicator', () => {
const indicator = fixture.nativeElement.querySelector('.status-indicator.enabled');
expect(indicator).toBeTruthy();
});
it('should display cron expression', () => {
const cron = fixture.nativeElement.querySelector('.schedule-cron code');
expect(cron.textContent).toContain('0 6 * * *');
});
});
describe('Actions Menu', () => {
beforeEach(() => {
component.schedules.set([mockSchedule]);
fixture.detectChanges();
});
it('should toggle actions menu', () => {
expect(component.activeMenu()).toBeNull();
component.toggleActions(mockSchedule.id);
expect(component.activeMenu()).toBe(mockSchedule.id);
component.toggleActions(mockSchedule.id);
expect(component.activeMenu()).toBeNull();
});
it('should toggle schedule enabled status via API', () => {
component.toggleEnabled(mockSchedule);
expect(mockApi.pauseSchedule).toHaveBeenCalledWith(mockSchedule.id);
});
it('should duplicate schedule via API', () => {
component.duplicateSchedule(mockSchedule);
expect(mockApi.createSchedule).toHaveBeenCalled();
});
it('should delete schedule after confirmation', () => {
spyOn(window, 'confirm').and.returnValue(true);
component.deleteSchedule(mockSchedule);
expect(mockApi.deleteSchedule).toHaveBeenCalledWith(mockSchedule.id);
});
it('should not delete schedule if cancelled', () => {
spyOn(window, 'confirm').and.returnValue(false);
component.deleteSchedule(mockSchedule);
expect(mockApi.deleteSchedule).not.toHaveBeenCalled();
});
});
describe('Create Modal', () => {
it('should open create modal', () => {
component.showCreateModal();
fixture.detectChanges();
expect(component.showModal()).toBe(true);
expect(component.editingSchedule()).toBeNull();
const modal = fixture.nativeElement.querySelector('.modal');
expect(modal).toBeTruthy();
});
it('should close modal', () => {
component.showCreateModal();
fixture.detectChanges();
component.closeModal();
fixture.detectChanges();
expect(component.showModal()).toBe(false);
});
it('should create new schedule via API', () => {
component.showCreateModal();
component.scheduleForm.name = 'New Schedule';
component.scheduleForm.cronExpression = '0 12 * * *';
component.scheduleForm.mode = 'analysis-only';
component.saveSchedule();
expect(mockApi.createSchedule).toHaveBeenCalled();
});
});
describe('Edit Modal', () => {
beforeEach(() => {
component.schedules.set([mockSchedule]);
fixture.detectChanges();
});
it('should open edit modal with schedule data', () => {
component.editSchedule(mockSchedule);
fixture.detectChanges();
expect(component.showModal()).toBe(true);
expect(component.editingSchedule()).toBe(mockSchedule);
expect(component.scheduleForm.name).toBe(mockSchedule.name);
});
it('should update existing schedule via API', () => {
component.editSchedule(mockSchedule);
component.scheduleForm.name = 'Updated Name';
component.saveSchedule();
expect(mockApi.updateSchedule).toHaveBeenCalled();
});
});
describe('Form Validation', () => {
it('should validate form correctly', () => {
component.showCreateModal();
component.scheduleForm.name = '';
expect(component.isFormValid()).toBe(false);
component.scheduleForm.name = 'Test';
component.scheduleForm.cronExpression = '0 6 * * *';
component.scheduleForm.mode = 'analysis-only';
expect(component.isFormValid()).toBe(true);
});
});
describe('Impact Preview', () => {
it('should generate impact preview via API', () => {
component.showCreateModal();
component.scheduleForm.name = 'Test';
component.scheduleForm.cronExpression = '0 6 * * *';
component.previewImpact();
expect(mockApi.previewImpact).toHaveBeenCalled();
expect(component.impactPreview()).not.toBeNull();
expect(component.impactPreview()?.total).toBe(42);
});
it('should clear impact preview on modal close', () => {
component.showCreateModal();
component.previewImpact();
expect(component.impactPreview()).not.toBeNull();
component.closeModal();
expect(component.impactPreview()).toBeNull();
});
});
describe('Mode Labels', () => {
it('should return correct labels for modes', () => {
expect(component.getModeLabel('analysis-only')).toBe('Analysis Only');
expect(component.getModeLabel('content-refresh')).toBe('Content Refresh');
});
});
describe('Cron Descriptions', () => {
it('should return cron descriptions', () => {
expect(component.getCronDescription('0 * * * *')).toBe('Every hour');
expect(component.getCronDescription('0 6 * * *')).toBe('Daily at 6:00 AM');
expect(component.getCronDescription('0 0 * * *')).toBe('Daily at midnight');
expect(component.getCronDescription('0 0 * * 0')).toBe('Weekly on Sunday at midnight');
expect(component.getCronDescription('0 0 1 * *')).toBe('Monthly on the 1st at midnight');
expect(component.getCronDescription('*/5 * * * *')).toBe('Custom schedule');
});
});
describe('Utility Methods', () => {
it('should format datetime correctly', () => {
const datetime = '2024-12-29T10:30:00Z';
const formatted = component.formatDateTime(datetime);
expect(formatted).toContain('Dec');
expect(formatted).toContain('29');
});
});
describe('Empty State', () => {
it('should show empty state when no schedules', () => {
mockApi.listSchedules.and.returnValue(of([]));
component.schedules.set([]);
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain('No schedules configured');
});
});
});

View File

@@ -112,6 +112,7 @@ export interface Schedule {
createdAt: string;
updatedAt: string;
createdBy: string;
source?: 'system' | 'user' | 'integration';
}
/**

View File

@@ -33,18 +33,12 @@ export const schedulerOpsRoutes: Routes = [
},
{
path: 'schedules',
loadComponent: () =>
import('./schedule-management.component').then(
(m) => m.ScheduleManagementComponent
),
data: { title: 'Schedule Management' },
redirectTo: '/ops/operations/jobengine?tab=schedules',
pathMatch: 'full',
},
{
path: 'workers',
loadComponent: () =>
import('./worker-fleet.component').then(
(m) => m.WorkerFleetComponent
),
data: { title: 'Worker Fleet' },
redirectTo: '/ops/operations/jobengine?tab=schedules',
pathMatch: 'full',
},
];

View File

@@ -16,13 +16,15 @@ import { SCHEDULER_API } from '../../core/api/scheduler.client';
import { OPERATIONS_PATHS, schedulerRunStreamPath } from '../platform/ops/operations-paths';
import { DateFormatService } from '../../core/i18n/date-format.service';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
import { StellaFilterChipComponent, type FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
/**
* Scheduler Runs Component (Sprint: SPRINT_20251229_017)
* Lists scheduler runs with real-time updates and cancel/retry actions.
*/
@Component({
selector: 'app-scheduler-runs',
imports: [FormsModule, RouterLink],
imports: [FormsModule, RouterLink, StellaFilterMultiComponent, StellaFilterChipComponent],
template: `
<div class="scheduler-runs">
<header class="page-header">
@@ -31,12 +33,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
<p>Monitor and manage scheduled task executions.</p>
</div>
<div class="header-actions">
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerSchedules">
Manage Schedules
</button>
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerWorkers">
Worker Fleet
</button>
</div>
</header>
@@ -56,28 +52,22 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
}
<!-- Filters -->
<div class="filters">
<input
type="text"
placeholder="Search by schedule name or run ID..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
class="filter-bar__input"
placeholder="Search by schedule name or run ID..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
/>
</div>
<stella-filter-multi
label="Status"
[options]="statusMultiOptions()"
(optionsChange)="onStatusMultiChange($event)"
/>
<select [ngModel]="statusFilter()" (ngModelChange)="onStatusFilterChange($event)">
<option value="">All statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
<option value="pending">Pending</option>
<option value="queued">Queued</option>
</select>
<select [ngModel]="timeFilter()" (ngModelChange)="onTimeFilterChange($event)">
<option value="1h">Last 1 hour</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
</div>
<!-- Stats Summary -->
@@ -255,31 +245,38 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
}
}
.filters {
.filter-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
input, select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
font-size: 0.88rem;
color: inherit;
transition: border-color 150ms ease, box-shadow 150ms ease;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
}
input {
flex: 1;
max-width: 400px;
}
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
margin-bottom: 1rem;
align-items: center;
}
.filter-bar__search { position: relative; flex: 1; }
.filter-bar__search-icon {
position: absolute; left: 0.75rem; top: 50%;
transform: translateY(-50%); color: var(--color-text-secondary);
}
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
}
.filter-bar__select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filter-bar__select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.connection-banner {
@@ -685,6 +682,34 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
readonly statusFilter = signal<SchedulerRunStatus | ''>('');
readonly timeFilter = signal<'1h' | '24h' | '7d' | '30d'>('24h');
private readonly statusList: SchedulerRunStatus[] = ['running', 'completed', 'failed', 'cancelled', 'pending', 'queued'];
readonly selectedStatuses = signal<Set<string>>(new Set(this.statusList));
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedStatuses();
return this.statusList.map(s => ({ id: s, label: s, checked: sel.has(s) }));
});
readonly timeChipOptions: FilterChipOption[] = [
{ id: '1h', label: '1h' },
{ id: '24h', label: '24h' },
{ id: '7d', label: '7d' },
{ id: '30d', label: '30d' },
];
onStatusMultiChange(opts: FilterMultiOption[]): void {
const checked = opts.filter(o => o.checked).map(o => o.id);
this.selectedStatuses.set(new Set(checked));
// Also update the single statusFilter for the existing filteredRuns logic
if (checked.length === this.statusList.length || checked.length === 0) {
this.statusFilter.set('');
} else if (checked.length === 1) {
this.statusFilter.set(checked[0] as SchedulerRunStatus);
} else {
this.statusFilter.set('');
}
}
readonly expandedRun = signal<string | null>(null);
readonly isConnected = signal(true);
readonly actionNotice = signal<string | null>(null);
@@ -703,9 +728,9 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
);
}
const status = this.statusFilter();
if (status) {
result = result.filter(r => r.status === status);
const statuses = this.selectedStatuses();
if (statuses.size > 0 && statuses.size < this.statusList.length) {
result = result.filter(r => statuses.has(r.status));
}
const cutoff = this.getTimeCutoffMs(this.timeFilter());

View File

@@ -1,279 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { WorkerFleetComponent } from './worker-fleet.component';
import { Worker, BackpressureStatus } from './scheduler-ops.models';
describe('WorkerFleetComponent', () => {
let fixture: ComponentFixture<WorkerFleetComponent>;
let component: WorkerFleetComponent;
const mockWorker: Worker = {
id: 'worker-test-001',
hostname: 'worker-1.example.com',
version: '1.5.0',
status: 'active',
startedAt: new Date(Date.now() - 86400000).toISOString(),
lastHeartbeat: new Date().toISOString(),
currentLoad: 75,
maxLoad: 100,
completedJobs: 1500,
failedJobs: 25,
activeJobs: [
{ jobId: 'job-001', type: 'scan', startedAt: new Date().toISOString(), progress: 50 },
],
capabilities: ['scan', 'sbom', 'export'],
labels: { env: 'production' },
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WorkerFleetComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(WorkerFleetComponent);
component = fixture.componentInstance;
});
afterEach(() => {
component.ngOnDestroy();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display page header', () => {
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.page-header h1');
expect(header.textContent).toBe('Worker Fleet');
});
describe('Fleet Summary', () => {
beforeEach(() => {
component.workers.set([mockWorker]);
fixture.detectChanges();
});
it('should calculate fleet summary', () => {
const summary = component.fleetSummary();
expect(summary.totalWorkers).toBe(1);
expect(summary.activeWorkers).toBe(1);
expect(summary.usedCapacity).toBe(75);
expect(summary.totalCapacity).toBe(100);
});
it('should display summary cards', () => {
const summaryCards = fixture.nativeElement.querySelectorAll('.summary-card');
expect(summaryCards.length).toBeGreaterThan(0);
});
it('should calculate version distribution', () => {
const workers = [
{ ...mockWorker, id: 'w1', version: '1.5.0' },
{ ...mockWorker, id: 'w2', version: '1.5.0' },
{ ...mockWorker, id: 'w3', version: '1.4.0' },
];
component.workers.set(workers);
const summary = component.fleetSummary();
expect(summary.versionDistribution['1.5.0']).toBe(2);
expect(summary.versionDistribution['1.4.0']).toBe(1);
});
});
describe('Backpressure Status', () => {
it('should display backpressure warning when active', () => {
component.backpressure.set({
isActive: true,
severity: 'high',
queueDepth: 500,
queueThreshold: 200,
workerUtilization: 95,
workerThreshold: 80,
estimatedClearTime: 3600000,
recommendations: ['Scale up workers', 'Pause new jobs'],
});
fixture.detectChanges();
const warning = fixture.nativeElement.querySelector('.backpressure-warning');
expect(warning).toBeTruthy();
});
it('should hide backpressure warning when not active', () => {
component.backpressure.set({
isActive: false,
severity: 'none',
queueDepth: 50,
queueThreshold: 200,
workerUtilization: 30,
workerThreshold: 80,
recommendations: [],
});
fixture.detectChanges();
const warning = fixture.nativeElement.querySelector('.backpressure-warning');
expect(warning).toBeFalsy();
});
});
describe('Worker Cards', () => {
beforeEach(() => {
component.workers.set([mockWorker]);
fixture.detectChanges();
});
it('should display worker cards', () => {
const cards = fixture.nativeElement.querySelectorAll('.worker-card');
expect(cards.length).toBe(1);
});
it('should display worker hostname', () => {
const card = fixture.nativeElement.querySelector('.worker-card');
expect(card.textContent).toContain('worker-1.example.com');
});
it('should display worker version', () => {
const card = fixture.nativeElement.querySelector('.worker-card');
expect(card.textContent).toContain('1.5.0');
});
it('should display load bar', () => {
const loadBar = fixture.nativeElement.querySelector('.load-fill');
expect(loadBar).toBeTruthy();
expect(loadBar.style.width).toBe('75%');
});
it('should display active jobs count', () => {
const card = fixture.nativeElement.querySelector('.worker-card');
expect(card.textContent).toContain('1'); // active jobs
});
});
describe('Worker Actions', () => {
beforeEach(() => {
component.workers.set([mockWorker]);
fixture.detectChanges();
});
it('should drain worker after confirmation', () => {
spyOn(window, 'confirm').and.returnValue(true);
component.drainWorker(mockWorker);
const updated = component.workers().find(w => w.id === mockWorker.id);
expect(updated?.status).toBe('draining');
});
it('should not drain worker if cancelled', () => {
spyOn(window, 'confirm').and.returnValue(false);
component.drainWorker(mockWorker);
const updated = component.workers().find(w => w.id === mockWorker.id);
expect(updated?.status).toBe('active');
});
it('should restart worker after confirmation', () => {
spyOn(window, 'confirm').and.returnValue(true);
const consoleSpy = spyOn(console, 'log');
component.restartWorker(mockWorker);
expect(consoleSpy).toHaveBeenCalledWith('Restarting worker:', mockWorker.id);
});
});
describe('Worker Status Classes', () => {
it('should return correct status class', () => {
expect(component.getStatusClass('active')).toBe('status-active');
expect(component.getStatusClass('draining')).toBe('status-draining');
expect(component.getStatusClass('offline')).toBe('status-offline');
expect(component.getStatusClass('unhealthy')).toBe('status-unhealthy');
});
});
describe('Utility Methods', () => {
it('should format datetime correctly', () => {
const datetime = '2024-12-29T10:30:00Z';
const formatted = component.formatDateTime(datetime);
expect(formatted).toContain('Dec');
expect(formatted).toContain('29');
});
it('should format uptime correctly', () => {
const startedAt = new Date(Date.now() - 3600000).toISOString(); // 1 hour ago
const uptime = component.formatUptime(startedAt);
expect(uptime).toContain('h');
});
it('should calculate load percentage', () => {
expect(component.getLoadPercentage(75, 100)).toBe(75);
expect(component.getLoadPercentage(50, 200)).toBe(25);
expect(component.getLoadPercentage(0, 100)).toBe(0);
});
it('should handle zero max load', () => {
expect(component.getLoadPercentage(50, 0)).toBe(0);
});
});
describe('Version Distribution', () => {
it('should display version bars', () => {
component.workers.set([
{ ...mockWorker, id: 'w1', version: '1.5.0' },
{ ...mockWorker, id: 'w2', version: '1.4.0' },
]);
fixture.detectChanges();
const versionBars = fixture.nativeElement.querySelectorAll('.version-bar');
expect(versionBars.length).toBeGreaterThan(0);
});
});
describe('Lifecycle', () => {
it('should connect SSE on init', () => {
component.ngOnInit();
expect(component.isConnected()).toBe(true);
});
it('should disconnect SSE on destroy', () => {
component.ngOnInit();
component.ngOnDestroy();
expect(component.isConnected()).toBe(false);
});
});
describe('Empty State', () => {
it('should show empty state when no workers', () => {
component.workers.set([]);
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
});
});
describe('Worker Detail Modal', () => {
beforeEach(() => {
component.workers.set([mockWorker]);
fixture.detectChanges();
});
it('should open worker detail modal', () => {
component.viewWorkerDetails(mockWorker);
fixture.detectChanges();
expect(component.selectedWorker()).toBe(mockWorker);
});
it('should close worker detail modal', () => {
component.viewWorkerDetails(mockWorker);
component.closeWorkerDetails();
expect(component.selectedWorker()).toBeNull();
});
});
});

View File

@@ -1,798 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
signal,
inject,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
Worker,
WorkerFleetSummary,
WorkerStatus,
BackpressureStatus,
} from './scheduler-ops.models';
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
import { DateFormatService } from '../../core/i18n/date-format.service';
/**
* Worker Fleet Dashboard Component (Sprint: SPRINT_20251229_017)
* Displays worker status, load, and health with drain/restart controls.
*/
@Component({
selector: 'app-worker-fleet',
imports: [RouterLink],
template: `
<div class="worker-fleet">
<header class="page-header">
<div class="header-content">
<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>
</header>
@if (actionNotice()) {
<div class="fleet-notice" role="status">{{ actionNotice() }}</div>
}
<!-- Fleet Summary -->
<div class="fleet-summary">
<div class="summary-card">
<span class="summary-value">{{ fleetSummary().totalWorkers }}</span>
<span class="summary-label">Total Workers</span>
</div>
<div class="summary-card success">
<span class="summary-value">{{ fleetSummary().activeWorkers }}</span>
<span class="summary-label">Active</span>
</div>
<div class="summary-card warning">
<span class="summary-value">{{ fleetSummary().drainingWorkers }}</span>
<span class="summary-label">Draining</span>
</div>
<div class="summary-card error">
<span class="summary-value">{{ fleetSummary().unhealthyWorkers }}</span>
<span class="summary-label">Unhealthy</span>
</div>
<div class="summary-card">
<div class="capacity-bar">
<div
class="capacity-fill"
[style.width.%]="capacityPercentage()"
></div>
</div>
<span class="summary-label">
{{ fleetSummary().usedCapacity }} / {{ fleetSummary().totalCapacity }} capacity
</span>
</div>
</div>
<!-- Backpressure Warning -->
@if (backpressure().isActive) {
<div class="backpressure-warning" [class]="'severity-' + backpressure().severity">
<div class="warning-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<div class="warning-content">
<h3>Backpressure Detected</h3>
<p>
Queue depth: {{ backpressure().queueDepth }} / {{ backpressure().queueThreshold }}
| Worker utilization: {{ backpressure().workerUtilization }}%
</p>
@if (backpressure().estimatedClearTime) {
<p class="clear-time">
Estimated clear time: {{ formatDuration(backpressure().estimatedClearTime) }}
</p>
}
</div>
<div class="warning-actions">
<button class="btn btn-secondary" (click)="scaleWorkers()">
Scale Workers
</button>
</div>
</div>
}
<!-- Version Distribution -->
<section class="version-section">
<h2>Version Distribution</h2>
<div class="version-bars">
@for (entry of getVersionEntries(); track entry.version) {
<div class="version-bar">
<span class="version-label">{{ entry.version }}</span>
<div class="bar-container">
<div
class="bar-fill"
[style.width.%]="entry.percentage"
></div>
</div>
<span class="version-count">{{ entry.count }} workers</span>
</div>
}
</div>
</section>
<!-- Workers Grid -->
<section class="workers-section">
<div class="section-header">
<h2>Workers</h2>
<div class="section-actions">
<button class="btn btn-secondary" (click)="drainAll()">
Drain All
</button>
<button class="btn btn-secondary" (click)="refreshFleet()">
Refresh
</button>
</div>
</div>
<div class="workers-grid">
@for (worker of workers(); track worker.id) {
<div class="worker-card" [class]="'status-' + worker.status">
<div class="worker-header">
<div class="worker-status">
<span class="status-dot" [class]="worker.status"></span>
<span class="status-text">{{ worker.status }}</span>
</div>
<span class="worker-version">v{{ worker.version }}</span>
</div>
<h3 class="worker-hostname">{{ worker.hostname }}</h3>
<code class="worker-id">{{ worker.id }}</code>
<div class="worker-load">
<div class="load-bar">
<div
class="load-fill"
[style.width.%]="(worker.currentLoad / worker.maxLoad) * 100"
[class.high]="worker.currentLoad / worker.maxLoad > 0.8"
></div>
</div>
<span class="load-text">
{{ worker.currentLoad }} / {{ worker.maxLoad }} jobs
</span>
</div>
<div class="worker-stats">
<div class="stat">
<span class="stat-value">{{ worker.completedJobs }}</span>
<span class="stat-label">Completed</span>
</div>
<div class="stat">
<span class="stat-value">{{ worker.failedJobs }}</span>
<span class="stat-label">Failed</span>
</div>
</div>
<div class="worker-timing">
<span>Started: {{ formatDateTime(worker.startedAt) }}</span>
<span>Last heartbeat: {{ formatRelative(worker.lastHeartbeat) }}</span>
</div>
@if (worker.activeJobs.length > 0) {
<div class="active-jobs">
<h4>Active Jobs ({{ worker.activeJobs.length }})</h4>
@for (job of worker.activeJobs; track job.jobId) {
<div class="job-item">
<span class="job-type">{{ job.type }}</span>
<span class="job-progress">{{ job.progress }}%</span>
</div>
}
</div>
}
<div class="worker-actions">
@if (worker.status === 'active') {
<button class="btn btn-sm btn-secondary" (click)="drainWorker(worker)">
Drain
</button>
}
@if (worker.status === 'draining') {
<button class="btn btn-sm btn-secondary" (click)="cancelDrain(worker)">
Cancel Drain
</button>
}
@if (worker.status === 'unhealthy') {
<button class="btn btn-sm btn-danger" (click)="restartWorker(worker)">
Restart
</button>
}
<button class="btn btn-sm btn-secondary" (click)="viewWorkerLogs(worker)">
Logs
</button>
</div>
</div>
} @empty {
<div class="empty-state">
<p>No workers registered.</p>
</div>
}
</div>
</section>
</div>
`,
styles: [`
.worker-fleet {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
.back-link {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-link);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
p {
margin: 0;
color: var(--color-text-secondary);
}
}
.fleet-summary {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.fleet-notice {
background: var(--color-status-info-bg);
border: 1px solid rgba(56, 189, 248, 0.45);
color: var(--color-status-info);
border-radius: var(--radius-lg);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.summary-card {
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
padding: 1rem;
text-align: center;
&.success { background: var(--color-status-success-bg); }
&.warning { background: var(--color-status-warning-bg); }
&.error { background: var(--color-status-error-bg); }
.summary-value {
display: block;
font-size: 2rem;
font-weight: var(--font-weight-semibold);
margin-bottom: 0.25rem;
}
.summary-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
}
}
.capacity-bar {
height: 8px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 0.5rem;
.capacity-fill {
height: 100%;
background: var(--color-btn-primary-bg);
transition: width 0.3s ease;
}
}
.backpressure-warning {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
background: var(--color-status-warning-bg);
border-radius: var(--radius-lg);
margin-bottom: 2rem;
&.severity-high, &.severity-critical {
background: var(--color-status-error-bg);
}
.warning-icon {
font-size: 1.5rem;
}
.warning-content {
flex: 1;
h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.clear-time {
margin-top: 0.25rem;
font-weight: var(--font-weight-medium);
}
}
}
.version-section {
margin-bottom: 2rem;
h2 {
font-size: 1rem;
margin: 0 0 1rem;
}
}
.version-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.version-bar {
display: grid;
grid-template-columns: 80px 1fr 100px;
align-items: center;
gap: 1rem;
.version-label {
font-family: monospace;
font-size: 0.875rem;
}
.bar-container {
height: 20px;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--color-btn-primary-bg);
}
.version-count {
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: right;
}
}
.workers-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h2 {
font-size: 1rem;
margin: 0;
}
.section-actions {
display: flex;
gap: 0.5rem;
}
}
}
.workers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.worker-card {
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
padding: 1.25rem;
border-left: 4px solid var(--color-status-success);
&.status-draining { border-left-color: var(--color-status-warning); }
&.status-offline { border-left-color: var(--color-text-secondary); }
&.status-unhealthy { border-left-color: var(--color-status-error); }
.worker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.worker-status {
display: flex;
align-items: center;
gap: 0.5rem;
.status-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
&.active { background: var(--color-status-success); }
&.draining { background: var(--color-status-warning); }
&.offline { background: var(--color-text-secondary); }
&.unhealthy { background: var(--color-status-error); }
}
.status-text {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: var(--font-weight-medium);
}
}
.worker-version {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--color-surface-tertiary);
border-radius: 0.125rem;
font-family: monospace;
}
.worker-hostname {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.worker-id {
display: block;
font-size: 0.625rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
}
.worker-load {
margin-bottom: 1rem;
.load-bar {
height: 6px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 0.25rem;
}
.load-fill {
height: 100%;
background: var(--color-status-success);
transition: width 0.3s ease;
&.high {
background: var(--color-status-warning);
}
}
.load-text {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.worker-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
.stat {
display: flex;
flex-direction: column;
.stat-value {
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
}
.stat-label {
font-size: 0.625rem;
color: var(--color-text-secondary);
text-transform: uppercase;
}
}
}
.worker-timing {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.active-jobs {
margin-bottom: 0.75rem;
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.job-item {
display: flex;
justify-content: space-between;
padding: 0.25rem 0.5rem;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
}
.worker-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
border: none;
&.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
&.btn-secondary {
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
}
&.btn-danger {
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
color: var(--color-text-secondary);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkerFleetComponent {
private readonly dateFmt = inject(DateFormatService);
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
readonly actionNotice = signal<string | null>(null);
readonly workers = signal<Worker[]>([
{
id: 'worker-001',
hostname: 'worker-prod-1.example.com',
version: '2.1.0',
status: 'active',
startedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
lastHeartbeat: new Date(Date.now() - 5000).toISOString(),
currentLoad: 8,
maxLoad: 10,
completedJobs: 1250,
failedJobs: 12,
activeJobs: [
{ jobId: 'job-001', type: 'scan', startedAt: new Date().toISOString(), progress: 65 },
{ jobId: 'job-002', type: 'sbom', startedAt: new Date().toISOString(), progress: 30 },
],
capabilities: ['scan', 'sbom', 'export'],
labels: { 'tier': 'production', 'region': 'us-east-1' },
},
{
id: 'worker-002',
hostname: 'worker-prod-2.example.com',
version: '2.1.0',
status: 'active',
startedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
lastHeartbeat: new Date(Date.now() - 3000).toISOString(),
currentLoad: 5,
maxLoad: 10,
completedJobs: 980,
failedJobs: 8,
activeJobs: [
{ jobId: 'job-003', type: 'export', startedAt: new Date().toISOString(), progress: 80 },
],
capabilities: ['scan', 'sbom', 'export'],
labels: { 'tier': 'production', 'region': 'us-east-1' },
},
{
id: 'worker-003',
hostname: 'worker-prod-3.example.com',
version: '2.0.5',
status: 'draining',
startedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
lastHeartbeat: new Date(Date.now() - 2000).toISOString(),
currentLoad: 2,
maxLoad: 10,
completedJobs: 2150,
failedJobs: 25,
activeJobs: [
{ jobId: 'job-004', type: 'scan', startedAt: new Date().toISOString(), progress: 95 },
],
capabilities: ['scan', 'sbom'],
labels: { 'tier': 'production', 'region': 'us-west-2' },
},
{
id: 'worker-004',
hostname: 'worker-prod-4.example.com',
version: '2.1.0',
status: 'unhealthy',
startedAt: new Date(Date.now() - 86400000 * 1).toISOString(),
lastHeartbeat: new Date(Date.now() - 120000).toISOString(),
currentLoad: 0,
maxLoad: 10,
completedJobs: 150,
failedJobs: 45,
activeJobs: [],
capabilities: ['scan', 'sbom', 'export'],
labels: { 'tier': 'production', 'region': 'us-west-2' },
},
]);
readonly backpressure = signal<BackpressureStatus>({
isActive: true,
severity: 'medium',
queueDepth: 450,
queueThreshold: 500,
workerUtilization: 85,
workerThreshold: 80,
estimatedClearTime: 300000,
recommendations: [
'Consider scaling up workers to handle increased load.',
'Review long-running jobs for potential optimization.',
],
});
readonly fleetSummary = computed<WorkerFleetSummary>(() => {
const allWorkers = this.workers();
const active = allWorkers.filter(w => w.status === 'active');
const draining = allWorkers.filter(w => w.status === 'draining');
const unhealthy = allWorkers.filter(w => w.status === 'unhealthy');
const offline = allWorkers.filter(w => w.status === 'offline');
const totalCapacity = allWorkers.reduce((sum, w) => sum + w.maxLoad, 0);
const usedCapacity = allWorkers.reduce((sum, w) => sum + w.currentLoad, 0);
const versionDistribution: Record<string, number> = {};
allWorkers.forEach(w => {
versionDistribution[w.version] = (versionDistribution[w.version] || 0) + 1;
});
return {
totalWorkers: allWorkers.length,
activeWorkers: active.length,
drainingWorkers: draining.length,
offlineWorkers: offline.length,
unhealthyWorkers: unhealthy.length,
totalCapacity,
usedCapacity,
versionDistribution,
};
});
readonly capacityPercentage = computed(() => {
const summary = this.fleetSummary();
return summary.totalCapacity > 0
? Math.round((summary.usedCapacity / summary.totalCapacity) * 100)
: 0;
});
getVersionEntries(): Array<{ version: string; count: number; percentage: number }> {
const distribution = this.fleetSummary().versionDistribution;
const total = Object.values(distribution).reduce((sum, c) => sum + c, 0);
return Object.entries(distribution)
.map(([version, count]) => ({
version,
count,
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return left.version.localeCompare(right.version);
});
}
drainWorker(worker: Worker): void {
if (confirm(`Drain worker "${worker.hostname}"? It will stop accepting new jobs.`)) {
this.workers.update(workers =>
workers.map(w =>
w.id === worker.id ? { ...w, status: 'draining' as WorkerStatus } : w
)
);
this.actionNotice.set(`Worker ${worker.hostname} is now draining.`);
}
}
cancelDrain(worker: Worker): void {
this.workers.update(workers =>
workers.map(w =>
w.id === worker.id ? { ...w, status: 'active' as WorkerStatus } : w
)
);
this.actionNotice.set(`Drain cancelled for ${worker.hostname}.`);
}
restartWorker(worker: Worker): void {
if (confirm(`Restart worker "${worker.hostname}"?`)) {
this.actionNotice.set(`Restart requested for ${worker.hostname}.`);
}
}
viewWorkerLogs(worker: Worker): void {
this.actionNotice.set(`Opened logs for ${worker.hostname}.`);
}
drainAll(): void {
if (confirm('Drain all active workers? This will prevent new jobs from being scheduled.')) {
this.workers.update(workers =>
workers.map(w =>
w.status === 'active' ? { ...w, status: 'draining' as WorkerStatus } : w
)
);
this.actionNotice.set('All active workers moved to draining.');
}
}
refreshFleet(): void {
this.actionNotice.set('Fleet status refreshed.');
}
scaleWorkers(): void {
this.actionNotice.set('Scale workers action queued for operator review.');
}
formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString(this.dateFmt.locale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
formatRelative(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
return `${Math.floor(diff / 3600000)}h ago`;
}
formatDuration(ms: number | undefined): string {
if (ms === undefined) return '-';
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
const minutes = Math.floor(ms / 60000);
return `${minutes}m`;
}
}

View File

@@ -417,7 +417,7 @@ export interface NavItem {
}
<span class="nav-item__label">{{ label }}</span>
@if (tooltip && !isChild) {
<span class="nav-item__context">{{ tooltip }}</span>
<span class="nav-item__context" [attr.title]="tooltip">{{ tooltip }}</span>
}
</span>
}
@@ -624,7 +624,10 @@ export interface NavItem {
color: var(--color-sidebar-text-muted);
font-size: 0.6875rem;
line-height: 1.35;
white-space: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.nav-item__badge {