-
-
-
-
- {{ group.title }}
-{{ group.description }}
-
- @for (card of group.cards; track card.id) {
-
-
-
-
- {{ card.owner }}
-
- {{ card.metric }}
-
-
- {{ card.title }}
-{{ card.detail }}
- - } -
-
-
- Security / Reachability
-Reachability
-- Coverage, witnesses, proof-of-exposure artifacts, and sensor gaps stay in one investigation shell. -
-
- @if (returnTo()) {
-
- }
-
-
-
@@ -60,40 +47,11 @@
-
+
@if (activeTab() === 'coverage') {
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
index d0c96e7c6..15bbe1528 100644
--- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
@@ -17,6 +17,16 @@ import type {
ConfidenceTier,
ReachabilityWitness,
} from '../../core/api/witness.models';
+import {
+ ContextHeaderComponent,
+ type TabItem,
+ TabbedNavComponent,
+} from '../../shared/ui';
+import {
+ buildContextRouteParams,
+ readContextRouteParam,
+ readContextRouteState,
+} from '../../shared/ui/context-route-state/context-route-state';
import { PoEDrawerComponent } from './poe-drawer.component';
import {
type CoverageStatus,
@@ -55,7 +65,13 @@ const TIER_FILTERS: readonly TierFilter[] = [
@Component({
selector: 'app-reachability-center',
standalone: true,
- imports: [CommonModule, RouterLink, PoEDrawerComponent],
+ imports: [
+ CommonModule,
+ RouterLink,
+ PoEDrawerComponent,
+ ContextHeaderComponent,
+ TabbedNavComponent,
+ ],
templateUrl: './reachability-center.component.html',
styleUrls: ['./reachability-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -81,6 +97,12 @@ export class ReachabilityCenterComponent implements OnInit {
readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]);
readonly gapRows = signal([...REACHABILITY_GAP_ROWS]);
readonly witnesses = signal([]);
+ readonly tabItems: readonly TabItem[] = [
+ { id: 'coverage', label: 'Coverage', testId: 'reachability-tab-coverage' },
+ { id: 'witnesses', label: 'Witnesses', testId: 'reachability-tab-witnesses' },
+ { id: 'poe', label: 'PoE / Exposure', testId: 'reachability-tab-poe' },
+ { id: 'gaps', label: 'Sensor Gaps', testId: 'reachability-tab-gaps' },
+ ];
readonly filteredCoverageRows = computed(() => {
const status = this.coverageStatusFilter();
@@ -243,6 +265,24 @@ export class ReachabilityCenterComponent implements OnInit {
void this.navigateToTab('gaps');
}
+ onTabSelected(tabId: string): void {
+ switch (tabId as ReachabilityTab) {
+ case 'witnesses':
+ this.showWitnesses();
+ break;
+ case 'poe':
+ this.showPoE();
+ break;
+ case 'gaps':
+ this.showGaps();
+ break;
+ case 'coverage':
+ default:
+ this.showCoverage();
+ break;
+ }
+ }
+
openPoeArtifact(artifactId: string): void {
this.selectedPoeArtifactId.set(artifactId);
void this.navigateToTab('poe', artifactId);
@@ -357,29 +397,28 @@ export class ReachabilityCenterComponent implements OnInit {
params: ParamMap,
queryParams: ParamMap
): void {
- const tab = this.parseTab(segments, queryParams.get('tab'));
+ const tab = this.parseTab(segments, queryParams);
this.activeTab.set(tab);
- this.returnTo.set(queryParams.get('returnTo'));
- this.witnessSearch.set(queryParams.get('search') ?? '');
- this.tierFilter.set(this.parseTier(queryParams.get('tier')));
+ this.returnTo.set(readContextRouteParam(queryParams, 'returnTo'));
+ this.witnessSearch.set(readContextRouteParam(queryParams, 'search') ?? '');
+ this.tierFilter.set(this.parseTier(readContextRouteParam(queryParams, 'tier')));
this.selectedPoeArtifactId.set(
- tab === 'poe' ? params.get('artifactId') : null
+ tab === 'poe' ? readContextRouteParam(params, 'artifactId') : null
);
}
private parseTab(
segments: readonly string[],
- queryValue: string | null
+ queryParams: ParamMap
): ReachabilityTab {
- if (queryValue && REACHABILITY_TABS.includes(queryValue as ReachabilityTab)) {
- return queryValue as ReachabilityTab;
- }
-
const firstRecognized = segments.find((segment) =>
REACHABILITY_TABS.includes(segment as ReachabilityTab)
);
- return (firstRecognized as ReachabilityTab | undefined) ?? 'coverage';
+ return (
+ (firstRecognized as ReachabilityTab | undefined) ??
+ readContextRouteState(queryParams, 'tab', REACHABILITY_TABS, 'coverage')
+ );
}
private parseTier(value: string | null): TierFilter {
@@ -406,21 +445,12 @@ export class ReachabilityCenterComponent implements OnInit {
}
private buildQueryParams(tab: ReachabilityTab): Record {
- const params: Record = {
+ return buildContextRouteParams({
tab,
- };
-
- if (this.returnTo()) {
- params['returnTo'] = this.returnTo()!;
- }
- if (this.witnessSearch().trim()) {
- params['search'] = this.witnessSearch().trim();
- }
- if (this.tierFilter()) {
- params['tier'] = this.tierFilter();
- }
-
- return params;
+ returnTo: this.returnTo(),
+ search: this.witnessSearch().trim() || null,
+ tier: this.tierFilter() || null,
+ }) as Record;
}
private async verifyWitnessFallbackAware(
diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html
index 9ad3953fe..30bcc6e35 100644
--- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html
@@ -1,25 +1,22 @@
-
-
+
+
+
@if (message()) {
-
-
- Trust & Signing
-Identity Watchlist
-- Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell. -
-{{ currentModeLabel() }}
-
- @if (returnTo()) {
-
- }
-
-
-
-
+
@if (activeTab() === 'entries') {
@@ -132,8 +108,8 @@
-
- (null);
- readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [
- { id: 'summary', label: 'Summary' },
- { id: 'graph', label: 'Graph' },
- { id: 'timeline', label: 'Timeline' },
- { id: 'critical-path', label: 'Critical Path' },
- { id: 'replay', label: 'Replay' },
- { id: 'evidence', label: 'Evidence' },
+ readonly tabs: readonly TabItem[] = [
+ { id: 'summary', label: 'Summary', testId: 'run-workspace-tab-summary' },
+ { id: 'graph', label: 'Graph', testId: 'run-workspace-tab-graph' },
+ { id: 'timeline', label: 'Timeline', testId: 'run-workspace-tab-timeline' },
+ { id: 'critical-path', label: 'Critical Path', testId: 'run-workspace-tab-critical-path' },
+ { id: 'replay', label: 'Replay', testId: 'run-workspace-tab-replay' },
+ { id: 'evidence', label: 'Evidence', testId: 'run-workspace-tab-evidence' },
];
+ readonly headerChips = computed(() => {
+ const detail = this.context()?.detail;
+ if (!detail) {
+ return [];
+ }
+
+ return [
+ detail.releaseType,
+ detail.status,
+ detail.outcome,
+ `${detail.targetRegion || 'global'}/${detail.targetEnvironment || 'all'}`,
+ ];
+ });
readonly filteredGraph = computed(() => {
const context = this.context();
@@ -133,27 +160,32 @@ export class RunGraphReplayPageComponent {
});
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
- this.returnTo.set(params.get('returnTo'));
- this.selectedStepId.set(params.get('step'));
+ this.returnTo.set(readContextRouteParam(params, 'returnTo'));
+ this.selectedStepId.set(readContextRouteParam(params, 'step'));
});
}
setTab(tab: RunWorkspaceTab): void {
void this.router.navigate(['/releases/runs', this.runId(), tab], {
- queryParams: {
+ queryParams: buildContextRouteParams({
+ drawer: this.selectedStepId() ? 'step' : null,
step: this.selectedStepId(),
returnTo: this.returnTo(),
- },
+ }),
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
+ onTabSelected(tabId: string): void {
+ this.setTab(this.normalizeTab(tabId));
+ }
+
openStep(stepId: string): void {
this.selectedStepId.set(stepId);
void this.router.navigate([], {
relativeTo: this.route,
- queryParams: { step: stepId },
+ queryParams: buildContextRouteParams({ drawer: 'step', step: stepId }),
queryParamsHandling: 'merge',
replaceUrl: true,
});
@@ -163,7 +195,7 @@ export class RunGraphReplayPageComponent {
this.selectedStepId.set(null);
void this.router.navigate([], {
relativeTo: this.route,
- queryParams: { step: null },
+ queryParams: buildContextRouteParams({ drawer: null, step: null }),
queryParamsHandling: 'merge',
replaceUrl: true,
});
@@ -183,6 +215,14 @@ export class RunGraphReplayPageComponent {
queryParams: {
releaseId: context?.detail.releaseId,
runId: this.runId(),
+ returnTo: buildContextReturnTo(
+ this.router,
+ ['/releases/runs', this.runId(), this.activeTab()],
+ {
+ drawer: this.selectedStepId() ? 'step' : null,
+ step: this.selectedStepId(),
+ },
+ ),
},
});
}
@@ -201,6 +241,15 @@ export class RunGraphReplayPageComponent {
}
}
+ returnToSource(): void {
+ const returnTo = this.returnTo();
+ if (!returnTo) {
+ return;
+ }
+
+ void this.router.navigateByUrl(returnTo).catch(() => undefined);
+ }
+
formatWhen(value: string | null | undefined): string {
if (!value) {
return 'n/a';
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts
new file mode 100644
index 000000000..87d91115e
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts
@@ -0,0 +1,243 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ EventEmitter,
+ HostListener,
+ Input,
+ Output,
+} from '@angular/core';
+
+let nextContextDrawerId = 0;
+
+@Component({
+ selector: 'app-context-drawer-host',
+ standalone: true,
+ template: `
+ @if (open) {
+ ();
+
+ readonly titleId = `context-drawer-title-${nextContextDrawerId++}`;
+
+ @HostListener('document:keydown.escape')
+ onEscape(): void {
+ if (this.open) {
+ this.requestClose();
+ }
+ }
+
+ requestClose(): void {
+ this.closed.emit();
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts
new file mode 100644
index 000000000..c560a8909
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts
@@ -0,0 +1,153 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+ selector: 'app-context-header',
+ standalone: true,
+ template: `
+
+
+ `,
+ styles: [`
+ .context-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ }
+
+ .context-header__copy {
+ display: grid;
+ gap: 0.35rem;
+ min-width: 0;
+ }
+
+ .context-header__eyebrow {
+ margin: 0;
+ color: var(--color-status-info, var(--color-brand-primary));
+ font-size: 0.78rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ .context-header__title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ }
+
+ .context-header__title {
+ margin: 0;
+ color: var(--color-text-heading, var(--color-text-primary));
+ font-size: 1.6rem;
+ }
+
+ .context-header__subtitle,
+ .context-header__note {
+ margin: 0;
+ color: var(--color-text-secondary);
+ line-height: 1.45;
+ }
+
+ .context-header__note {
+ font-size: 0.92rem;
+ }
+
+ .context-header__chips {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .context-header__chip {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ border: 1px solid var(--color-border-primary);
+ padding: 0.2rem 0.55rem;
+ background: var(--color-surface-secondary, var(--color-surface-primary));
+ color: var(--color-text-secondary);
+ font-size: 0.76rem;
+ }
+
+ .context-header__actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ flex-shrink: 0;
+ }
+
+ .context-header__return {
+ border: 1px solid var(--color-border-primary);
+ border-radius: 0.75rem;
+ background: var(--color-surface-secondary, var(--color-surface-primary));
+ color: var(--color-text-primary);
+ cursor: pointer;
+ font-weight: 600;
+ padding: 0.6rem 0.9rem;
+ }
+
+ @media (max-width: 860px) {
+ .context-header {
+ display: grid;
+ }
+
+ .context-header__actions {
+ justify-content: flex-start;
+ }
+ }
+ `],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ContextHeaderComponent {
+ @Input() eyebrow = '';
+ @Input() title = '';
+ @Input() subtitle = '';
+ @Input() contextNote = '';
+ @Input() chips: readonly string[] = [];
+ @Input() backLabel: string | null = null;
+
+ @Output() readonly backClick = new EventEmitter();
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts
new file mode 100644
index 000000000..00cd3667e
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts
@@ -0,0 +1,81 @@
+import type { Params, Router } from '@angular/router';
+
+export type ContextRouteStateKey =
+ | 'tab'
+ | 'panel'
+ | 'drawer'
+ | 'returnTo'
+ | 'scope'
+ | 'view';
+
+export interface ContextRouteStateReader {
+ get(name: string): string | null;
+}
+
+export function coerceContextRouteState(
+ value: string | null | undefined,
+ allowed: readonly T[],
+ fallback: T,
+): T {
+ if (!value) {
+ return fallback;
+ }
+
+ return allowed.includes(value as T) ? (value as T) : fallback;
+}
+
+export function readContextRouteState(
+ reader: ContextRouteStateReader,
+ key: ContextRouteStateKey | string,
+ allowed: readonly T[],
+ fallback: T,
+): T {
+ return coerceContextRouteState(reader.get(key), allowed, fallback);
+}
+
+export function readContextRouteParam(
+ reader: ContextRouteStateReader,
+ key: ContextRouteStateKey | string,
+): string | null {
+ const value = reader.get(key);
+ if (!value) {
+ return null;
+ }
+
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+export function buildContextRouteParams(
+ values: Record,
+): Params {
+ const params: Params = {};
+
+ for (const [key, value] of Object.entries(values)) {
+ if (value === null) {
+ params[key] = null;
+ continue;
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (trimmed.length > 0) {
+ params[key] = trimmed;
+ }
+ }
+ }
+
+ return params;
+}
+
+export function buildContextReturnTo(
+ router: Router,
+ commands: readonly unknown[],
+ queryParams?: Record,
+): string {
+ return router.serializeUrl(
+ router.createUrlTree(commands as readonly string[], {
+ queryParams: queryParams ? buildContextRouteParams(queryParams) : undefined,
+ }),
+ );
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
index 4a848fab5..6ab5bf3ca 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
@@ -7,9 +7,14 @@
// Layout primitives
export * from './page-header/page-header.component';
+export * from './context-header/context-header.component';
+export * from './context-drawer-host/context-drawer-host.component';
export * from './filter-bar/filter-bar.component';
+export * from './list-detail-shell/list-detail-shell.component';
export * from './split-pane/split-pane.component';
export * from './tabbed-nav/tabbed-nav.component';
+export * from './overview-card-groups/overview-card-groups.component';
+export * from './context-route-state/context-route-state';
// Data display
export * from './status-badge/status-badge.component';
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts
new file mode 100644
index 000000000..f3fe1756c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts
@@ -0,0 +1,52 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'app-list-detail-shell',
+ standalone: true,
+ template: `
+
+
+ }
+ `,
+ styles: [`
+ .overview-card-groups__group {
+ display: grid;
+ gap: 0.9rem;
+ }
+
+ .overview-card-groups__header h2,
+ .overview-card-groups__card h3 {
+ margin: 0;
+ }
+
+ .overview-card-groups__header p,
+ .overview-card-groups__card p {
+ color: var(--color-text-secondary);
+ margin: 0.35rem 0 0;
+ line-height: 1.45;
+ }
+
+ .overview-card-groups__grid {
+ display: grid;
+ gap: 0.9rem;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ }
+
+ .overview-card-groups__card {
+ display: grid;
+ gap: 0.8rem;
+ padding: 1rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: 1rem;
+ background: var(--color-surface-primary);
+ color: inherit;
+ text-decoration: none;
+ min-width: 0;
+ transition: border-color 120ms ease, transform 120ms ease;
+ }
+
+ .overview-card-groups__card:hover {
+ border-color: var(--color-brand-primary);
+ transform: translateY(-1px);
+ }
+
+ .overview-card-groups__card-topline {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ justify-content: space-between;
+ }
+
+ .overview-card-groups__owner,
+ .overview-card-groups__impact {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 0.18rem 0.55rem;
+ font-size: 0.76rem;
+ font-weight: 600;
+ }
+
+ .overview-card-groups__owner {
+ background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent);
+ color: var(--color-brand-primary);
+ }
+
+ .overview-card-groups__owner--setup {
+ background: color-mix(in srgb, var(--color-status-warning-bg) 72%, transparent);
+ color: var(--color-status-warning-text);
+ }
+
+ .overview-card-groups__impact {
+ background: var(--color-surface-secondary);
+ color: var(--color-text-secondary);
+ }
+
+ .overview-card-groups__impact--blocking {
+ background: var(--color-status-error-bg);
+ color: var(--color-status-error-text);
+ }
+
+ .overview-card-groups__impact--degraded {
+ background: var(--color-status-warning-bg);
+ color: var(--color-status-warning-text);
+ }
+
+ .overview-card-groups__impact--info {
+ background: var(--color-status-info-bg);
+ color: var(--color-status-info-text);
+ }
+ `],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OverviewCardGroupsComponent {
+ @Input() groups: readonly OverviewCardGroup[] = [];
+ @Input() groupTestIdPrefix = 'overview-group';
+ @Input() cardTestIdPrefix = 'overview-card';
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts
index f92e1bab2..69d6cfcde 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts
@@ -13,8 +13,10 @@ export interface TabItem {
id: string;
label: string;
icon?: string;
- route?: string; // If set, uses router navigation
+ route?: string | readonly unknown[]; // If set, uses router navigation
+ queryParams?: Record;
disabled?: boolean;
+ testId?: string;
}
@Component({
@@ -23,16 +25,18 @@ export interface TabItem {
imports: [RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-
+
+
}
@@ -422,8 +398,8 @@
-
@if (entriesLoading() && !entries().length) {
@if (entryPanelMode()) {
- Loading watchlist rules...
} @else if (!filteredEntries().length) {
@@ -218,7 +194,7 @@
- = {
+ return buildContextRouteParams({
scope,
tab,
- };
-
- if (returnTo) {
- params['returnTo'] = returnTo;
- }
-
- if (tab === 'entries' && entryId) {
- params['entryId'] = entryId;
- }
- if (tab === 'entries' && entryId === 'new' && duplicateOf) {
- params['duplicateOf'] = duplicateOf;
- }
- if (tab === 'alerts' && alertId) {
- params['alertId'] = alertId;
- }
- if (tab === 'tuning' && entryId) {
- params['entryId'] = entryId;
- }
-
- return params;
+ returnTo,
+ entryId: tab === 'entries' || tab === 'tuning' ? entryId : null,
+ duplicateOf: tab === 'entries' && entryId === 'new' ? duplicateOf : null,
+ alertId: tab === 'alerts' ? alertId : null,
+ }) as Record;
}
private showSuccess(message: string): void {
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html
index b736089f3..fde9b6cd0 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html
@@ -1,44 +1,20 @@
-
-
+ Back to release runs
+
- @if (context(); as context) {
-
-
- @if (returnTo()) {
-
@if (loading()) {
-
-
- }
+
+
+
}
diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts
index f253b0511..72b7db3f7 100644
--- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts
@@ -4,6 +4,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ReplayControlsComponent } from '../evidence-export/replay-controls.component';
+import {
+ ContextDrawerHostComponent,
+ ContextHeaderComponent,
+ type TabItem,
+ TabbedNavComponent,
+} from '../../shared/ui';
+import {
+ buildContextReturnTo,
+ buildContextRouteParams,
+ readContextRouteParam,
+} from '../../shared/ui/context-route-state/context-route-state';
import { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component';
import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
@@ -20,6 +31,9 @@ import {
imports: [
CommonModule,
RouterLink,
+ ContextHeaderComponent,
+ ContextDrawerHostComponent,
+ TabbedNavComponent,
WorkflowVisualizerComponent,
StepDetailPanelComponent,
TimeTravelControlsComponent,
@@ -45,14 +59,27 @@ export class RunGraphReplayPageComponent {
readonly criticalPathOnly = signal(false);
readonly returnTo = signal
+
+
+
}
-
+
}
diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts
index 764b3e712..83cf374f0 100644
--- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts
@@ -32,6 +32,17 @@ import {
WatchlistMatchMode,
WatchlistScope,
} from '../../core/api/watchlist.models';
+import {
+ ContextHeaderComponent,
+ ListDetailShellComponent,
+ type TabItem,
+ TabbedNavComponent,
+} from '../../shared/ui';
+import {
+ buildContextRouteParams,
+ readContextRouteParam,
+ readContextRouteState,
+} from '../../shared/ui/context-route-state/context-route-state';
type ViewMode = 'list' | 'edit' | 'alerts';
type WatchlistTab = 'entries' | 'alerts' | 'tuning';
@@ -62,7 +73,13 @@ const ALERT_SORT_ORDERS: readonly AlertSortOrder[] = ['newest', 'oldest'];
@Component({
selector: 'app-watchlist-page',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule],
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ ContextHeaderComponent,
+ ListDetailShellComponent,
+ TabbedNavComponent,
+ ],
templateUrl: './watchlist-page.component.html',
styleUrls: ['./watchlist-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -118,6 +135,11 @@ export class WatchlistPageComponent implements OnInit {
'Critical',
];
readonly tabOptions: readonly WatchlistTab[] = WATCHLIST_TABS;
+ readonly tabItems: readonly TabItem[] = [
+ { id: 'entries', label: 'Entries', testId: 'watchlist-tab-entries' },
+ { id: 'alerts', label: 'Alerts', testId: 'watchlist-tab-alerts' },
+ { id: 'tuning', label: 'Tuning', testId: 'watchlist-tab-tuning' },
+ ];
readonly scopeOptions: readonly WatchlistScopeFilter[] = WATCHLIST_SCOPES;
readonly alertWindows: readonly AlertWindow[] = ALERT_WINDOWS;
readonly alertSortOptions: readonly AlertSortOrder[] = ALERT_SORT_ORDERS;
@@ -729,6 +751,21 @@ export class WatchlistPageComponent implements OnInit {
void this.loadEntries();
}
+ onTabSelected(tabId: string): void {
+ switch (tabId as WatchlistTab) {
+ case 'alerts':
+ this.showAlerts();
+ break;
+ case 'tuning':
+ this.showTuning();
+ break;
+ case 'entries':
+ default:
+ this.showList();
+ break;
+ }
+ }
+
changeScope(scope: WatchlistScopeFilter): void {
const entry = this.selectedEntry();
const alert = this.selectedAlert();
@@ -886,15 +923,15 @@ export class WatchlistPageComponent implements OnInit {
segments: readonly string[],
params: ParamMap
): void {
- const tab = this.parseTab(segments, params.get('tab'));
- const scope = this.parseScope(params.get('scope'));
- const entryId = params.get('entryId');
- const duplicateOf = params.get('duplicateOf');
- const alertId = params.get('alertId');
+ const tab = this.parseTab(segments, params);
+ const scope = readContextRouteState(params, 'scope', WATCHLIST_SCOPES, 'tenant');
+ const entryId = readContextRouteParam(params, 'entryId');
+ const duplicateOf = readContextRouteParam(params, 'duplicateOf');
+ const alertId = readContextRouteParam(params, 'alertId');
this.activeTab.set(tab);
this.scopeFilter.set(scope);
- this.returnTo.set(params.get('returnTo'));
+ this.returnTo.set(readContextRouteParam(params, 'returnTo'));
if (tab === 'alerts') {
this.entryPanelMode.set(null);
@@ -1033,25 +1070,14 @@ export class WatchlistPageComponent implements OnInit {
private parseTab(
segments: readonly string[],
- queryValue: string | null
+ params: ParamMap
): WatchlistTab {
- if (queryValue && WATCHLIST_TABS.includes(queryValue as WatchlistTab)) {
- return queryValue as WatchlistTab;
- }
-
const finalSegment = segments.at(-1);
if (finalSegment && WATCHLIST_TABS.includes(finalSegment as WatchlistTab)) {
return finalSegment as WatchlistTab;
}
- return 'entries';
- }
-
- private parseScope(rawValue: string | null): WatchlistScopeFilter {
- if (rawValue && WATCHLIST_SCOPES.includes(rawValue as WatchlistScopeFilter)) {
- return rawValue as WatchlistScopeFilter;
- }
- return 'tenant';
+ return readContextRouteState(params, 'tab', WATCHLIST_TABS, 'entries');
}
private resolveAlertThreshold(window: AlertWindow): number {
@@ -1112,29 +1138,14 @@ export class WatchlistPageComponent implements OnInit {
const alertId = overrides.alertId ?? this.selectedAlertId();
const duplicateOf = overrides.duplicateOf ?? this.duplicateSourceId();
- const params: Record
@if (alertsLoading() && !alerts().length) {
@if (selectedAlert(); as alert) {
- Loading watchlist alerts...
} @else if (!filteredAlerts().length) {
@@ -481,7 +457,7 @@
Alert detail
@@ -548,7 +524,7 @@
- Back to release runs
-
+ {{ context()?.detail?.releaseName || 'Release Run' }}
-
- Runtime graphing, replay, and evidence for
- {{ runId() }}
-
- {{ context.detail.releaseType }}
- {{ context.detail.status }}
- {{ context.detail.outcome }}
- {{ context.detail.targetRegion || 'global' }}/{{ context.detail.targetEnvironment || 'all' }}
-
- }
-
- Opened from another operator flow.
- Return to previous context
-
- }
-
-
+ Loading run graph and replay context...
@@ -241,22 +217,24 @@
}
- @if (selectedStep()) {
-
-
-
- Step detail
- -
+ @if (presentation === 'overlay' && showBackdrop) {
+
+ }
+
+
+
+
+
+
+
+
+ }
+ `,
+ styles: [`
+ .context-drawer-host {
+ min-width: 0;
+ }
+
+ .context-drawer-host--overlay {
+ inset: 0;
+ pointer-events: none;
+ position: fixed;
+ z-index: 40;
+ }
+
+ .context-drawer-host--rail {
+ display: block;
+ height: 100%;
+ min-width: 0;
+ }
+
+ .context-drawer-host__backdrop {
+ background: color-mix(in srgb, #09101d 58%, transparent);
+ border: none;
+ inset: 0;
+ pointer-events: auto;
+ position: absolute;
+ }
+
+ .context-drawer-host__panel {
+ background: var(--color-surface-primary);
+ border: 1px solid var(--color-border-primary);
+ box-shadow: 0 24px 48px rgba(4, 16, 32, 0.22);
+ min-width: 0;
+ pointer-events: auto;
+ }
+
+ .context-drawer-host--overlay .context-drawer-host__panel {
+ border-bottom-left-radius: 1rem;
+ border-top-left-radius: 1rem;
+ height: 100%;
+ margin-left: auto;
+ overflow: auto;
+ position: relative;
+ }
+
+ .context-drawer-host--rail .context-drawer-host__panel {
+ border-radius: 1rem;
+ height: 100%;
+ overflow: auto;
+ position: sticky;
+ top: 1rem;
+ }
+
+ .context-drawer-host__panel--md {
+ width: min(28rem, 92vw);
+ }
+
+ .context-drawer-host__panel--lg {
+ width: min(36rem, 94vw);
+ }
+
+ .context-drawer-host__panel--xl {
+ width: min(46rem, 96vw);
+ }
+
+ .context-drawer-host--rail .context-drawer-host__panel--md,
+ .context-drawer-host--rail .context-drawer-host__panel--lg,
+ .context-drawer-host--rail .context-drawer-host__panel--xl {
+ width: 100%;
+ }
+
+ .context-drawer-host__header {
+ align-items: flex-start;
+ border-bottom: 1px solid var(--color-border-primary);
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ padding: 1rem 1rem 0.85rem;
+ }
+
+ .context-drawer-host__title-block {
+ display: grid;
+ gap: 0.35rem;
+ min-width: 0;
+ }
+
+ .context-drawer-host__eyebrow {
+ margin: 0;
+ color: var(--color-status-info, var(--color-brand-primary));
+ font-size: 0.76rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ .context-drawer-host__title {
+ margin: 0;
+ font-size: 1.05rem;
+ }
+
+ .context-drawer-host__description {
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.45;
+ }
+
+ .context-drawer-host__header-actions {
+ align-items: center;
+ display: flex;
+ gap: 0.65rem;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ }
+
+ .context-drawer-host__close {
+ background: var(--color-surface-secondary, var(--color-surface-primary));
+ border: 1px solid var(--color-border-primary);
+ border-radius: 0.75rem;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ padding: 0.55rem 0.85rem;
+ }
+
+ .context-drawer-host__body {
+ display: grid;
+ gap: 1rem;
+ padding: 1rem;
+ }
+
+ @media (max-width: 900px) {
+ .context-drawer-host__header {
+ display: grid;
+ }
+
+ .context-drawer-host__header-actions {
+ justify-content: flex-start;
+ }
+
+ .context-drawer-host--rail .context-drawer-host__panel {
+ position: static;
+ }
+ }
+ `],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ContextDrawerHostComponent {
+ @Input() open = false;
+ @Input() presentation: 'overlay' | 'rail' = 'overlay';
+ @Input() size: 'md' | 'lg' | 'xl' = 'lg';
+ @Input() eyebrow = '';
+ @Input() title = '';
+ @Input() description = '';
+ @Input() closeLabel = 'Close';
+ @Input() showBackdrop = true;
+ @Input() testId: string | null = null;
+ @Input() closeTestId: string | null = null;
+
+ @Output() readonly closed = new EventEmitter
+ @if (eyebrow) {
+
+
+ {{ eyebrow }}
+ } +{{ title }}
+ @if (description) { +{{ description }}
+ } +
+
+
+
+
+
+
+
+
+ @if (eyebrow) {
+
+
+ {{ eyebrow }}
+ } + +
+
+
+ @if (subtitle) {
+ {{ title }}
+ + @if (chips.length) { +
+ @for (chip of chips; track chip) {
+ {{ chip }}
+ }
+
+ }
+ {{ subtitle }}
+ } + + @if (contextNote) { +{{ contextNote }}
+ } +
+ @if (backLabel) {
+
+ }
+
+
+
+
+
+ `,
+ styles: [`
+ .list-detail-shell {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .list-detail-shell--with-detail {
+ grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
+ align-items: start;
+ }
+
+ .list-detail-shell__primary,
+ .list-detail-shell__detail {
+ min-width: 0;
+ }
+
+ @media (max-width: 1100px) {
+ .list-detail-shell,
+ .list-detail-shell--with-detail {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
+ `],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ListDetailShellComponent {
+ @Input() detailVisible = false;
+ @Input() detailWidth = '24rem';
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts
new file mode 100644
index 000000000..05d1a1756
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts
@@ -0,0 +1,161 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { RouterLink } from '@angular/router';
+
+export type OverviewCardImpact = 'blocking' | 'degraded' | 'info';
+
+export interface OverviewCardGroupItem {
+ readonly id: string;
+ readonly title: string;
+ readonly detail: string;
+ readonly metric: string;
+ readonly impact: OverviewCardImpact;
+ readonly route: string;
+ readonly owner?: string;
+}
+
+export interface OverviewCardGroup {
+ readonly id: string;
+ readonly title: string;
+ readonly description: string;
+ readonly cards: readonly OverviewCardGroupItem[];
+}
+
+@Component({
+ selector: 'app-overview-card-groups',
+ standalone: true,
+ imports: [RouterLink],
+ template: `
+ @for (group of groups; track group.id) {
+
+
+
+
+ @if (detailVisible) {
+
+
+
+ }
+
+
+
+
+
+ {{ group.title }}
+{{ group.description }}
+
+ @for (card of group.cards; track card.id) {
+
+
+
+ @if (card.owner) {
+
+ {{ card.owner }}
+
+ }
+
+ {{ card.metric }}
+
+
+
+ {{ card.title }}
+{{ card.detail }}
+ + } +