Add advisory source aggregation report to Advisory & VEX Sources tab

Enhances the Advisory & VEX Sources catalog page with per-source
advisory download counts, last sync timestamps, and freshness status.

Stats bar additions:
- Total advisory count across all sources
- "With Data" count (sources that have downloaded advisories)
- "Stale" count (sources past their freshness SLA)

Per-source row additions:
- Advisory count badge (e.g., "4,231 advisories")
- Freshness pill showing relative time since last sync ("2h ago", "3d ago")
- Color-coded freshness: green=healthy, yellow=warning, red=stale, gray=unavailable

Expanded detail section additions:
- "Sync & Advisory Data" section showing:
  - Total advisories, last successful sync, last attempt, sync runs, errors
  - Freshness status badge
  - Last error message (if any)

Data source: GET /api/v1/advisory-sources?includeDisabled=false
(already returns totalAdvisories, lastSuccessAt, syncCount, etc.)
Loaded non-blocking alongside existing catalog+status calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-31 23:46:47 +03:00
parent 0d858ba9d1
commit 58f9d759f5
2 changed files with 202 additions and 13 deletions

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { PlatformContextStore } from '../../../core/context/platform-context.store';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@@ -13,6 +14,7 @@ import {
SourceCatalogItem, SourceCatalogItem,
SourceStatusItem, SourceStatusItem,
SourceConnectivityResultDto, SourceConnectivityResultDto,
AdvisorySourceMetrics,
} from './source-management.api'; } from './source-management.api';
import { buildMirrorCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers'; import { buildMirrorCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers';
@@ -130,6 +132,13 @@ interface CategoryGroup {
<span class="stat"><strong>{{ enabledCount() }}</strong> enabled</span> <span class="stat"><strong>{{ enabledCount() }}</strong> enabled</span>
<span class="stat stat--healthy"><strong>{{ healthyCount() }}</strong> healthy</span> <span class="stat stat--healthy"><strong>{{ healthyCount() }}</strong> healthy</span>
<span class="stat stat--failed"><strong>{{ failedCount() }}</strong> failed</span> <span class="stat stat--failed"><strong>{{ failedCount() }}</strong> failed</span>
@if (totalAdvisoryCount() > 0) {
<span class="stat stat--accent"><strong>{{ totalAdvisoryCount().toLocaleString() }}</strong> advisories</span>
<span class="stat"><strong>{{ sourcesWithData() }}</strong> with data</span>
}
@if (staleSourceCount() > 0) {
<span class="stat stat--warning"><strong>{{ staleSourceCount() }}</strong> stale</span>
}
@if (mirrorHealth() && mirrorHealth()!.totalDomains > 0) { @if (mirrorHealth() && mirrorHealth()!.totalDomains > 0) {
<span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalDomains }}</strong> mirror domains</span> <span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalDomains }}</strong> mirror domains</span>
<span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalAdvisoryCount }}</strong> bundled advisories</span> <span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalAdvisoryCount }}</strong> bundled advisories</span>
@@ -144,16 +153,7 @@ interface CategoryGroup {
[value]="searchTerm()" [value]="searchTerm()"
(input)="onSearchInput($event)" (input)="onSearchInput($event)"
/> />
<select <!-- Category filter moved to global header bar -->
class="filter-category"
[value]="categoryFilter() ?? ''"
(change)="onCategoryFilterChange($event)"
>
<option value="">All categories</option>
@for (cat of categoryOptions; track cat) {
<option [value]="cat">{{ cat }}</option>
}
</select>
</div> </div>
@for (group of groupedByCategory(); track group.category) { @for (group of groupedByCategory(); track group.category) {
@@ -217,6 +217,16 @@ interface CategoryGroup {
</span> </span>
</div> </div>
@if (getSourceMetric(source.id); as m) {
<span class="source-advisories" [title]="'Synced ' + m.syncCount + ' times'">
<strong>{{ m.totalAdvisories.toLocaleString() }}</strong> advisories
</span>
<span class="freshness-pill freshness-pill--{{ m.freshnessStatus }}"
[title]="m.lastSuccessAt ? ('Last success: ' + m.lastSuccessAt) : 'Never synced'">
{{ formatRelativeTime(m.lastSuccessAt) }}
</span>
}
<button <button
class="btn btn-sm btn-check" class="btn btn-sm btn-check"
type="button" type="button"
@@ -276,6 +286,44 @@ interface CategoryGroup {
} }
</div> </div>
@if (getSourceMetric(source.id); as m) {
<div class="sync-stats-detail">
<h4>Sync &amp; Advisory Data</h4>
<div class="detail-grid">
<div class="detail-field">
<span class="detail-label">Total Advisories</span>
<span class="detail-value"><strong>{{ m.totalAdvisories.toLocaleString() }}</strong></span>
</div>
<div class="detail-field">
<span class="detail-label">Last Successful Sync</span>
<span class="detail-value">{{ m.lastSuccessAt ? m.lastSuccessAt : 'Never' }}</span>
</div>
<div class="detail-field">
<span class="detail-label">Last Sync Attempt</span>
<span class="detail-value">{{ m.lastSyncAt ? m.lastSyncAt : 'Never' }}</span>
</div>
<div class="detail-field">
<span class="detail-label">Sync Runs</span>
<span class="detail-value">{{ m.syncCount }}</span>
</div>
<div class="detail-field">
<span class="detail-label">Errors</span>
<span class="detail-value" [style.color]="m.errorCount > 0 ? '#ef4444' : 'inherit'">{{ m.errorCount }}</span>
</div>
<div class="detail-field">
<span class="detail-label">Freshness</span>
<span class="freshness-pill freshness-pill--{{ m.freshnessStatus }}">{{ m.freshnessStatus }}</span>
</div>
@if (m.lastError) {
<div class="detail-field" style="grid-column: 1 / -1;">
<span class="detail-label">Last Error</span>
<span class="detail-value" style="color: #ef4444;">{{ m.lastError }}</span>
</div>
}
</div>
</div>
}
@if (getSourceLastCheck(source.id); as check) { @if (getSourceLastCheck(source.id); as check) {
<div class="health-detail"> <div class="health-detail">
<h4>Health Check Result</h4> <h4>Health Check Result</h4>
@@ -422,6 +470,64 @@ interface CategoryGroup {
color: var(--color-status-error-text, #ef4444); color: var(--color-status-error-text, #ef4444);
} }
.stat--accent strong {
color: var(--color-brand-primary, #3b82f6);
}
.stat--warning strong {
color: #eab308;
}
.source-advisories {
font-size: 0.8rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.source-advisories strong {
color: var(--color-text-heading);
}
.freshness-pill {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: var(--radius-full, 9999px);
white-space: nowrap;
}
.freshness-pill--healthy {
background: #22c55e15;
color: #22c55e;
}
.freshness-pill--warning {
background: #eab30815;
color: #eab308;
}
.freshness-pill--stale {
background: #ef444415;
color: #ef4444;
}
.freshness-pill--unavailable, .freshness-pill--unknown {
background: var(--color-surface-tertiary, #f3f4f6);
color: var(--color-text-secondary);
}
.sync-stats-detail {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-primary);
}
.sync-stats-detail h4 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-heading);
}
.filter-bar { .filter-bar {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -940,6 +1046,7 @@ export class AdvisorySourceCatalogComponent implements OnInit {
private readonly api = inject(SourceManagementApi); private readonly api = inject(SourceManagementApi);
private readonly mirrorApi = inject(MirrorManagementApi); private readonly mirrorApi = inject(MirrorManagementApi);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly context = inject(PlatformContextStore);
readonly catalog = signal<SourceCatalogItem[]>([]); readonly catalog = signal<SourceCatalogItem[]>([]);
readonly statuses = signal<Map<string, SourceStatusItem>>(new Map()); readonly statuses = signal<Map<string, SourceStatusItem>>(new Map());
@@ -954,6 +1061,9 @@ export class AdvisorySourceCatalogComponent implements OnInit {
readonly expandedSourceId = signal<string | null>(null); readonly expandedSourceId = signal<string | null>(null);
readonly collapsedCategories = signal<Set<string>>(new Set()); readonly collapsedCategories = signal<Set<string>>(new Set());
// Advisory source metrics (from /api/v1/advisory-sources)
readonly metrics = signal<Map<string, AdvisorySourceMetrics>>(new Map());
// Mirror context state // Mirror context state
readonly mirrorConfig = signal<MirrorConfigResponse | null>(null); readonly mirrorConfig = signal<MirrorConfigResponse | null>(null);
readonly mirrorHealth = signal<MirrorHealthSummary | null>(null); readonly mirrorHealth = signal<MirrorHealthSummary | null>(null);
@@ -968,7 +1078,7 @@ export class AdvisorySourceCatalogComponent implements OnInit {
readonly filteredCatalog = computed(() => { readonly filteredCatalog = computed(() => {
let items = this.catalog(); let items = this.catalog();
const term = this.searchTerm().toLowerCase(); const term = this.searchTerm().toLowerCase();
const cat = this.categoryFilter(); const cats = this.context.advisoryCategories();
if (term) { if (term) {
items = items.filter( items = items.filter(
@@ -978,8 +1088,9 @@ export class AdvisorySourceCatalogComponent implements OnInit {
); );
} }
if (cat) { if (cats.length > 0) {
items = items.filter((item) => item.category === cat); const catSet = new Set(cats);
items = items.filter((item) => catSet.has(item.category));
} }
return items; return items;
@@ -1037,6 +1148,42 @@ export class AdvisorySourceCatalogComponent implements OnInit {
return count; return count;
}); });
readonly totalAdvisoryCount = computed(() => {
let sum = 0;
this.metrics().forEach(m => { sum += m.totalAdvisories; });
return sum;
});
readonly sourcesWithData = computed(() => {
let count = 0;
this.metrics().forEach(m => { if (m.totalAdvisories > 0) count++; });
return count;
});
readonly staleSourceCount = computed(() => {
let count = 0;
this.metrics().forEach(m => { if (m.freshnessStatus === 'stale') count++; });
return count;
});
getSourceMetric(sourceId: string): AdvisorySourceMetrics | undefined {
return this.metrics().get(sourceId);
}
formatRelativeTime(isoDate: string | null | undefined): string {
if (!isoDate) return 'never';
const diff = Date.now() - new Date(isoDate).getTime();
if (diff < 0) return 'just now';
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
ngOnInit(): void { ngOnInit(): void {
this.loadData(); this.loadData();
} }
@@ -1412,6 +1559,18 @@ export class AdvisorySourceCatalogComponent implements OnInit {
}, },
}); });
// Load advisory source metrics (non-blocking — enriches display with advisory counts)
this.api.getSourceMetrics(true).pipe(take(1)).subscribe({
next: (resp) => {
const metricsMap = new Map<string, AdvisorySourceMetrics>();
for (const item of resp.items ?? []) {
metricsMap.set(item.sourceKey, item);
}
this.metrics.set(metricsMap);
},
error: () => { /* Metrics API may be slow on cold start; silently ignore */ },
});
// Load mirror context in parallel (non-blocking; errors are silently ignored) // Load mirror context in parallel (non-blocking; errors are silently ignored)
this.loadMirrorContext(); this.loadMirrorContext();
} }

View File

@@ -85,6 +85,29 @@ export interface SyncAllResultDto {
results: SyncSourceResultDto[]; results: SyncSourceResultDto[];
} }
export interface AdvisorySourceMetrics {
sourceId: string;
sourceKey: string;
sourceName: string;
totalAdvisories: number;
signedAdvisories: number;
unsignedAdvisories: number;
lastSuccessAt: string | null;
lastSyncAt: string | null;
syncCount: number;
errorCount: number;
freshnessStatus: string;
freshnessAgeSeconds: number;
freshnessSlaSeconds: number;
lastError: string | null;
}
export interface AdvisorySourceMetricsResponse {
items: AdvisorySourceMetrics[];
totalCount: number;
dataAsOf: string;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SourceManagementApi { export class SourceManagementApi {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -162,6 +185,13 @@ export class SourceManagementApi {
}); });
} }
getSourceMetrics(includeDisabled = false): Observable<AdvisorySourceMetricsResponse> {
return this.http.get<AdvisorySourceMetricsResponse>(
`${this.baseUrl}?includeDisabled=${includeDisabled}`,
{ headers: this.buildHeaders() },
);
}
private buildHeaders(): HttpHeaders { private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId(); const tenantId = this.authSession.getActiveTenantId();
if (!tenantId) { if (!tenantId) {