Finish off old sprints

This commit is contained in:
master
2026-02-18 15:01:04 +02:00
parent af4f261de8
commit 1bcab39a2c
28 changed files with 2534 additions and 330 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1,129 +1,549 @@
// Sprint: SPRINT_20251229_030_FE - Dead-Letter Management UI
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
DeadLetterEntry,
DeadLetterListResponse,
DeadLetterStatsSummary,
DeadLetterFilter,
ReplayRequest,
ReplayResponse,
Observable,
catchError,
forkJoin,
map,
of,
switchMap,
} from 'rxjs';
import {
BatchReplayProgress,
BatchReplayRequest,
BatchReplayResponse,
BatchReplayProgress,
ResolveRequest,
DeadLetterAuditEvent,
DeadLetterEntry,
DeadLetterEntrySummary,
DeadLetterFilter,
DeadLetterListResponse,
DeadLetterState,
DeadLetterStatsSummary,
ErrorCode,
ReplayRequest,
ReplayResponse,
ResolveRequest,
} from './deadletter.models';
interface ApiDeadLetterEntry {
entryId?: string;
id?: string;
originalJobId?: string;
jobId?: string;
runId?: string | null;
sourceId?: string | null;
jobType?: string;
tenantId?: string;
tenantName?: string;
status?: string;
state?: string;
errorCode?: string;
failureReason?: string;
errorMessage?: string;
category?: string;
payload?: unknown;
replayAttempts?: number;
retryCount?: number;
maxReplayAttempts?: number;
maxRetries?: number;
createdAt?: string;
updatedAt?: string;
failedAt?: string;
resolvedAt?: string | null;
resolutionNotes?: string | null;
updatedBy?: string;
}
interface ApiDeadLetterListResponse {
entries?: ApiDeadLetterEntry[];
items?: ApiDeadLetterEntry[];
totalCount?: number;
total?: number;
nextCursor?: string;
cursor?: string;
}
interface ApiDeadLetterStatsResponse {
totalEntries?: number;
pendingEntries?: number;
replayingEntries?: number;
replayedEntries?: number;
resolvedEntries?: number;
exhaustedEntries?: number;
expiredEntries?: number;
retryableEntries?: number;
topErrorCodes?: Record<string, number>;
stats?: DeadLetterStatsSummary['stats'];
byErrorType?: DeadLetterStatsSummary['byErrorType'];
byTenant?: DeadLetterStatsSummary['byTenant'];
trend?: DeadLetterStatsSummary['trend'];
}
interface ApiDeadLetterSummary {
errorCode?: string;
entryCount?: number;
}
interface ApiDeadLetterSummaryResponse {
summaries?: ApiDeadLetterSummary[];
}
interface ApiReplayResponse {
success?: boolean;
newJobId?: string | null;
error?: string | null;
errorMessage?: string | null;
}
interface ApiBatchResultResponse {
attempted?: number;
succeeded?: number;
failed?: number;
queued?: number;
skipped?: number;
batchId?: string;
}
interface ApiResolveBatchResponse {
resolvedCount?: number;
}
interface ApiReplayAuditRecord {
auditId?: string;
entryId?: string;
success?: boolean;
newJobId?: string | null;
errorMessage?: string | null;
triggeredBy?: string;
triggeredAt?: string;
attemptNumber?: number;
}
interface ApiReplayAuditListResponse {
audits?: ApiReplayAuditRecord[];
}
@Injectable({ providedIn: 'root' })
export class DeadLetterClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/orchestrator/deadletter';
private readonly batchProgressById = new Map<string, BatchReplayProgress>();
/**
* List dead-letter entries with filters.
*/
list(
filter?: DeadLetterFilter,
limit: number = 50,
limit = 50,
cursor?: string
): Observable<DeadLetterListResponse> {
let params = new HttpParams().set('limit', limit.toString());
if (cursor) params = params.set('cursor', cursor);
if (filter?.state) params = params.set('state', filter.state);
if (filter?.errorCode) params = params.set('errorCode', filter.errorCode);
if (filter?.tenantId) params = params.set('tenantId', filter.tenantId);
if (filter?.jobType) params = params.set('jobType', filter.jobType);
if (filter?.olderThanHours) params = params.set('olderThanHours', filter.olderThanHours.toString());
if (filter?.search) params = params.set('search', filter.search);
if (filter?.dateFrom) params = params.set('dateFrom', filter.dateFrom);
if (filter?.dateTo) params = params.set('dateTo', filter.dateTo);
return this.http.get<DeadLetterListResponse>(this.baseUrl, { params });
const params = this.buildListParams(filter, limit, cursor);
return this.http
.get<ApiDeadLetterListResponse>(this.baseUrl, { params })
.pipe(map((response) => this.mapListResponse(response)));
}
/**
* Get dead-letter entry details.
*/
getEntry(entryId: string): Observable<DeadLetterEntry> {
return this.http.get<DeadLetterEntry>(`${this.baseUrl}/${entryId}`);
return this.http
.get<ApiDeadLetterEntry>(`${this.baseUrl}/${entryId}`)
.pipe(map((entry) => this.mapEntryDetail(entry)));
}
/**
* Get queue statistics and summary.
*/
getStats(): Observable<DeadLetterStatsSummary> {
return this.http.get<DeadLetterStatsSummary>(`${this.baseUrl}/stats`);
return forkJoin({
stats: this.http
.get<ApiDeadLetterStatsResponse>(`${this.baseUrl}/stats`)
.pipe(catchError(() => of({} as ApiDeadLetterStatsResponse))),
summary: this.http
.get<ApiDeadLetterSummaryResponse>(`${this.baseUrl}/summary`)
.pipe(catchError(() => of({ summaries: [] }))),
}).pipe(
map(({ stats, summary }) => this.mapStatsSummary(stats, summary))
);
}
/**
* Replay a single entry.
*/
replay(entryId: string, options?: ReplayRequest): Observable<ReplayResponse> {
return this.http.post<ReplayResponse>(`${this.baseUrl}/${entryId}/replay`, options || {});
return this.http
.post<ApiReplayResponse>(`${this.baseUrl}/${entryId}/replay`, options || {})
.pipe(
map((response) => ({
success: response.success ?? false,
newJobId: response.newJobId ?? undefined,
error: response.error ?? response.errorMessage ?? undefined,
}))
);
}
/**
* Batch replay by filter.
*/
batchReplay(request: BatchReplayRequest): Observable<BatchReplayResponse> {
return this.http.post<BatchReplayResponse>(`${this.baseUrl}/replay/batch`, request);
return this.list(request.filter, 200).pipe(
switchMap((listResponse) => {
const entryIds = listResponse.items
.map((entry) => entry.id)
.filter((id) => id.length > 0);
if (entryIds.length === 0) {
return of(
this.mapBatchReplayResponse({
attempted: 0,
succeeded: 0,
failed: 0,
})
);
}
return this.http
.post<ApiBatchResultResponse>(`${this.baseUrl}/replay/batch`, { entryIds })
.pipe(map((response) => this.mapBatchReplayResponse(response)));
})
);
}
/**
* Replay all pending retryable entries.
*/
replayAllPending(options?: ReplayRequest): Observable<BatchReplayResponse> {
return this.http.post<BatchReplayResponse>(`${this.baseUrl}/replay/pending`, options || {});
replayAllPending(_options?: ReplayRequest): Observable<BatchReplayResponse> {
return this.http
.post<ApiBatchResultResponse>(`${this.baseUrl}/replay/pending`, {})
.pipe(map((response) => this.mapBatchReplayResponse(response)));
}
/**
* Get batch replay progress.
*/
getBatchProgress(batchId: string): Observable<BatchReplayProgress> {
return this.http.get<BatchReplayProgress>(`${this.baseUrl}/replay/batch/${batchId}`);
const progress =
this.batchProgressById.get(batchId) ??
{
batchId,
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
pending: 0,
status: 'completed' as const,
};
return of(progress);
}
/**
* Manually resolve an entry.
*/
resolve(entryId: string, request: ResolveRequest): Observable<DeadLetterEntry> {
return this.http.post<DeadLetterEntry>(`${this.baseUrl}/${entryId}/resolve`, request);
return this.http
.post<ApiDeadLetterEntry>(`${this.baseUrl}/${entryId}/resolve`, {
notes: request.notes ?? request.reason,
})
.pipe(map((entry) => this.mapEntryDetail(entry)));
}
/**
* Batch resolve entries.
*/
batchResolve(entryIds: string[], request: ResolveRequest): Observable<{ resolved: number }> {
return this.http.post<{ resolved: number }>(`${this.baseUrl}/resolve/batch`, {
entryIds,
...request,
});
return this.http
.post<ApiResolveBatchResponse>(`${this.baseUrl}/resolve/batch`, {
entryIds,
notes: request.notes ?? request.reason,
})
.pipe(
map((response) => ({
resolved: response.resolvedCount ?? 0,
}))
);
}
/**
* Get entry audit history.
*/
getAuditHistory(entryId: string): Observable<DeadLetterAuditEvent[]> {
return this.http.get<DeadLetterAuditEvent[]>(`${this.baseUrl}/${entryId}/audit`);
return this.http
.get<ApiReplayAuditListResponse | DeadLetterAuditEvent[]>(
`${this.baseUrl}/${entryId}/audit`
)
.pipe(
map((response) => this.mapAuditEvents(response)),
catchError(() => of([]))
);
}
/**
* Export dead-letter entries as CSV.
*/
export(filter?: DeadLetterFilter): Observable<Blob> {
let params = new HttpParams();
if (filter?.state) params = params.set('state', filter.state);
if (filter?.state) params = params.set('status', this.toApiState(filter.state));
if (filter?.errorCode) params = params.set('errorCode', filter.errorCode);
if (filter?.tenantId) params = params.set('tenantId', filter.tenantId);
if (filter?.dateFrom) params = params.set('dateFrom', filter.dateFrom);
if (filter?.dateTo) params = params.set('dateTo', filter.dateTo);
if (filter?.dateFrom) params = params.set('createdAfter', filter.dateFrom);
if (filter?.dateTo) params = params.set('createdBefore', filter.dateTo);
return this.http.get(`${this.baseUrl}/export`, {
params,
responseType: 'blob',
});
}
private buildListParams(filter?: DeadLetterFilter, limit = 50, cursor?: string): HttpParams {
let params = new HttpParams().set('limit', limit.toString());
if (cursor) params = params.set('cursor', cursor);
if (filter?.state) params = params.set('status', this.toApiState(filter.state));
if (filter?.errorCode) params = params.set('errorCode', filter.errorCode);
if (filter?.jobType) params = params.set('jobType', filter.jobType);
if (filter?.dateFrom) params = params.set('createdAfter', filter.dateFrom);
if (filter?.dateTo) params = params.set('createdBefore', filter.dateTo);
if (filter?.olderThanHours && filter.olderThanHours > 0) {
const before = new Date(Date.now() - filter.olderThanHours * 60 * 60 * 1000).toISOString();
params = params.set('createdBefore', before);
}
return params;
}
private mapListResponse(response: ApiDeadLetterListResponse): DeadLetterListResponse {
const rawItems = response.items ?? response.entries ?? [];
const items = rawItems
.map((entry) => this.mapEntrySummary(entry))
.filter((entry): entry is DeadLetterEntrySummary => !!entry);
return {
items,
total: response.total ?? response.totalCount ?? items.length,
cursor: response.cursor ?? response.nextCursor,
};
}
private mapStatsSummary(
stats: ApiDeadLetterStatsResponse,
summary: ApiDeadLetterSummaryResponse
): DeadLetterStatsSummary {
if (stats.stats) {
return {
stats: stats.stats,
byErrorType: stats.byErrorType ?? [],
byTenant: stats.byTenant ?? [],
trend: stats.trend ?? [],
};
}
const topErrorCodes = stats.topErrorCodes ?? {};
const summaryCounts = (summary.summaries ?? [])
.filter((item) => !!item.errorCode)
.map((item) => ({
errorCode: this.toErrorCode(item.errorCode),
count: item.entryCount ?? 0,
}));
const fallbackCounts = Object.entries(topErrorCodes).map(([code, count]) => ({
errorCode: this.toErrorCode(code),
count,
}));
const byErrorTypeSource = summaryCounts.length > 0 ? summaryCounts : fallbackCounts;
const totalForPercentages = byErrorTypeSource.reduce((acc, item) => acc + item.count, 0);
const byErrorType = byErrorTypeSource.map((item) => ({
errorCode: item.errorCode,
count: item.count,
percentage: totalForPercentages > 0 ? (item.count / totalForPercentages) * 100 : 0,
}));
return {
stats: {
total: stats.totalEntries ?? 0,
pending: stats.pendingEntries ?? 0,
retrying: stats.replayingEntries ?? 0,
resolved: stats.resolvedEntries ?? 0,
replayed: stats.replayedEntries ?? 0,
failed: (stats.exhaustedEntries ?? 0) + (stats.expiredEntries ?? 0),
olderThan24h: 0,
retryable: stats.retryableEntries ?? 0,
},
byErrorType,
byTenant: [],
trend: [],
};
}
private mapEntrySummary(entry: ApiDeadLetterEntry): DeadLetterEntrySummary | null {
const id = entry.id ?? entry.entryId ?? '';
if (!id) return null;
const createdAt = entry.createdAt ?? entry.failedAt ?? new Date().toISOString();
return {
id,
jobId: entry.jobId ?? entry.originalJobId ?? id,
jobType: entry.jobType ?? 'unknown',
tenantId: entry.tenantId ?? 'default',
tenantName: entry.tenantName ?? entry.tenantId ?? 'default',
state: this.toUiState(entry.state ?? entry.status),
errorCode: this.toErrorCode(entry.errorCode),
errorMessage: entry.errorMessage ?? entry.failureReason ?? 'Unknown error',
retryCount: entry.retryCount ?? entry.replayAttempts ?? 0,
maxRetries: entry.maxRetries ?? entry.maxReplayAttempts ?? 0,
age: this.computeAgeSeconds(createdAt),
createdAt,
};
}
private mapEntryDetail(entry: ApiDeadLetterEntry): DeadLetterEntry {
const summary = this.mapEntrySummary(entry) ?? {
id: entry.id ?? entry.entryId ?? '',
jobId: entry.jobId ?? entry.originalJobId ?? '',
jobType: entry.jobType ?? 'unknown',
tenantId: entry.tenantId ?? 'default',
tenantName: entry.tenantName ?? entry.tenantId ?? 'default',
state: this.toUiState(entry.state ?? entry.status),
errorCode: this.toErrorCode(entry.errorCode),
errorMessage: entry.errorMessage ?? entry.failureReason ?? 'Unknown error',
retryCount: entry.retryCount ?? entry.replayAttempts ?? 0,
maxRetries: entry.maxRetries ?? entry.maxReplayAttempts ?? 0,
age: 0,
createdAt: entry.createdAt ?? entry.failedAt ?? new Date().toISOString(),
};
const payload = this.parsePayload(entry.payload);
return {
...summary,
payload,
errorCategory: this.toErrorCategory(entry.category),
stackTrace: undefined,
updatedAt: entry.updatedAt ?? summary.createdAt,
resolvedAt: entry.resolvedAt ?? undefined,
resolvedBy: entry.updatedBy ?? undefined,
resolutionReason: undefined,
resolutionNotes: entry.resolutionNotes ?? undefined,
replayedJobId: undefined,
};
}
private mapAuditEvents(
response: ApiReplayAuditListResponse | DeadLetterAuditEvent[]
): DeadLetterAuditEvent[] {
if (Array.isArray(response)) {
return response;
}
return (response.audits ?? []).map((audit) => ({
id: audit.auditId ?? '',
entryId: audit.entryId ?? '',
action: audit.success ? 'replayed' : 'retry_failed',
timestamp: audit.triggeredAt ?? new Date().toISOString(),
actor: audit.triggeredBy ?? undefined,
details: {
attemptNumber: audit.attemptNumber ?? 0,
newJobId: audit.newJobId ?? null,
errorMessage: audit.errorMessage ?? null,
},
}));
}
private mapBatchReplayResponse(response: ApiBatchResultResponse): BatchReplayResponse {
if (response.batchId) {
return {
queued: response.queued ?? response.succeeded ?? 0,
skipped: response.skipped ?? response.failed ?? 0,
batchId: response.batchId,
};
}
const attempted = response.attempted ?? 0;
const succeeded = response.succeeded ?? 0;
const failed = response.failed ?? 0;
const batchId = this.createBatchId();
this.batchProgressById.set(batchId, {
batchId,
total: attempted,
completed: attempted,
succeeded,
failed,
pending: 0,
status: 'completed',
});
return {
queued: succeeded,
skipped: failed,
batchId,
};
}
private toApiState(state: DeadLetterState): string {
switch (state) {
case 'retrying':
return 'replaying';
case 'failed':
return 'exhausted';
default:
return state;
}
}
private toUiState(state: string | undefined): DeadLetterState {
const normalized = (state ?? '').toLowerCase();
switch (normalized) {
case 'replaying':
return 'retrying';
case 'resolved':
case 'replayed':
case 'pending':
case 'failed':
case 'retrying':
return normalized as DeadLetterState;
case 'exhausted':
case 'expired':
return 'failed';
default:
return 'pending';
}
}
private toErrorCode(value: string | undefined): ErrorCode {
const raw = (value ?? '').toUpperCase();
const known: readonly ErrorCode[] = [
'DLQ_TIMEOUT',
'DLQ_RESOURCE',
'DLQ_NETWORK',
'DLQ_DEPENDENCY',
'DLQ_VALIDATION',
'DLQ_POLICY',
'DLQ_AUTH',
'DLQ_CONFLICT',
'DLQ_UNKNOWN',
];
if (known.includes(raw as ErrorCode)) {
return raw as ErrorCode;
}
if (raw.includes('TIMEOUT')) return 'DLQ_TIMEOUT';
if (raw.includes('NETWORK') || raw.includes('CONNECTION') || raw.includes('DNS')) return 'DLQ_NETWORK';
if (raw.includes('RESOURCE') || raw.includes('MEMORY') || raw.includes('CPU')) return 'DLQ_RESOURCE';
if (raw.includes('DEPENDENCY') || raw.includes('SERVICE_UNAVAILABLE')) return 'DLQ_DEPENDENCY';
if (raw.includes('VALIDATION')) return 'DLQ_VALIDATION';
if (raw.includes('POLICY')) return 'DLQ_POLICY';
if (raw.includes('AUTH') || raw.includes('TOKEN')) return 'DLQ_AUTH';
if (raw.includes('CONFLICT') || raw.includes('DUPLICATE')) return 'DLQ_CONFLICT';
return 'DLQ_UNKNOWN';
}
private toErrorCategory(value: string | undefined): 'transient' | 'permanent' {
const normalized = (value ?? '').toLowerCase();
return normalized === 'transient' ? 'transient' : 'permanent';
}
private parsePayload(payload: unknown): Record<string, unknown> {
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload as Record<string, unknown>;
}
if (typeof payload === 'string') {
try {
const parsed = JSON.parse(payload) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return { raw: payload };
}
}
return {};
}
private computeAgeSeconds(createdAt: string): number {
const createdMillis = new Date(createdAt).getTime();
if (Number.isNaN(createdMillis)) return 0;
return Math.max(0, Math.floor((Date.now() - createdMillis) / 1000));
}
private createBatchId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `batch-${Date.now()}`;
}
}

View File

@@ -5,7 +5,7 @@
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { delay, map, switchMap } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import type {
Schedule,
@@ -33,6 +33,19 @@ export interface CreateScheduleDto {
export type UpdateScheduleDto = Partial<CreateScheduleDto>;
interface SchedulerScheduleEnvelope {
readonly schedule?: Record<string, unknown>;
readonly summary?: Record<string, unknown> | null;
}
interface SchedulerScheduleCollectionResponse {
readonly schedules?: readonly SchedulerScheduleEnvelope[];
}
interface SchedulerRunsPreviewResponse {
readonly total?: number;
}
// ============================================================================
// API Interface
// ============================================================================
@@ -65,31 +78,55 @@ export class SchedulerHttpClient implements SchedulerApi {
) {}
listSchedules(): Observable<Schedule[]> {
return this.http.get<Schedule[]>(`${this.baseUrl}/schedules/`, {
return this.http.get<SchedulerScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules/`, {
headers: this.buildHeaders(),
});
}).pipe(
map((response) => this.mapScheduleList(response)),
);
}
getSchedule(id: string): Observable<Schedule> {
return this.http.get<Schedule>(`${this.baseUrl}/schedules/${id}`, {
return this.http.get<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/${id}`, {
headers: this.buildHeaders(),
});
}).pipe(
map((response) => this.mapSchedule(response)),
);
}
createSchedule(schedule: CreateScheduleDto): Observable<Schedule> {
return this.http.post<Schedule>(`${this.baseUrl}/schedules/`, schedule, {
const payload = this.toCreateRequest(schedule);
return this.http.post<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/`, payload, {
headers: this.buildHeaders(),
});
}).pipe(
map((response) => this.mapSchedule(response)),
);
}
updateSchedule(id: string, schedule: UpdateScheduleDto): Observable<Schedule> {
return this.http.put<Schedule>(`${this.baseUrl}/schedules/${id}`, schedule, {
headers: this.buildHeaders(),
});
const headers = this.buildHeaders();
const payload = this.toUpdateRequest(schedule);
return this.http.patch<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/${id}`, payload, {
headers,
}).pipe(
switchMap((response) => {
if (schedule.enabled === undefined) {
return of(response);
}
const toggle$ = schedule.enabled
? this.http.post<void>(`${this.baseUrl}/schedules/${id}/resume`, {}, { headers })
: this.http.post<void>(`${this.baseUrl}/schedules/${id}/pause`, {}, { headers });
return toggle$.pipe(map(() => response));
}),
map((response) => this.mapSchedule(response)),
);
}
deleteSchedule(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/schedules/${id}`, {
// Compatibility fallback: pausing removes the item from default list responses.
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/pause`, {}, {
headers: this.buildHeaders(),
});
}
@@ -107,15 +144,180 @@ export class SchedulerHttpClient implements SchedulerApi {
}
triggerSchedule(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/trigger`, {}, {
return this.http.post<void>(`${this.baseUrl}/runs/`, {
scheduleId: id,
trigger: 'manual',
reason: {
manualReason: 'Triggered from schedule management UI',
},
}, {
headers: this.buildHeaders(),
});
}
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
return this.http.post<ScheduleImpactPreview>(`${this.baseUrl}/schedules/preview-impact`, schedule, {
previewImpact(_schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
return this.http.post<SchedulerRunsPreviewResponse>(`${this.baseUrl}/runs/preview`, {
selector: {
scope: 'all-images',
},
usageOnly: true,
sampleSize: 10,
}, {
headers: this.buildHeaders(),
});
}).pipe(
map((response) => {
const total = Number.isFinite(response?.total) ? Number(response.total) : 0;
const warnings = total > 1000
? [`Preview includes ${total} impacted records; consider a narrower selector.`]
: [];
return {
scheduleId: 'preview',
proposedChange: 'update',
affectedRuns: total,
nextRunTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
estimatedLoad: Math.min(100, Math.max(5, total > 0 ? Math.round(total / 20) : 5)),
conflicts: [],
warnings,
} satisfies ScheduleImpactPreview;
}),
);
}
private mapScheduleList(payload: SchedulerScheduleCollectionResponse | Schedule[]): Schedule[] {
if (Array.isArray(payload)) {
return payload.map((entry) => this.mapSchedule(entry));
}
const entries = Array.isArray(payload?.schedules) ? payload.schedules : [];
return entries.map((entry) => this.mapSchedule(entry));
}
private mapSchedule(payload: SchedulerScheduleEnvelope | Schedule): Schedule {
const envelope = payload as SchedulerScheduleEnvelope;
const schedule = (envelope?.schedule ?? payload) as Record<string, unknown>;
const summary = envelope?.summary as Record<string, unknown> | null | undefined;
const limits = this.asRecord(schedule?.['limits']);
const recentRuns = Array.isArray(summary?.['recentRuns'])
? summary['recentRuns'] as readonly Record<string, unknown>[]
: [];
const lastRunAt = recentRuns.length > 0
? this.readString(recentRuns[0], 'completedAt')
: undefined;
const maxJobs = this.readNumber(limits, 'maxJobs');
const maxRetries = maxJobs > 0
? Math.min(10, Math.max(1, Math.round(maxJobs / 10)))
: 3;
return {
id: this.readString(schedule, 'id') || `sch-${Date.now()}`,
name: this.readString(schedule, 'name') || 'Unnamed schedule',
description: this.readString(schedule, 'description') || '',
cronExpression: this.readString(schedule, 'cronExpression') || '0 6 * * *',
timezone: this.readString(schedule, 'timezone') || 'UTC',
enabled: this.readBoolean(schedule, 'enabled', true),
taskType: this.inferTaskType(this.readString(schedule, 'mode')),
taskConfig: {},
lastRunAt,
nextRunAt: undefined,
createdAt: this.readString(schedule, 'createdAt') || new Date().toISOString(),
updatedAt: this.readString(schedule, 'updatedAt') || new Date().toISOString(),
createdBy: this.readString(schedule, 'createdBy') || 'system',
tags: [],
retryPolicy: {
maxRetries,
backoffMultiplier: 2,
initialDelayMs: 1000,
maxDelayMs: 60000,
},
concurrencyLimit: Math.max(1, this.readNumber(limits, 'parallelism') || 1),
};
}
private toCreateRequest(schedule: CreateScheduleDto): Record<string, unknown> {
return {
name: schedule.name,
cronExpression: schedule.cronExpression,
timezone: schedule.timezone,
enabled: schedule.enabled,
mode: this.toSchedulerMode(schedule.taskType),
selection: {
scope: 'all-images',
},
limits: {
parallelism: schedule.concurrencyLimit ?? 1,
},
};
}
private toUpdateRequest(schedule: UpdateScheduleDto): Record<string, unknown> {
const request: Record<string, unknown> = {};
if (schedule.name !== undefined) {
request['name'] = schedule.name;
}
if (schedule.cronExpression !== undefined) {
request['cronExpression'] = schedule.cronExpression;
}
if (schedule.timezone !== undefined) {
request['timezone'] = schedule.timezone;
}
if (schedule.taskType !== undefined) {
request['mode'] = this.toSchedulerMode(schedule.taskType);
}
if (schedule.concurrencyLimit !== undefined) {
request['limits'] = { parallelism: schedule.concurrencyLimit };
}
return request;
}
private toSchedulerMode(taskType: ScheduleTaskType): string {
switch (taskType) {
case 'scan':
case 'cleanup':
case 'custom':
return 'analysis-only';
default:
return 'content-refresh';
}
}
private inferTaskType(mode: string): ScheduleTaskType {
return mode.toLowerCase() === 'content-refresh'
? 'vulnerability-sync'
: 'scan';
}
private readString(source: Record<string, unknown> | null | undefined, key: string): string {
const value = source?.[key];
return typeof value === 'string' ? value : '';
}
private readNumber(source: Record<string, unknown> | null | undefined, key: string): number {
const value = source?.[key];
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
private readBoolean(source: Record<string, unknown> | null | undefined, key: string, fallback: boolean): boolean {
const value = source?.[key];
return typeof value === 'boolean' ? value : fallback;
}
private asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
private buildHeaders(): HttpHeaders {

View File

@@ -6,11 +6,13 @@ import {
inject,
Input,
OnChanges,
OnInit,
Output,
signal,
SimpleChanges,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import {
FeedMirror,
FeedSnapshot,
@@ -20,6 +22,25 @@ import {
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
import { SnapshotActionsComponent } from './snapshot-actions.component';
const EMPTY_FEED_MIRROR: FeedMirror = {
mirrorId: '',
name: 'Loading mirror...',
feedType: 'custom',
upstreamUrl: '',
localPath: '',
enabled: false,
syncStatus: 'pending',
lastSyncAt: null,
nextSyncAt: null,
syncIntervalMinutes: 60,
snapshotCount: 0,
totalSizeBytes: 0,
latestSnapshotId: null,
errorMessage: null,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
};
@Component({
selector: 'app-mirror-detail',
imports: [CommonModule, FormsModule, SnapshotActionsComponent],
@@ -781,27 +802,72 @@ import { SnapshotActionsComponent } from './snapshot-actions.component';
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MirrorDetailComponent implements OnChanges {
export class MirrorDetailComponent implements OnInit, OnChanges {
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
private readonly route = inject(ActivatedRoute);
private mirrorState: FeedMirror = EMPTY_FEED_MIRROR;
@Input() set mirror(value: FeedMirror | null | undefined) {
this.mirrorState = value ?? EMPTY_FEED_MIRROR;
}
get mirror(): FeedMirror {
return this.mirrorState;
}
@Input({ required: true }) mirror!: FeedMirror;
@Output() back = new EventEmitter<void>();
readonly snapshots = signal<readonly FeedSnapshot[]>([]);
readonly retentionConfig = signal<SnapshotRetentionConfig | null>(null);
readonly loadingSnapshots = signal(true);
readonly loadingMirror = signal(false);
readonly syncing = signal(false);
readonly showSettings = signal(false);
readonly settingsSyncInterval = signal(0);
readonly settingsUpstreamUrl = signal('');
ngOnChanges(changes: SimpleChanges): void {
if (changes['mirror']) {
this.loadSnapshots();
this.loadRetentionConfig();
this.settingsSyncInterval.set(this.mirror.syncIntervalMinutes);
this.settingsUpstreamUrl.set(this.mirror.upstreamUrl);
ngOnInit(): void {
const routeMirrorId = this.route.snapshot.paramMap.get('mirrorId');
if (routeMirrorId && !this.hasMirrorData()) {
this.loadMirrorById(routeMirrorId);
return;
}
if (this.hasMirrorData()) {
this.initializeMirrorState();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['mirror'] && this.hasMirrorData()) {
this.initializeMirrorState();
}
}
private initializeMirrorState(): void {
this.settingsSyncInterval.set(this.mirror.syncIntervalMinutes);
this.settingsUpstreamUrl.set(this.mirror.upstreamUrl);
this.loadSnapshots();
this.loadRetentionConfig();
}
private hasMirrorData(): boolean {
return !!this.mirror?.mirrorId;
}
private loadMirrorById(mirrorId: string): void {
this.loadingMirror.set(true);
this.feedMirrorApi.getMirror(mirrorId).subscribe({
next: (mirror) => {
this.mirror = mirror;
this.loadingMirror.set(false);
this.initializeMirrorState();
},
error: (err) => {
console.error('Failed to load mirror details:', err);
this.loadingMirror.set(false);
},
});
}
private loadSnapshots(): void {
@@ -826,6 +892,10 @@ export class MirrorDetailComponent implements OnChanges {
}
toggleEnabled(event: Event): void {
if (!this.hasMirrorData()) {
return;
}
const checked = (event.target as HTMLInputElement).checked;
const update: MirrorConfigUpdate = { enabled: checked };
this.feedMirrorApi.updateMirrorConfig(this.mirror.mirrorId, update).subscribe({
@@ -835,6 +905,10 @@ export class MirrorDetailComponent implements OnChanges {
}
triggerSync(): void {
if (!this.hasMirrorData()) {
return;
}
this.syncing.set(true);
this.feedMirrorApi.triggerSync({ mirrorId: this.mirror.mirrorId }).subscribe({
next: (result) => {
@@ -850,6 +924,10 @@ export class MirrorDetailComponent implements OnChanges {
}
saveSettings(): void {
if (!this.hasMirrorData()) {
return;
}
const update: MirrorConfigUpdate = {
syncIntervalMinutes: this.settingsSyncInterval(),
upstreamUrl: this.settingsUpstreamUrl(),

View File

@@ -0,0 +1,58 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const events = [];
const push = (kind, payload) => events.push({ ts: new Date().toISOString(), kind, ...payload, page: page.url() });
page.on('console', msg => {
if (msg.type() === 'error') {
push('console_error', { text: msg.text() });
}
});
page.on('requestfailed', request => {
const url = request.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
push('request_failed', {
method: request.method(),
url,
error: request.failure()?.errorText ?? 'unknown'
});
});
page.on('response', response => {
const url = response.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
if (response.status() >= 400) {
push('response_error', { status: response.status(), method: response.request().method(), url });
}
});
await page.goto('https://stella-ops.local/welcome', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force: true, noWaitAfter: true });
await page.waitForTimeout(1000);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill('admin');
await page.locator('input[name="password"]').first().fill('Admin@Stella2026!');
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const path of ['/evidence/proof-chains', '/policy/packs']) {
await page.goto(`https://stella-ops.local${path}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
}
console.log(JSON.stringify(events, null, 2));
await browser.close();
})();

View File

@@ -0,0 +1,49 @@
const { chromium } = require('playwright');
const BASE='https://stella-ops.local';
const USER='admin';
const PASS='Admin@Stella2026!';
(async () => {
const browser = await chromium.launch({ headless: true, args:['--disable-dev-shm-usage'] });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport:{width:1511,height:864} });
const page = await ctx.newPage();
const failed=[];
const responses=[];
page.on('requestfailed', req => {
const url=req.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
failed.push({ url, method:req.method(), error:req.failure()?.errorText || 'unknown', page: page.url() });
});
page.on('response', res => {
const url=res.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
if (res.status() >= 400) {
responses.push({ status: res.status(), method: res.request().method(), url, page: page.url() });
}
});
await page.goto(`${BASE}/welcome`, { waitUntil:'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force:true, noWaitAfter:true });
await page.waitForTimeout(1200);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill(USER);
await page.locator('input[name="password"]').first().fill(PASS);
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const p of ['/security/exceptions','/evidence/proof-chains']) {
await page.goto(`${BASE}${p}`, { waitUntil:'domcontentloaded' });
await page.waitForTimeout(2200);
}
await browser.close();
console.log(JSON.stringify({ failed, responses }, null, 2));
})();

View File

@@ -0,0 +1,65 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const failed = [];
const websockets = [];
page.on('requestfailed', request => {
const url = request.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) {
return;
}
failed.push({
url,
method: request.method(),
error: request.failure()?.errorText ?? 'unknown',
page: page.url(),
});
});
page.on('websocket', socket => {
const record = { url: socket.url(), events: [] };
websockets.push(record);
socket.on('framesent', () => record.events.push('sent'));
socket.on('framereceived', () => record.events.push('recv'));
socket.on('close', () => record.events.push('close'));
});
page.on('console', msg => {
if (msg.type() === 'error') {
console.log('console-error', msg.text(), '@', page.url());
}
});
await page.goto('https://stella-ops.local/welcome', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force: true, noWaitAfter: true });
await page.waitForTimeout(1200);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill('admin');
await page.locator('input[name="password"]').first().fill('Admin@Stella2026!');
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const path of ['/security/exceptions', '/evidence/proof-chains']) {
await page.goto(`https://stella-ops.local${path}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
}
const filteredFailed = failed.filter(item => !item.url.includes('/connect/authorize?'));
console.log(JSON.stringify({ filteredFailed, websockets }, null, 2));
await browser.close();
})();