feat(web): derive timeline-list into canonical audit-grade event-stream timeline [SPRINT-029]
Rework the orphan TimelineListComponent into a canonical audit-grade event-stream primitive for all mounted chronology surfaces. Canonical event model (FE-TLD-001): - TimelineEvent with id, timestamp (ISO-8601 UTC), title, description, actor, eventKind (info/success/warning/error/critical/neutral), icon, evidenceLink, metadata key-value pairs, and expandable detail payload - Relative time for <24h, absolute UTC for >=24h, full ISO on tooltip - Date grouping when events span multiple days Derived primitive (FE-TLD-002): - Vertical timeline with colored severity markers - Deterministic UTC timestamp formatting - Expandable detail sections with expand/collapse toggle - Optional actor, metadata chips, and evidence links - Loading skeleton and empty state - Accessibility: role="feed", role="article", aria-labels, datetime attrs - Content projection via ng-template for domain-specific rendering Adopted on 3 surfaces (FE-TLD-003): - incident-timeline: replaces bespoke inline timeline markers with shared component; preserves affected-services chips and correlated-events via expandable and content projection - audit-timeline-search: replaces bespoke timeline rendering; preserves module/action badges via content projection - releases-activity: replaces timeline view mode (was rendering duplicate table) with canonical timeline; preserves lane/env/outcome chips Tests (FE-TLD-004): 32 focused tests covering event rendering, severity markers, timestamp formatting, expandable toggle, loading/empty states, date grouping, accessibility, and default fallbacks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-TLD-001 - Freeze the canonical event model
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Product Manager
|
||||
Task description:
|
||||
@@ -28,12 +28,12 @@ Task description:
|
||||
- Decide where relative time, absolute time, and grouping should appear so audit and ops surfaces remain truthful and scannable.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] A canonical event model exists for mounted timeline surfaces.
|
||||
- [ ] Rules for relative vs absolute time display are documented.
|
||||
- [ ] Grouping or expansion expectations are defined before implementation.
|
||||
- [x] A canonical event model exists for mounted timeline surfaces.
|
||||
- [x] Rules for relative vs absolute time display are documented.
|
||||
- [x] Grouping or expansion expectations are defined before implementation.
|
||||
|
||||
### FE-TLD-002 - Derive the shared timeline primitive
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
@@ -41,45 +41,57 @@ Task description:
|
||||
- Avoid keeping a toy timeline component that cannot carry actual operator evidence.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] The shared timeline primitive supports the agreed event model.
|
||||
- [ ] Timestamp rendering is deterministic and appropriate for audit-grade surfaces.
|
||||
- [ ] The component supports richer detail than the current orphan implementation.
|
||||
- [x] The shared timeline primitive supports the agreed event model.
|
||||
- [x] Timestamp rendering is deterministic and appropriate for audit-grade surfaces.
|
||||
- [x] The component supports richer detail than the current orphan implementation.
|
||||
|
||||
### FE-TLD-003 - Adopt the derived timeline on mounted chronology surfaces
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Adopt the derived timeline on a small set of mounted chronology surfaces where it improves consistency without flattening domain-specific meaning.
|
||||
- Use the adoption set to validate both compact event streams and denser evidence timelines.
|
||||
|
||||
Adoption surfaces:
|
||||
1. **Incident Timeline** (`features/platform-health/incident-timeline.component.ts`) - replaced bespoke inline timeline with canonical component, preserving domain-specific affected-services chips and correlated-events expandable.
|
||||
2. **Audit Timeline Search** (`features/audit-log/audit-timeline-search.component.ts`) - replaced bespoke inline timeline with canonical component, preserving module/action badge rendering via content projection.
|
||||
3. **Releases Activity** (`features/releases/releases-activity.component.ts`) - replaced the timeline view mode (which was rendering a table identical to the table view) with the canonical timeline, preserving lane/environment/outcome chips via content projection.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] A bounded set of mounted chronology surfaces adopt the shared timeline.
|
||||
- [ ] Timeline UX improves on scanability and event meaning.
|
||||
- [ ] Domain-specific context is preserved, not lost to over-generalization.
|
||||
- [x] A bounded set of mounted chronology surfaces adopt the shared timeline.
|
||||
- [x] Timeline UX improves on scanability and event meaning.
|
||||
- [x] Domain-specific context is preserved, not lost to over-generalization.
|
||||
|
||||
### FE-TLD-004 - Verify and document the derivation
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused regression coverage for timeline formatting and document the canonical timeline contract and adoption choices.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Tests cover core timeline rendering and timestamp behavior.
|
||||
- [ ] Docs explain where the shared timeline is appropriate and where bespoke views still make sense.
|
||||
- [ ] The old orphan classification becomes intentional and documented.
|
||||
- [x] Tests cover core timeline rendering and timestamp behavior.
|
||||
- [x] Docs explain where the shared timeline is appropriate and where bespoke views still make sense.
|
||||
- [x] The old orphan classification becomes intentional and documented.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the unused timeline-list into a canonical event-stream pattern for mounted audit and evidence chronologies. | Codex |
|
||||
| 2026-03-08 | FE-TLD-001 DONE: Frozen canonical event model with TimelineEvent interface (id, timestamp, title, description, actor, eventKind, icon, evidenceLink, metadata, expandable). Time display rules: relative <24h, absolute UTC ISO-8601 >=24h, full ISO on tooltip. Date grouping supported. | Developer |
|
||||
| 2026-03-08 | FE-TLD-002 DONE: Derived TimelineListComponent with vertical timeline, colored severity markers (info/success/warning/error/critical/neutral), deterministic UTC timestamps, expandable detail sections, actor/source metadata, date grouping, loading skeleton, empty state, accessibility (role="feed", aria-labels), and content projection. | Developer |
|
||||
| 2026-03-08 | FE-TLD-003 DONE: Adopted on 3 surfaces: incident-timeline, audit-timeline-search, releases-activity (timeline view mode). Domain-specific context preserved via content projection. | Developer |
|
||||
| 2026-03-08 | FE-TLD-004 DONE: 32 focused tests covering event rendering, severity markers, timestamp formatting (relative vs absolute), expandable toggle, loading/empty states, date grouping, accessibility, and default fallbacks. Build passes. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- Risk: oversimplifying audit/evidence timelines could erase domain meaning or precision.
|
||||
- Mitigation: freeze the event model first and adopt only on bounded surfaces where the shared primitive fits cleanly.
|
||||
- Decision: Excluded witness/evidence hosts (sprint 031 territory), VEX timeline (domain-specific source-consensus visualization), pedigree timeline (horizontal ancestry lineage), observation timeline (SVG bar chart), and explainer timeline (process steps) from adoption because they are fundamentally different visualization patterns, not generic event streams.
|
||||
- Decision: Used content projection (ng-template #eventContent) to allow adopting surfaces to render domain-specific chips, badges, and links without modifying the shared component.
|
||||
- Decision: The `eventKind` field uses 'critical' as a distinct severity above 'error' (with visual emphasis via box-shadow ring).
|
||||
|
||||
## Next Checkpoints
|
||||
- Freeze the event model and time-display rules.
|
||||
- Build the richer shared timeline primitive.
|
||||
- Adopt it on a bounded set of mounted chronology surfaces.
|
||||
- Freeze the event model and time-display rules. -- DONE
|
||||
- Build the richer shared timeline primitive. -- DONE
|
||||
- Adopt it on a bounded set of mounted chronology surfaces. -- DONE
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md`
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md` - Derived `PageHeaderComponent` into canonical `ContextHeaderComponent` with unified header contract, adopted on 4 target pages, 15 focused tests.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md` - Derived MetricCardComponent into canonical KPI card with semantic delta handling, severity accents, and loading/empty/error states. Adopted on 3 dashboards (12 bespoke tiles replaced). 40 tests pass.
|
||||
- `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md`
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md` - Derived canonical audit-grade timeline-list primitive. Adopted on incident-timeline, audit-timeline-search, and releases-activity.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md` - Consolidated SplitPaneComponent into ListDetailShellComponent as the canonical master-detail layout primitive. Added collapsible toggle, detail slide-in animation, and accessibility roles. Adopted on signing-key-dashboard. SplitPaneComponent deprecated.
|
||||
- `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- The queued orphan batch currently spans `SPRINT_20260308_013` through `SPRINT_20260308_023` and is intentionally not marked active until product review approves staffing.
|
||||
- Newly queued follow-on planning sprints cover Settings information architecture rationalization plus UX derivation tracks for the orphan `PageHeaderComponent`, `MetricCardComponent`, `TimelineListComponent`, `SplitPaneComponent`, and `WitnessViewerComponent` (`SPRINT_20260308_026` through `SPRINT_20260308_031`).
|
||||
- Sprint `028` (MetricCardComponent derivation into canonical KPI card) is DONE. The shared `MetricCardComponent` now supports semantic delta direction (`up-is-good` / `up-is-bad` / `neutral`), severity accents, loading/empty/error states, and ARIA accessibility. Adopted on signals-runtime, search-quality, and delivery-analytics dashboards. See `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md`.
|
||||
- Sprint `029` (TimelineListComponent derivation) is DONE. Canonical audit-grade timeline primitive with 6 severity levels, UTC timestamps, expandable detail, date grouping, and content projection. Adopted on incident-timeline, audit-timeline-search, and releases-activity. See `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md`.
|
||||
- Sprint `030` (SplitPaneComponent consolidation into ListDetailShellComponent) is DONE. The canonical master-detail layout primitive is `ListDetailShellComponent` with collapsible toggle support. `SplitPaneComponent` is deprecated. Adopted on signing-key-dashboard (trust-admin). See sprint file for contract details.
|
||||
- Sprint `014` (CopyToClipboard, InlineCode, TruncatePipe adoption) is DONE. See `docs/features/checked/web/orphan-copy-inline-truncate-adoption.md`.
|
||||
- Sprint `015` (FilterBarComponent adoption) shipped, then was partially rolled back on audit-family pages to restore lost filter semantics. See `docs/features/checked/web/filter-bar-unification.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
|
||||
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
|
||||
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
function mapActionToKind(action: string): TimelineEventKind {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('create') || lower.includes('approve') || lower.includes('success')) return 'success';
|
||||
if (lower.includes('delete') || lower.includes('revoke') || lower.includes('fail')) return 'error';
|
||||
if (lower.includes('update') || lower.includes('modify')) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-timeline-search',
|
||||
imports: [RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, TimelineListComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="timeline-page">
|
||||
@@ -32,36 +41,25 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (entries().length > 0) {
|
||||
<div class="timeline">
|
||||
@for (entry of entries(); track entry.timestamp) {
|
||||
<div class="timeline-entry">
|
||||
<div class="timeline-marker">
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-line"></div>
|
||||
</div>
|
||||
<div class="entry-content">
|
||||
<div class="entry-time">{{ formatTime(entry.timestamp) }}</div>
|
||||
@if (entry.clusterSize && entry.clusterSize > 1) {
|
||||
<div class="cluster-badge">{{ entry.clusterSize }} events</div>
|
||||
}
|
||||
<div class="entry-events">
|
||||
@for (event of entry.events; track event.id) {
|
||||
<div class="event-item" [routerLink]="['/evidence/audit-log/events', event.id]">
|
||||
<span class="badge module" [class]="event.module">{{ event.module }}</span>
|
||||
<span class="badge action" [class]="event.action">{{ event.action }}</span>
|
||||
<span class="actor">{{ event.actor.name }}</span>
|
||||
<span class="desc">{{ event.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Canonical Timeline -->
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="searching()"
|
||||
[groupByDate]="true"
|
||||
[emptyMessage]="searched() && !searching() ? 'No events found matching your search.' : 'Enter a search query to find audit events.'"
|
||||
ariaLabel="Audit timeline search results"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata && event.metadata['module']) {
|
||||
<div class="event-badges">
|
||||
<span class="badge badge--module" [attr.data-module]="event.metadata['module']">{{ event.metadata['module'] }}</span>
|
||||
@if (event.metadata['action']) {
|
||||
<span class="badge badge--action">{{ event.metadata['action'] }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (searched() && !searching()) {
|
||||
<div class="no-results">No events found matching your search.</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -69,36 +67,27 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
|
||||
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
h1 { margin: 0 0 0.25rem; }
|
||||
.description { color: var(--color-text-secondary); margin: 0; }
|
||||
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 2rem; }
|
||||
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; }
|
||||
.search-bar input[type="text"] { flex: 1; min-width: 250px; padding: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 1rem; }
|
||||
.date-filters { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.date-filters input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
|
||||
.date-filters span { color: var(--color-text-secondary); }
|
||||
.btn-primary { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.75rem 1.5rem; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.timeline { position: relative; }
|
||||
.timeline-entry { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.timeline-marker { display: flex; flex-direction: column; align-items: center; width: 20px; }
|
||||
.marker-dot { width: 12px; height: 12px; border-radius: var(--radius-full); background: var(--color-brand-primary); border: 2px solid var(--color-surface-primary); z-index: 1; }
|
||||
.marker-line { width: 2px; flex: 1; background: var(--color-border-primary); margin-top: 4px; }
|
||||
.timeline-entry:last-child .marker-line { display: none; }
|
||||
.entry-content { flex: 1; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; }
|
||||
.entry-time { font-family: monospace; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
|
||||
.cluster-badge { display: inline-block; background: var(--color-surface-elevated); padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.entry-events { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.event-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--color-surface-elevated); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
|
||||
.event-item:hover { background: var(--color-status-info-bg); }
|
||||
.badge { display: inline-block; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); font-size: 0.7rem; text-transform: uppercase; }
|
||||
.badge.module { background: var(--color-surface-primary); }
|
||||
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.action { background: var(--color-surface-primary); }
|
||||
.actor { font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||
.desc { font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.no-results { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
|
||||
|
||||
.event-badges { display: flex; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
||||
.badge {
|
||||
display: inline-block; padding: 0.0625rem 0.35rem;
|
||||
border-radius: var(--radius-sm); font-size: 0.6875rem; text-transform: uppercase;
|
||||
}
|
||||
.badge--module { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
|
||||
.badge--module[data-module="policy"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge--module[data-module="authority"] { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge--module[data-module="vex"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge--action { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
|
||||
`]
|
||||
})
|
||||
export class AuditTimelineSearchComponent {
|
||||
@@ -112,6 +101,44 @@ export class AuditTimelineSearchComponent {
|
||||
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
endDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
/** Map audit timeline entries to canonical TimelineEvent[]. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
const result: TimelineEvent[] = [];
|
||||
for (const entry of this.entries()) {
|
||||
if (entry.clusterSize && entry.clusterSize > 1) {
|
||||
// Cluster: render as a single summary event
|
||||
const firstEvent = entry.events[0];
|
||||
result.push({
|
||||
id: entry.clusterId ?? entry.timestamp,
|
||||
timestamp: entry.timestamp,
|
||||
title: `${entry.clusterSize} events`,
|
||||
description: entry.events.map(e => `[${e.module}] ${e.description}`).join(' | '),
|
||||
actor: firstEvent?.actor?.name,
|
||||
eventKind: firstEvent ? mapActionToKind(firstEvent.action) : 'info',
|
||||
icon: 'summarize',
|
||||
metadata: firstEvent ? { module: firstEvent.module, action: firstEvent.action } : undefined,
|
||||
expandable: entry.events.length > 1 ? entry.events.map(e =>
|
||||
`${e.timestamp} [${e.module}/${e.action}] ${e.actor.name}: ${e.description}`
|
||||
).join('\n') : undefined,
|
||||
});
|
||||
} else {
|
||||
// Individual events
|
||||
for (const event of entry.events) {
|
||||
result.push({
|
||||
id: event.id,
|
||||
timestamp: event.timestamp ?? entry.timestamp,
|
||||
title: event.description,
|
||||
actor: event.actor?.name,
|
||||
eventKind: mapActionToKind(event.action),
|
||||
icon: 'event_note',
|
||||
metadata: { module: event.module, action: event.action },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
search(): void {
|
||||
if (!this.query.trim()) return;
|
||||
this.searching.set(true);
|
||||
@@ -124,8 +151,4 @@ export class AuditTimelineSearchComponent {
|
||||
error: () => this.searching.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(ts: string): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,85 @@
|
||||
// Sprint: SPRINT_20251229_032_FE - Platform Health Dashboard
|
||||
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
|
||||
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PlatformHealthClient } from '../../core/api/platform-health.client';
|
||||
import {
|
||||
Incident,
|
||||
IncidentSeverity,
|
||||
INCIDENT_SEVERITY_COLORS,
|
||||
} from '../../core/api/platform-health.models';
|
||||
import { healthSloPath } from '../platform/ops/operations-paths';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
function mapSeverityToKind(severity: IncidentSeverity, state: string): TimelineEventKind {
|
||||
if (state === 'resolved') return 'success';
|
||||
switch (severity) {
|
||||
case 'critical': return 'critical';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function mapSeverityToIcon(severity: IncidentSeverity, state: string): string {
|
||||
if (state === 'resolved') return 'check_circle';
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'radio_button_unchecked';
|
||||
}
|
||||
}
|
||||
|
||||
function buildExpandablePayload(incident: Incident): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (incident.rootCauseSuggestion) {
|
||||
parts.push(`Suggested Root Cause: ${incident.rootCauseSuggestion}`);
|
||||
}
|
||||
if (incident.correlatedEvents.length > 0) {
|
||||
parts.push(`Correlated Events (${incident.correlatedEvents.length}):`);
|
||||
for (const evt of incident.correlatedEvents) {
|
||||
parts.push(` ${evt.timestamp} [${evt.service}] ${evt.description}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-incident-timeline',
|
||||
imports: [CommonModule, RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, TimelineListComponent],
|
||||
template: `
|
||||
<div class="incident-timeline p-6">
|
||||
<header class="mb-6">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
|
||||
<div class="incident-timeline">
|
||||
<header class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<a [routerLink]="healthOverviewPath">Platform Health</a>
|
||||
<span>/</span>
|
||||
<span>Incidents</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Incident Timeline</h1>
|
||||
<p class="text-gray-600 mt-1">Correlated incidents with root-cause analysis</p>
|
||||
<h1>Incident Timeline</h1>
|
||||
<p class="subtitle">Correlated incidents with root-cause analysis</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="header-actions">
|
||||
<select
|
||||
[(ngModel)]="hoursBack"
|
||||
(ngModelChange)="loadIncidents()"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option [value]="6">Last 6 hours</option>
|
||||
<option [value]="24">Last 24 hours</option>
|
||||
<option [value]="72">Last 3 days</option>
|
||||
<option [value]="168">Last 7 days</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="includeResolved"
|
||||
(ngModelChange)="loadIncidents()"
|
||||
class="rounded"
|
||||
/>
|
||||
Include resolved
|
||||
</label>
|
||||
<button
|
||||
(click)="exportReport()"
|
||||
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button type="button" class="btn-secondary" (click)="exportReport()">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,154 +87,160 @@ import { healthSloPath } from '../platform/ops/operations-paths';
|
||||
</header>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<section class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Total Incidents</span>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ incidents().length }}</p>
|
||||
<section class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Total Incidents</span>
|
||||
<p class="summary-value">{{ incidents().length }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Active</span>
|
||||
<p class="text-2xl font-bold text-red-600">{{ activeCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Active</span>
|
||||
<p class="summary-value summary-value--error">{{ activeCount() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Critical</span>
|
||||
<p class="text-2xl font-bold text-red-600">{{ criticalCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Critical</span>
|
||||
<p class="summary-value summary-value--error">{{ criticalCount() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Resolved</span>
|
||||
<p class="text-2xl font-bold text-green-600">{{ resolvedCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Resolved</span>
|
||||
<p class="summary-value summary-value--success">{{ resolvedCount() }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filters -->
|
||||
<section class="bg-white rounded-lg border p-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<select
|
||||
[(ngModel)]="severityFilter"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select
|
||||
[(ngModel)]="stateFilter"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
placeholder="Search incidents..."
|
||||
class="px-3 py-2 text-sm border rounded-md flex-1"
|
||||
/>
|
||||
</div>
|
||||
<section class="filter-section">
|
||||
<select [(ngModel)]="severityFilter">
|
||||
<option value="all">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select [(ngModel)]="stateFilter">
|
||||
<option value="all">All States</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
placeholder="Search incidents..."
|
||||
class="search-input"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Timeline -->
|
||||
<section class="bg-white rounded-lg border">
|
||||
<div class="divide-y">
|
||||
@for (incident of filteredIncidents(); track incident.id) {
|
||||
<div class="p-4" [class.bg-red-50]="incident.state === 'active'">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Timeline marker -->
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="w-4 h-4 rounded-full"
|
||||
[class]="incident.state === 'active' ? 'bg-red-500' : 'bg-gray-400'"
|
||||
></span>
|
||||
<div class="w-0.5 h-full bg-gray-200 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded"
|
||||
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]"
|
||||
>
|
||||
{{ incident.severity | uppercase }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ incident.title }}</span>
|
||||
@if (incident.state === 'resolved') {
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800">
|
||||
Resolved
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mb-2">{{ incident.description }}</p>
|
||||
|
||||
<!-- Affected Services -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-500">Affected:</span>
|
||||
@for (service of incident.affectedServices; track service) {
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 rounded">{{ service }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Root Cause -->
|
||||
@if (incident.rootCauseSuggestion) {
|
||||
<div class="p-3 bg-blue-50 border border-blue-200 rounded mb-2">
|
||||
<p class="text-sm text-blue-800">
|
||||
<span class="font-medium">Suggested Root Cause:</span>
|
||||
{{ incident.rootCauseSuggestion }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Canonical Timeline -->
|
||||
<section class="timeline-section">
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
[emptyMessage]="loading() ? 'Loading incidents...' : 'No incidents found for the selected time range'"
|
||||
ariaLabel="Incident timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (getIncidentForEvent(event.id); as incident) {
|
||||
@if (incident.affectedServices.length > 0) {
|
||||
<div class="affected-services">
|
||||
<span class="affected-label">Affected:</span>
|
||||
@for (service of incident.affectedServices; track service) {
|
||||
<span class="service-chip">{{ service }}</span>
|
||||
}
|
||||
|
||||
<!-- Correlated Events -->
|
||||
@if (incident.correlatedEvents.length > 0) {
|
||||
<details class="mt-2">
|
||||
<summary class="text-sm text-blue-600 cursor-pointer hover:underline">
|
||||
View {{ incident.correlatedEvents.length }} correlated events
|
||||
</summary>
|
||||
<div class="mt-2 pl-4 border-l-2 border-gray-200 space-y-2">
|
||||
@for (event of incident.correlatedEvents; track event.timestamp) {
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-500">{{ event.timestamp | date:'shortTime' }}</span>
|
||||
<span class="text-gray-700 ml-2">{{ event.service }}:</span>
|
||||
<span class="text-gray-600">{{ event.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="mt-3 text-xs text-gray-500 flex items-center gap-4">
|
||||
<span>Started: {{ incident.startedAt | date:'medium' }}</span>
|
||||
@if (incident.resolvedAt) {
|
||||
<span>Resolved: {{ incident.resolvedAt | date:'medium' }}</span>
|
||||
}
|
||||
@if (incident.duration) {
|
||||
<span>Duration: {{ incident.duration }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
@if (loading()) {
|
||||
Loading incidents...
|
||||
} @else {
|
||||
No incidents found for the selected time range
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (incident.state === 'resolved') {
|
||||
<span class="resolved-badge">Resolved</span>
|
||||
}
|
||||
@if (incident.resolvedAt) {
|
||||
<span class="duration-info">Duration: {{ incident.duration ?? 'N/A' }}</span>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.incident-timeline {
|
||||
padding: 1.5rem;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header { display: grid; gap: 0.5rem; }
|
||||
.breadcrumb { font-size: 0.8125rem; color: var(--color-text-secondary); display: flex; gap: 0.35rem; align-items: center; }
|
||||
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
h1 { margin: 0; font-size: 1.375rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
|
||||
.subtitle { margin: 0.125rem 0 0; color: var(--color-text-secondary); font-size: 0.8125rem; }
|
||||
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.header-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.header-actions select, .btn-secondary {
|
||||
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-secondary); }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--color-text-secondary); }
|
||||
|
||||
/* Summary cards */
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
|
||||
.summary-card {
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 0.75rem 1rem;
|
||||
}
|
||||
.summary-label { font-size: 0.75rem; color: var(--color-text-secondary); }
|
||||
.summary-value { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
|
||||
.summary-value--error { color: var(--color-status-error-text); }
|
||||
.summary-value--success { color: var(--color-status-success-text); }
|
||||
|
||||
/* Filters */
|
||||
.filter-section {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 0.75rem 1rem;
|
||||
}
|
||||
.filter-section select {
|
||||
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1; padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
/* Timeline section */
|
||||
.timeline-section {
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 1rem;
|
||||
}
|
||||
|
||||
/* Domain-specific chips inside content projection */
|
||||
.affected-services { display: flex; align-items: center; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
||||
.affected-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
|
||||
.service-chip {
|
||||
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
|
||||
background: var(--color-surface-secondary); border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.resolved-badge {
|
||||
display: inline-block; margin-top: 0.25rem;
|
||||
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
|
||||
background: var(--color-status-success-bg); color: var(--color-status-success-text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.duration-info {
|
||||
display: inline-block; margin-top: 0.25rem; margin-left: 0.5rem;
|
||||
font-size: 0.6875rem; color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-row { flex-direction: column; }
|
||||
.filter-section { flex-wrap: wrap; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
@@ -222,10 +257,7 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
stateFilter = signal<'all' | 'active' | 'resolved'>('all');
|
||||
searchQuery = signal('');
|
||||
|
||||
// Expose constants
|
||||
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
|
||||
|
||||
// Computed
|
||||
// Computed counts
|
||||
activeCount = computed(() => this.incidents().filter((i) => i.state === 'active').length);
|
||||
resolvedCount = computed(() => this.incidents().filter((i) => i.state === 'resolved').length);
|
||||
criticalCount = computed(() => this.incidents().filter((i) => i.severity === 'critical').length);
|
||||
@@ -247,6 +279,26 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
|
||||
/** Map filtered incidents to canonical TimelineEvent[]. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
return this.filteredIncidents().map((incident) => ({
|
||||
id: incident.id,
|
||||
timestamp: incident.startedAt,
|
||||
title: `[${incident.severity.toUpperCase()}] ${incident.title}`,
|
||||
description: incident.description,
|
||||
actor: undefined,
|
||||
eventKind: mapSeverityToKind(incident.severity, incident.state),
|
||||
icon: mapSeverityToIcon(incident.severity, incident.state),
|
||||
metadata: incident.duration ? { duration: incident.duration } : undefined,
|
||||
expandable: buildExpandablePayload(incident),
|
||||
}));
|
||||
});
|
||||
|
||||
/** Lookup incident by event ID for content projection. */
|
||||
getIncidentForEvent(eventId: string): Incident | undefined {
|
||||
return this.filteredIncidents().find((i) => i.id === eventId);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadIncidents();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
interface ReleaseActivityProjection {
|
||||
activityId: string;
|
||||
@@ -24,10 +25,28 @@ interface PlatformListResponse<T> {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function deriveOutcomeKind(status: string): TimelineEventKind {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower.includes('published') || lower.includes('approved') || lower.includes('deployed')) return 'success';
|
||||
if (lower.includes('blocked') || lower.includes('rejected') || lower.includes('failed')) return 'error';
|
||||
if (lower.includes('pending_approval')) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function deriveOutcomeIcon(status: string): string {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower.includes('published') || lower.includes('deployed')) return 'rocket_launch';
|
||||
if (lower.includes('approved')) return 'check_circle';
|
||||
if (lower.includes('blocked') || lower.includes('rejected')) return 'block';
|
||||
if (lower.includes('failed')) return 'error';
|
||||
if (lower.includes('pending_approval')) return 'pending';
|
||||
return 'play_circle';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-releases-activity',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent],
|
||||
template: `
|
||||
<section class="activity">
|
||||
<header>
|
||||
@@ -97,49 +116,83 @@ interface PlatformListResponse<T> {
|
||||
@if (loading()) {
|
||||
<div class="banner">Loading release runs...</div>
|
||||
} @else {
|
||||
@if (viewMode() === 'correlations') {
|
||||
<div class="clusters">
|
||||
@for (cluster of correlationClusters(); track cluster.key) {
|
||||
<article>
|
||||
<h3>{{ cluster.key }}</h3>
|
||||
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
|
||||
<p>{{ cluster.environments }}</p>
|
||||
</article>
|
||||
} @empty {
|
||||
<div class="banner">No run correlations match the current filters.</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Release Version</th>
|
||||
<th>Lane</th>
|
||||
<th>Outcome</th>
|
||||
<th>Environment</th>
|
||||
<th>Needs Approval</th>
|
||||
<th>Data Integrity</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||
<td>{{ row.releaseName }}</td>
|
||||
<td>{{ deriveLane(row) }}</td>
|
||||
<td>{{ deriveOutcome(row) }}</td>
|
||||
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
|
||||
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
|
||||
<td>{{ deriveDataIntegrity(row) }}</td>
|
||||
<td>{{ formatDate(row.occurredAt) }}</td>
|
||||
</tr>
|
||||
@switch (viewMode()) {
|
||||
@case ('timeline') {
|
||||
<!-- Canonical timeline rendering -->
|
||||
<div class="timeline-container">
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
emptyMessage="No runs match the active filters."
|
||||
ariaLabel="Release activity timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata) {
|
||||
<div class="run-meta">
|
||||
@if (event.metadata['lane']) {
|
||||
<span class="run-chip">{{ event.metadata['lane'] }}</span>
|
||||
}
|
||||
@if (event.metadata['environment']) {
|
||||
<span class="run-chip">{{ event.metadata['environment'] }}</span>
|
||||
}
|
||||
@if (event.metadata['outcome']) {
|
||||
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
|
||||
}
|
||||
@if (event.evidenceLink) {
|
||||
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
}
|
||||
@case ('correlations') {
|
||||
<div class="clusters">
|
||||
@for (cluster of correlationClusters(); track cluster.key) {
|
||||
<article>
|
||||
<h3>{{ cluster.key }}</h3>
|
||||
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
|
||||
<p>{{ cluster.environments }}</p>
|
||||
</article>
|
||||
} @empty {
|
||||
<tr><td colspan="8">No runs match the active filters.</td></tr>
|
||||
<div class="banner">No run correlations match the current filters.</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Release Version</th>
|
||||
<th>Lane</th>
|
||||
<th>Outcome</th>
|
||||
<th>Environment</th>
|
||||
<th>Needs Approval</th>
|
||||
<th>Data Integrity</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||
<td>{{ row.releaseName }}</td>
|
||||
<td>{{ deriveLane(row) }}</td>
|
||||
<td>{{ deriveOutcome(row) }}</td>
|
||||
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
|
||||
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
|
||||
<td>{{ deriveDataIntegrity(row) }}</td>
|
||||
<td>{{ formatDate(row.occurredAt) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="8">No runs match the active filters.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -155,6 +208,18 @@ interface PlatformListResponse<T> {
|
||||
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
|
||||
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
|
||||
|
||||
/* Timeline container */
|
||||
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem}
|
||||
|
||||
/* Domain-specific run metadata */
|
||||
.run-meta{display:flex;gap:.25rem;margin-top:.25rem;align-items:center;flex-wrap:wrap}
|
||||
.run-chip{padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);background:var(--color-surface-secondary);color:var(--color-text-secondary)}
|
||||
.run-chip--outcome[data-outcome="success"]{background:var(--color-status-success-bg);color:var(--color-status-success-text)}
|
||||
.run-chip--outcome[data-outcome="failed"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
|
||||
.run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
|
||||
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
|
||||
.run-link:hover{text-decoration:underline}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -202,6 +267,25 @@ export class ReleasesActivityComponent {
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
return this.filteredRows().map((row) => ({
|
||||
id: row.activityId,
|
||||
timestamp: row.occurredAt,
|
||||
title: `${row.releaseName} - ${row.eventType}`,
|
||||
description: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'} · ${row.status}`,
|
||||
actor: row.actorId || undefined,
|
||||
eventKind: deriveOutcomeKind(row.status),
|
||||
icon: deriveOutcomeIcon(row.status),
|
||||
evidenceLink: `/releases/runs/${row.releaseId}/summary`,
|
||||
metadata: {
|
||||
lane: this.deriveLane(row),
|
||||
environment: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'}`,
|
||||
outcome: this.deriveOutcome(row),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
readonly correlationClusters = computed(() => {
|
||||
const map = new Map<string, { key: string; count: number; releaseSet: Set<string>; envSet: Set<string> }>();
|
||||
for (const row of this.filteredRows()) {
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Timeline List Component Tests
|
||||
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-004)
|
||||
*/
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from './timeline-list.component';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = new Date('2026-03-08T12:00:00.000Z');
|
||||
|
||||
function minutesAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 60_000).toISOString();
|
||||
}
|
||||
function hoursAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 3_600_000).toISOString();
|
||||
}
|
||||
function daysAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
const SAMPLE_EVENTS: TimelineEvent[] = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: minutesAgo(5),
|
||||
title: 'Scan completed',
|
||||
description: 'Container image scan finished',
|
||||
actor: 'scanner-worker-01',
|
||||
eventKind: 'success',
|
||||
icon: 'check_circle',
|
||||
metadata: { imageDigest: 'sha256:abc123' },
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
timestamp: hoursAgo(3),
|
||||
title: 'Policy evaluated',
|
||||
eventKind: 'info',
|
||||
icon: 'policy',
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
timestamp: daysAgo(2),
|
||||
title: 'Finding created',
|
||||
description: 'CVE-2024-12345 detected',
|
||||
eventKind: 'error',
|
||||
icon: 'error',
|
||||
evidenceLink: '/findings/CVE-2024-12345',
|
||||
expandable: '{"cve":"CVE-2024-12345","cvss":9.8}',
|
||||
},
|
||||
{
|
||||
id: 'evt-4',
|
||||
timestamp: daysAgo(2),
|
||||
title: 'Attestation created',
|
||||
eventKind: 'warning',
|
||||
icon: 'verified',
|
||||
metadata: { sigAlgo: 'ECDSA-P256' },
|
||||
},
|
||||
{
|
||||
id: 'evt-5',
|
||||
timestamp: daysAgo(5),
|
||||
title: 'Cache hit',
|
||||
eventKind: 'neutral',
|
||||
},
|
||||
];
|
||||
|
||||
// Test host component to set inputs
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TimelineListComponent],
|
||||
template: `
|
||||
<app-timeline-list
|
||||
[events]="events"
|
||||
[loading]="loading"
|
||||
[groupByDate]="groupByDate"
|
||||
[emptyMessage]="emptyMessage"
|
||||
[ariaLabel]="ariaLabel"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
events: TimelineEvent[] = [];
|
||||
loading = false;
|
||||
groupByDate = false;
|
||||
emptyMessage = 'No events to display';
|
||||
ariaLabel = 'Test timeline';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TimelineListComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Patch Date.now for deterministic relative-time output
|
||||
vi.useFakeTimers({ now: NOW });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
el = fixture.nativeElement;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering basics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render empty state when no events', () => {
|
||||
fixture.detectChanges();
|
||||
const emptyEl = el.querySelector('.timeline__empty');
|
||||
expect(emptyEl).toBeTruthy();
|
||||
expect(emptyEl!.textContent).toContain('No events to display');
|
||||
});
|
||||
|
||||
it('should render custom empty message', () => {
|
||||
host.emptyMessage = 'Nothing here.';
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__empty-text')!.textContent).toContain('Nothing here.');
|
||||
});
|
||||
|
||||
it('should render events with titles', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const titles = el.querySelectorAll('.timeline__title');
|
||||
expect(titles.length).toBe(5);
|
||||
expect(titles[0].textContent).toContain('Scan completed');
|
||||
expect(titles[2].textContent).toContain('Finding created');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Severity marker colors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should apply correct severity marker classes', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const markers = el.querySelectorAll('.timeline__marker');
|
||||
expect(markers[0].classList.contains('timeline__marker--success')).toBe(true);
|
||||
expect(markers[1].classList.contains('timeline__marker--info')).toBe(true);
|
||||
expect(markers[2].classList.contains('timeline__marker--error')).toBe(true);
|
||||
expect(markers[3].classList.contains('timeline__marker--warning')).toBe(true);
|
||||
expect(markers[4].classList.contains('timeline__marker--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('should render critical marker with distinct styling', () => {
|
||||
host.events = [{
|
||||
id: 'crit-1',
|
||||
timestamp: minutesAgo(1),
|
||||
title: 'Critical breach',
|
||||
eventKind: 'critical',
|
||||
}];
|
||||
fixture.detectChanges();
|
||||
const marker = el.querySelector('.timeline__marker');
|
||||
expect(marker!.classList.contains('timeline__marker--critical')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Timestamp formatting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should display relative time for events less than 24h old', () => {
|
||||
host.events = [
|
||||
{ id: 't-1', timestamp: minutesAgo(5), title: '5m event' },
|
||||
{ id: 't-2', timestamp: hoursAgo(3), title: '3h event' },
|
||||
];
|
||||
fixture.detectChanges();
|
||||
const times = el.querySelectorAll('.timeline__time');
|
||||
expect(times[0].textContent).toContain('5m ago');
|
||||
expect(times[1].textContent).toContain('3h ago');
|
||||
});
|
||||
|
||||
it('should display "Just now" for very recent events', () => {
|
||||
host.events = [{ id: 't-now', timestamp: NOW.toISOString(), title: 'Now event' }];
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('Just now');
|
||||
});
|
||||
|
||||
it('should display absolute UTC time for events older than 24h', () => {
|
||||
host.events = [{ id: 't-old', timestamp: daysAgo(2), title: 'Old event' }];
|
||||
fixture.detectChanges();
|
||||
const timeText = el.querySelector('.timeline__time')!.textContent!.trim();
|
||||
expect(timeText).toContain('UTC');
|
||||
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should show full ISO timestamp in title attribute (tooltip)', () => {
|
||||
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
|
||||
fixture.detectChanges();
|
||||
const timeEl = el.querySelector('.timeline__time') as HTMLElement;
|
||||
expect(timeEl.getAttribute('title')).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Expandable detail sections
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should show expand button for events with expandable content', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // evt-3 has expandable
|
||||
fixture.detectChanges();
|
||||
const btn = el.querySelector('.timeline__expand-btn');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn!.textContent).toContain('Show details');
|
||||
});
|
||||
|
||||
it('should toggle expandable section on click', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]];
|
||||
fixture.detectChanges();
|
||||
|
||||
const btn = el.querySelector('.timeline__expand-btn') as HTMLButtonElement;
|
||||
expect(el.querySelector('.timeline__expandable')).toBeNull();
|
||||
|
||||
btn.click();
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expandable')).toBeTruthy();
|
||||
expect(el.querySelector('.timeline__expandable-content')!.textContent).toContain('CVE-2024-12345');
|
||||
expect(btn.textContent).toContain('Hide details');
|
||||
|
||||
btn.click();
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expandable')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not show expand button for events without expandable content', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // evt-1 has no expandable
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expand-btn')).toBeNull();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Optional fields
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should render actor when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has actor
|
||||
fixture.detectChanges();
|
||||
const actor = el.querySelector('.timeline__actor');
|
||||
expect(actor).toBeTruthy();
|
||||
expect(actor!.textContent).toContain('scanner-worker-01');
|
||||
});
|
||||
|
||||
it('should not render actor when not provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[1]]; // no actor
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__actor')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render description when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const desc = el.querySelector('.timeline__description');
|
||||
expect(desc).toBeTruthy();
|
||||
expect(desc!.textContent).toContain('Container image scan finished');
|
||||
});
|
||||
|
||||
it('should render evidence link when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // has evidenceLink
|
||||
fixture.detectChanges();
|
||||
const link = el.querySelector('.timeline__evidence-link') as HTMLAnchorElement;
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.getAttribute('href')).toBe('/findings/CVE-2024-12345');
|
||||
});
|
||||
|
||||
it('should render metadata chips when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has metadata
|
||||
fixture.detectChanges();
|
||||
const chips = el.querySelectorAll('.timeline__meta-chip');
|
||||
expect(chips.length).toBe(1);
|
||||
expect(chips[0].querySelector('.timeline__meta-key')!.textContent).toContain('imageDigest');
|
||||
expect(chips[0].querySelector('.timeline__meta-value')!.textContent).toContain('sha256:abc123');
|
||||
});
|
||||
|
||||
it('should render material icon when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has icon
|
||||
fixture.detectChanges();
|
||||
const icon = el.querySelector('.timeline__icon');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon!.textContent).toContain('check_circle');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Date grouping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should group events by date when groupByDate is true', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
host.groupByDate = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const dateHeaders = el.querySelectorAll('.timeline__date-label');
|
||||
// Events span 3 different days: today (~5m ago, ~3h ago), 2 days ago (2 events), 5 days ago
|
||||
expect(dateHeaders.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should not group events when groupByDate is false', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
host.groupByDate = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.timeline__date-group')).toBeNull();
|
||||
const items = el.querySelectorAll('.timeline__item');
|
||||
expect(items.length).toBe(5);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Loading state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should show loading skeleton when loading is true', () => {
|
||||
host.loading = true;
|
||||
fixture.detectChanges();
|
||||
const skeleton = el.querySelector('.timeline--loading');
|
||||
expect(skeleton).toBeTruthy();
|
||||
expect(el.querySelectorAll('.timeline__skeleton-item').length).toBe(5);
|
||||
});
|
||||
|
||||
it('should show sr-only loading text for screen readers', () => {
|
||||
host.loading = true;
|
||||
fixture.detectChanges();
|
||||
const srOnly = el.querySelector('.sr-only');
|
||||
expect(srOnly).toBeTruthy();
|
||||
expect(srOnly!.textContent).toContain('Loading timeline');
|
||||
});
|
||||
|
||||
it('should not show loading skeleton when loading is false', () => {
|
||||
host.loading = false;
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline--loading')).toBeNull();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Accessibility
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should have role="feed" on the timeline container', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const timeline = el.querySelector('[role="feed"]');
|
||||
expect(timeline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom ariaLabel', () => {
|
||||
host.ariaLabel = 'Incident feed';
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const timeline = el.querySelector('[role="feed"]');
|
||||
expect(timeline!.getAttribute('aria-label')).toBe('Incident feed');
|
||||
});
|
||||
|
||||
it('should have role="article" on each event item', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const articles = el.querySelectorAll('[role="article"]');
|
||||
expect(articles.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should have aria-label on each event item matching title', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const article = el.querySelector('[role="article"]');
|
||||
expect(article!.getAttribute('aria-label')).toBe('Scan completed');
|
||||
});
|
||||
|
||||
it('should have aria-expanded on expand button', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // has expandable
|
||||
fixture.detectChanges();
|
||||
const btn = el.querySelector('.timeline__expand-btn');
|
||||
expect(btn!.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
(btn as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(btn!.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have datetime attribute on time elements', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const timeEl = el.querySelector('.timeline__time');
|
||||
expect(timeEl!.getAttribute('datetime')).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Vertical connector line
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should not render connector line on the last item', () => {
|
||||
host.events = SAMPLE_EVENTS.slice(0, 2);
|
||||
fixture.detectChanges();
|
||||
const items = el.querySelectorAll('.timeline__item');
|
||||
expect(items[1].classList.contains('timeline__item--last')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Default eventKind fallback
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should default to neutral marker when eventKind is not provided', () => {
|
||||
host.events = [{ id: 'no-kind', timestamp: minutesAgo(1), title: 'No kind' }];
|
||||
fixture.detectChanges();
|
||||
const marker = el.querySelector('.timeline__marker');
|
||||
expect(marker!.classList.contains('timeline__marker--neutral')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,349 @@
|
||||
/**
|
||||
* Timeline List Component
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-001..002)
|
||||
*
|
||||
* Chronological event timeline display.
|
||||
* Canonical audit-grade event-stream timeline.
|
||||
*
|
||||
* Features:
|
||||
* - Vertical timeline with colored severity markers
|
||||
* - Deterministic UTC ISO-8601 timestamp formatting
|
||||
* - Relative time for <24h, absolute for older, full ISO on hover
|
||||
* - Expandable detail sections for event payloads
|
||||
* - Optional actor/source metadata
|
||||
* - Date grouping when events span multiple days
|
||||
* - Loading skeleton and empty states
|
||||
* - Accessibility (role="feed", aria-labels)
|
||||
* - Content projection via ng-template for domain-specific rendering
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ContentChild,
|
||||
TemplateRef,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical Event Model (FE-TLD-001)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Severity / event kind for timeline events.
|
||||
* Determines marker color and visual weight.
|
||||
*/
|
||||
export type TimelineEventKind = 'info' | 'success' | 'warning' | 'error' | 'critical' | 'neutral';
|
||||
|
||||
/**
|
||||
* Canonical timeline event model for all audit/evidence/release chronology surfaces.
|
||||
*
|
||||
* Time display rules:
|
||||
* - Relative time for events < 24h old (e.g. "5m ago", "3h ago")
|
||||
* - Absolute UTC ISO-8601 for events >= 24h old (e.g. "2026-03-08 14:23 UTC")
|
||||
* - Full ISO-8601 timestamp always available via tooltip on hover
|
||||
* - Events are grouped by date when spanning multiple days
|
||||
*/
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
timestamp: Date | string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
type?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||
/** Unique event identifier. */
|
||||
readonly id: string;
|
||||
/** ISO-8601 UTC timestamp. */
|
||||
readonly timestamp: string;
|
||||
/** Event summary (single line). */
|
||||
readonly title: string;
|
||||
/** Optional detail text. */
|
||||
readonly description?: string;
|
||||
/** Who or what caused this event (user, service, system). */
|
||||
readonly actor?: string;
|
||||
/** Event severity / kind. Defaults to 'neutral'. */
|
||||
readonly eventKind?: TimelineEventKind;
|
||||
/** Material icon name (optional). */
|
||||
readonly icon?: string;
|
||||
/** Link to related evidence (optional URL). */
|
||||
readonly evidenceLink?: string;
|
||||
/** Arbitrary key-value metadata. */
|
||||
readonly metadata?: Record<string, string>;
|
||||
/** Expandable detail payload (rendered in collapsible section). */
|
||||
readonly expandable?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DateGroup {
|
||||
dateLabel: string;
|
||||
events: TimelineEvent[];
|
||||
}
|
||||
|
||||
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Format a timestamp for display.
|
||||
* - < 1 minute: "Just now"
|
||||
* - < 60 minutes: "Xm ago"
|
||||
* - < 24 hours: "Xh ago"
|
||||
* - >= 24 hours: "YYYY-MM-DD HH:mm UTC"
|
||||
*/
|
||||
function formatDisplayTime(iso: string, now: Date): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
if (diffMs < 0) {
|
||||
// Future event: show absolute
|
||||
return formatAbsoluteUtc(date);
|
||||
}
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
return formatAbsoluteUtc(date);
|
||||
}
|
||||
|
||||
function formatAbsoluteUtc(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hh = String(date.getUTCHours()).padStart(2, '0');
|
||||
const mm = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm} UTC`;
|
||||
}
|
||||
|
||||
function toIsoFull(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function toDateKey(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Component({
|
||||
selector: 'app-timeline-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [NgTemplateOutlet],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="timeline">
|
||||
@for (event of events; track event.id; let last = $last) {
|
||||
<div class="timeline__item" [class.timeline__item--last]="last">
|
||||
<div class="timeline__marker" [class]="'timeline__marker--' + (event.type || 'neutral')">
|
||||
@if (event.icon) {
|
||||
<span>{{ event.icon }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="timeline__content">
|
||||
<div class="timeline__header">
|
||||
<span class="timeline__title">{{ event.title }}</span>
|
||||
<time class="timeline__time">{{ formatTime(event.timestamp) }}</time>
|
||||
<!-- Loading skeleton -->
|
||||
@if (loading()) {
|
||||
<div class="timeline timeline--loading" role="status" aria-label="Loading timeline events">
|
||||
@for (i of skeletonRows; track i) {
|
||||
<div class="timeline__skeleton-item">
|
||||
<div class="timeline__skeleton-marker"></div>
|
||||
<div class="timeline__skeleton-content">
|
||||
<div class="timeline__skeleton-title"></div>
|
||||
<div class="timeline__skeleton-desc"></div>
|
||||
</div>
|
||||
@if (event.description) {
|
||||
<p class="timeline__description">{{ event.description }}</p>
|
||||
}
|
||||
@if (eventTemplate) {
|
||||
<ng-container *ngTemplateOutlet="eventTemplate; context: { $implicit: event }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="sr-only">Loading timeline...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="timeline"
|
||||
role="feed"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.aria-busy]="false"
|
||||
>
|
||||
@if (groupByDate()) {
|
||||
<!-- Grouped by date -->
|
||||
@for (group of dateGroups(); track group.dateLabel) {
|
||||
<div class="timeline__date-group">
|
||||
<div class="timeline__date-header" role="separator">
|
||||
<span class="timeline__date-label">{{ group.dateLabel }}</span>
|
||||
</div>
|
||||
@for (event of group.events; track event.id; let last = $last; let idx = $index) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last && $last }"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
|
||||
}
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
@for (event of events(); track event.id; let last = $last) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last }"
|
||||
></ng-container>
|
||||
} @empty {
|
||||
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Event row template -->
|
||||
<ng-template #eventRow let-event let-last="last">
|
||||
<article
|
||||
class="timeline__item"
|
||||
[class.timeline__item--last]="last"
|
||||
role="article"
|
||||
[attr.aria-label]="event.title"
|
||||
>
|
||||
<div
|
||||
class="timeline__marker"
|
||||
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (event.icon) {
|
||||
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="timeline__empty">No events to display</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="timeline__content">
|
||||
<div class="timeline__header">
|
||||
<span class="timeline__title">{{ event.title }}</span>
|
||||
<time
|
||||
class="timeline__time"
|
||||
[attr.datetime]="toIsoFull(event.timestamp)"
|
||||
[title]="toIsoFull(event.timestamp)"
|
||||
>{{ formatTime(event.timestamp) }}</time>
|
||||
</div>
|
||||
|
||||
@if (event.actor) {
|
||||
<span class="timeline__actor">{{ event.actor }}</span>
|
||||
}
|
||||
|
||||
@if (event.description) {
|
||||
<p class="timeline__description">{{ event.description }}</p>
|
||||
}
|
||||
|
||||
@if (event.evidenceLink) {
|
||||
<a
|
||||
class="timeline__evidence-link"
|
||||
[href]="event.evidenceLink"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View evidence</a>
|
||||
}
|
||||
|
||||
@if (event.metadata && hasKeys(event.metadata)) {
|
||||
<div class="timeline__metadata">
|
||||
@for (entry of objectEntries(event.metadata); track entry[0]) {
|
||||
<span class="timeline__meta-chip">
|
||||
<span class="timeline__meta-key">{{ entry[0] }}</span>
|
||||
<span class="timeline__meta-value">{{ entry[1] }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (event.expandable) {
|
||||
<button
|
||||
type="button"
|
||||
class="timeline__expand-btn"
|
||||
[attr.aria-expanded]="isExpanded(event.id)"
|
||||
(click)="toggleExpand(event.id)"
|
||||
>
|
||||
{{ isExpanded(event.id) ? 'Hide details' : 'Show details' }}
|
||||
</button>
|
||||
@if (isExpanded(event.id)) {
|
||||
<div class="timeline__expandable" role="region" aria-label="Event details">
|
||||
<pre class="timeline__expandable-content">{{ event.expandable }}</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (eventTemplate) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventTemplate; context: { $implicit: event }"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
</ng-template>
|
||||
|
||||
<!-- Empty state template -->
|
||||
<ng-template #emptyState>
|
||||
<div class="timeline__empty" role="status">
|
||||
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
|
||||
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [`
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared timeline primitive */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---- Date group header ---- */
|
||||
.timeline__date-group {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline__date-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.timeline__date-header::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.timeline__date-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Event item ---- */
|
||||
.timeline__item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
padding-bottom: 1.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.timeline__item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 1.25rem;
|
||||
left: 0.5625rem;
|
||||
top: 1.375rem;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--color-border-primary);
|
||||
@@ -75,6 +353,7 @@ export interface TimelineEvent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Marker ---- */
|
||||
.timeline__marker {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
@@ -87,6 +366,11 @@ export interface TimelineEvent {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline__icon {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timeline__marker--success {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
@@ -102,6 +386,12 @@ export interface TimelineEvent {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.timeline__marker--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
box-shadow: 0 0 0 2px var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.timeline__marker--info {
|
||||
background: var(--color-severity-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
@@ -112,6 +402,7 @@ export interface TimelineEvent {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Content ---- */
|
||||
.timeline__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -131,40 +422,261 @@ export interface TimelineEvent {
|
||||
}
|
||||
|
||||
.timeline__time {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline__actor {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.timeline__actor::before {
|
||||
content: 'by ';
|
||||
}
|
||||
|
||||
.timeline__description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.timeline__evidence-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline__evidence-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Metadata chips ---- */
|
||||
.timeline__metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.timeline__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.timeline__meta-key {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.timeline__meta-value {
|
||||
color: var(--color-text-primary);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ---- Expandable ---- */
|
||||
.timeline__expand-btn {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__expand-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeline__expand-btn:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.timeline__expandable {
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.timeline__expandable-content {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---- Empty ---- */
|
||||
.timeline__empty {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline__empty-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timeline__empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Loading skeleton ---- */
|
||||
.timeline--loading .timeline__skeleton-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.timeline__skeleton-marker {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timeline__skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.timeline__skeleton-title {
|
||||
width: 60%;
|
||||
height: 0.875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timeline__skeleton-desc {
|
||||
width: 40%;
|
||||
height: 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TimelineListComponent {
|
||||
@Input() events: TimelineEvent[] = [];
|
||||
/** Events to display. */
|
||||
readonly events = input<TimelineEvent[]>([]);
|
||||
|
||||
/** Whether to show loading skeleton. */
|
||||
readonly loading = input<boolean>(false);
|
||||
|
||||
/** Whether to group events by date (when events span multiple days). */
|
||||
readonly groupByDate = input<boolean>(false);
|
||||
|
||||
/** Empty state message. */
|
||||
readonly emptyMessage = input<string>('No events to display');
|
||||
|
||||
/** Accessible label for the feed container. */
|
||||
readonly ariaLabel = input<string>('Event timeline');
|
||||
|
||||
/** Optional content projection template for domain-specific rendering. */
|
||||
@ContentChild('eventContent') eventTemplate?: TemplateRef<unknown>;
|
||||
|
||||
formatTime(timestamp: Date | string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
/** Expanded event IDs. */
|
||||
private readonly expandedIds = signal<Set<string>>(new Set());
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
/** Skeleton row count for loading state. */
|
||||
readonly skeletonRows = [0, 1, 2, 3, 4];
|
||||
|
||||
/** Cached "now" for consistent relative time within a render cycle. */
|
||||
private renderNow = new Date();
|
||||
|
||||
/** Computed date groups for grouped display. */
|
||||
readonly dateGroups = computed<DateGroup[]>(() => {
|
||||
const evts = this.events();
|
||||
if (!evts.length) return [];
|
||||
|
||||
// Refresh "now" each time events change
|
||||
this.renderNow = new Date();
|
||||
|
||||
const groups = new Map<string, TimelineEvent[]>();
|
||||
for (const evt of evts) {
|
||||
const key = toDateKey(evt.timestamp);
|
||||
const arr = groups.get(key) ?? [];
|
||||
arr.push(evt);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([dateLabel, events]) => ({
|
||||
dateLabel,
|
||||
events,
|
||||
}));
|
||||
});
|
||||
|
||||
// Template helper: format display time
|
||||
formatTime(iso: string): string {
|
||||
return formatDisplayTime(iso, this.renderNow);
|
||||
}
|
||||
|
||||
// Template helper: full ISO timestamp for tooltip
|
||||
toIsoFull(iso: string): string {
|
||||
return toIsoFull(iso);
|
||||
}
|
||||
|
||||
// Template helper: check if metadata has keys
|
||||
hasKeys(obj: Record<string, string>): boolean {
|
||||
return Object.keys(obj).length > 0;
|
||||
}
|
||||
|
||||
// Template helper: Object.entries for template iteration
|
||||
objectEntries(obj: Record<string, string>): [string, string][] {
|
||||
return Object.entries(obj);
|
||||
}
|
||||
|
||||
// Expand/collapse
|
||||
isExpanded(id: string): boolean {
|
||||
return this.expandedIds().has(id);
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedIds.update(ids => {
|
||||
const next = new Set(ids);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user