Finish off old sprints
This commit is contained in:
BIN
src/Web/StellaOps.Web/qa-sidebar-manual-screens/security.png
Normal file
BIN
src/Web/StellaOps.Web/qa-sidebar-manual-screens/security.png
Normal file
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 |
BIN
src/Web/StellaOps.Web/qa-sidebar-manual-screens/security_vex.png
Normal file
BIN
src/Web/StellaOps.Web/qa-sidebar-manual-screens/security_vex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
58
src/Web/StellaOps.Web/tmp-debug-errors.js
Normal file
58
src/Web/StellaOps.Web/tmp-debug-errors.js
Normal 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();
|
||||
})();
|
||||
49
src/Web/StellaOps.Web/tmp-debug-requestfailed.js
Normal file
49
src/Web/StellaOps.Web/tmp-debug-requestfailed.js
Normal 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));
|
||||
})();
|
||||
65
src/Web/StellaOps.Web/tmp-debug-requestfailed2.js
Normal file
65
src/Web/StellaOps.Web/tmp-debug-requestfailed2.js
Normal 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user