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:
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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">← 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)">‹ Prev</button>
|
||||
<span class="pager__current">{{ page() }} / {{ totalPages() }}</span>
|
||||
<button class="pager__btn" [disabled]="page() >= totalPages()" (click)="page.set(page() + 1)">Next ›</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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>— 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`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ export interface Schedule {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
source?: 'system' | 'user' | 'integration';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user