diff --git a/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md b/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md index 4ebf868c3..7cbdee00a 100644 --- a/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md +++ b/docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md @@ -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 diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index d8794cd92..903109b0f 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -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` diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 25b38f84d..e609f175a 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.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`. diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts index ac431dc6a..e877b3db8 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts @@ -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: `
@@ -32,36 +41,25 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
- @if (entries().length > 0) { -
- @for (entry of entries(); track entry.timestamp) { -
-
-
-
-
-
-
{{ formatTime(entry.timestamp) }}
- @if (entry.clusterSize && entry.clusterSize > 1) { -
{{ entry.clusterSize }} events
- } -
- @for (event of entry.events; track event.id) { -
- {{ event.module }} - {{ event.action }} - {{ event.actor.name }} - {{ event.description }} -
- } -
-
+ + + + @if (event.metadata && event.metadata['module']) { +
+ {{ event.metadata['module'] }} + @if (event.metadata['action']) { + {{ event.metadata['action'] }} + }
} -
- } @else if (searched() && !searching()) { -
No events found matching your search.
- } + +
`, 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(() => { + 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(); - } } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts index cdb2b56b1..940aa6c77 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts @@ -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: ` -
-
-
- Platform Health +
+ -
-
- Total Incidents -

{{ incidents().length }}

+
+
+ Total Incidents +

{{ incidents().length }}

-
- Active -

{{ activeCount() }}

+
+ Active +

{{ activeCount() }}

-
- Critical -

{{ criticalCount() }}

+
+ Critical +

{{ criticalCount() }}

-
- Resolved -

{{ resolvedCount() }}

+
+ Resolved +

{{ resolvedCount() }}

-
-
- - - -
+
+ + +
- -
-
- @for (incident of filteredIncidents(); track incident.id) { -
-
- -
- -
-
- - -
-
- - {{ incident.severity | uppercase }} - - {{ incident.title }} - @if (incident.state === 'resolved') { - - Resolved - - } -
- -

{{ incident.description }}

- - -
- Affected: - @for (service of incident.affectedServices; track service) { - {{ service }} - } -
- - - @if (incident.rootCauseSuggestion) { -
-

- Suggested Root Cause: - {{ incident.rootCauseSuggestion }} -

-
+ +
+ + + @if (getIncidentForEvent(event.id); as incident) { + @if (incident.affectedServices.length > 0) { +
+ Affected: + @for (service of incident.affectedServices; track service) { + {{ service }} } - - - @if (incident.correlatedEvents.length > 0) { -
- - View {{ incident.correlatedEvents.length }} correlated events - -
- @for (event of incident.correlatedEvents; track event.timestamp) { -
- {{ event.timestamp | date:'shortTime' }} - {{ event.service }}: - {{ event.description }} -
- } -
-
- } - - -
- Started: {{ incident.startedAt | date:'medium' }} - @if (incident.resolvedAt) { - Resolved: {{ incident.resolvedAt | date:'medium' }} - } - @if (incident.duration) { - Duration: {{ incident.duration }} - } -
-
-
- } @empty { -
- @if (loading()) { - Loading incidents... - } @else { - No incidents found for the selected time range } -
- } -
+ @if (incident.state === 'resolved') { + Resolved + } + @if (incident.resolvedAt) { + Duration: {{ incident.duration ?? 'N/A' }} + } + } + +
`, 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(() => { + 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(); } diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index f0265a6a3..89419e848 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -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 { 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: `
@@ -97,49 +116,83 @@ interface PlatformListResponse { @if (loading()) { } @else { - @if (viewMode() === 'correlations') { -
- @for (cluster of correlationClusters(); track cluster.key) { -
-

{{ cluster.key }}

-

{{ cluster.count }} events · {{ cluster.releases }} release version(s)

-

{{ cluster.environments }}

-
- } @empty { - - } -
- } @else { - - - - - - - - - - - - - - - @for (row of filteredRows(); track row.activityId) { - - - - - - - - - - + @switch (viewMode()) { + @case ('timeline') { + +
+ + + @if (event.metadata) { +
+ @if (event.metadata['lane']) { + {{ event.metadata['lane'] }} + } + @if (event.metadata['environment']) { + {{ event.metadata['environment'] }} + } + @if (event.metadata['outcome']) { + {{ event.metadata['outcome'] }} + } + @if (event.evidenceLink) { + View run + } +
+ } +
+
+
+ } + @case ('correlations') { +
+ @for (cluster of correlationClusters(); track cluster.key) { +
+

{{ cluster.key }}

+

{{ cluster.count }} events · {{ cluster.releases }} release version(s)

+

{{ cluster.environments }}

+
} @empty { -
+ } - -
RunRelease VersionLaneOutcomeEnvironmentNeeds ApprovalData IntegrityWhen
{{ row.activityId }}{{ row.releaseName }}{{ deriveLane(row) }}{{ deriveOutcome(row) }}{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}{{ deriveDataIntegrity(row) }}{{ formatDate(row.occurredAt) }}
No runs match the active filters.
+
+ } + @default { + + + + + + + + + + + + + + + @for (row of filteredRows(); track row.activityId) { + + + + + + + + + + + } @empty { + + } + +
RunRelease VersionLaneOutcomeEnvironmentNeeds ApprovalData IntegrityWhen
{{ row.activityId }}{{ row.releaseName }}{{ deriveLane(row) }}{{ deriveOutcome(row) }}{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}{{ deriveDataIntegrity(row) }}{{ formatDate(row.occurredAt) }}
No runs match the active filters.
+ } } } @@ -155,6 +208,18 @@ interface PlatformListResponse { .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(() => { + 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; envSet: Set }>(); for (const row of this.filteredRows()) { diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts new file mode 100644 index 000000000..cd0eb5825 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.spec.ts @@ -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: ` + + `, +}) +class TestHostComponent { + events: TimelineEvent[] = []; + loading = false; + groupByDate = false; + emptyMessage = 'No events to display'; + ariaLabel = 'Test timeline'; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('TimelineListComponent', () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts index cca20520e..69f00e0dc 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts @@ -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; + /** 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: ` -
- @for (event of events; track event.id; let last = $last) { -
-
- @if (event.icon) { - {{ event.icon }} - } -
-
-
- {{ event.title }} - + + @if (loading()) { +
+ @for (i of skeletonRows; track i) { +
+
+
+
+
- @if (event.description) { -

{{ event.description }}

- } - @if (eventTemplate) { - - }
+ } + Loading timeline... +
+ } @else { +
+ @if (groupByDate()) { + + @for (group of dateGroups(); track group.dateLabel) { +
+ + @for (event of group.events; track event.id; let last = $last; let idx = $index) { + + } +
+ } @empty { + + } + } @else { + + @for (event of events(); track event.id; let last = $last) { + + } @empty { + + } + } +
+ } + + + +
+ - } @empty { -
No events to display
- } -
+ +
+
+ {{ event.title }} + +
+ + @if (event.actor) { + {{ event.actor }} + } + + @if (event.description) { +

{{ event.description }}

+ } + + @if (event.evidenceLink) { + View evidence + } + + @if (event.metadata && hasKeys(event.metadata)) { + + } + + @if (event.expandable) { + + @if (isExpanded(event.id)) { +
+
{{ event.expandable }}
+
+ } + } + + @if (eventTemplate) { + + } +
+ + + + + +
+ +

{{ emptyMessage() }}

+
+
`, 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([]); + + /** Whether to show loading skeleton. */ + readonly loading = input(false); + + /** Whether to group events by date (when events span multiple days). */ + readonly groupByDate = input(false); + + /** Empty state message. */ + readonly emptyMessage = input('No events to display'); + + /** Accessible label for the feed container. */ + readonly ariaLabel = input('Event timeline'); + + /** Optional content projection template for domain-specific rendering. */ @ContentChild('eventContent') eventTemplate?: TemplateRef; - 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>(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(() => { + const evts = this.events(); + if (!evts.length) return []; + + // Refresh "now" each time events change + this.renderNow = new Date(); + + const groups = new Map(); + 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): boolean { + return Object.keys(obj).length > 0; + } + + // Template helper: Object.entries for template iteration + objectEntries(obj: Record): [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; + }); } }