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 { Router, RouterModule } from '@angular/router';
import { PlatformContextStore } from '../../../core/context/platform-context.store';
import { forkJoin } from 'rxjs';
import { take } from 'rxjs/operators';
@@ -13,6 +14,7 @@ import {
SourceCatalogItem,
SourceStatusItem,
SourceConnectivityResultDto,
AdvisorySourceMetrics,
} from './source-management.api';
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 stat--healthy"><strong>{{ healthyCount() }}</strong> healthy</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) {
<span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalDomains }}</strong> mirror domains</span>
<span class="stat stat--mirror"><strong>{{ mirrorHealth()!.totalAdvisoryCount }}</strong> bundled advisories</span>
@@ -144,16 +153,7 @@ interface CategoryGroup {
[value]="searchTerm()"
(input)="onSearchInput($event)"
/>
<select
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>
<!-- Category filter moved to global header bar -->
</div>
@for (group of groupedByCategory(); track group.category) {
@@ -217,6 +217,16 @@ interface CategoryGroup {
</span>
</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
class="btn btn-sm btn-check"
type="button"
@@ -276,6 +286,44 @@ interface CategoryGroup {
}
</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) {
<div class="health-detail">
<h4>Health Check Result</h4>
@@ -422,6 +470,64 @@ interface CategoryGroup {
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 {
display: flex;
gap: 0.75rem;
@@ -940,6 +1046,7 @@ export class AdvisorySourceCatalogComponent implements OnInit {
private readonly api = inject(SourceManagementApi);
private readonly mirrorApi = inject(MirrorManagementApi);
private readonly router = inject(Router);
private readonly context = inject(PlatformContextStore);
readonly catalog = signal<SourceCatalogItem[]>([]);
readonly statuses = signal<Map<string, SourceStatusItem>>(new Map());
@@ -954,6 +1061,9 @@ export class AdvisorySourceCatalogComponent implements OnInit {
readonly expandedSourceId = signal<string | null>(null);
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
readonly mirrorConfig = signal<MirrorConfigResponse | null>(null);
readonly mirrorHealth = signal<MirrorHealthSummary | null>(null);
@@ -968,7 +1078,7 @@ export class AdvisorySourceCatalogComponent implements OnInit {
readonly filteredCatalog = computed(() => {
let items = this.catalog();
const term = this.searchTerm().toLowerCase();
const cat = this.categoryFilter();
const cats = this.context.advisoryCategories();
if (term) {
items = items.filter(
@@ -978,8 +1088,9 @@ export class AdvisorySourceCatalogComponent implements OnInit {
);
}
if (cat) {
items = items.filter((item) => item.category === cat);
if (cats.length > 0) {
const catSet = new Set(cats);
items = items.filter((item) => catSet.has(item.category));
}
return items;
@@ -1037,6 +1148,42 @@ export class AdvisorySourceCatalogComponent implements OnInit {
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 {
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)
this.loadMirrorContext();
}

View File

@@ -85,6 +85,29 @@ export interface SyncAllResultDto {
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' })
export class SourceManagementApi {
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 {
const tenantId = this.authSession.getActiveTenantId();
if (!tenantId) {