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:
@@ -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 & 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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user