Ship 7 remaining journey fixes: Harbor data, scan timeout, permissions,
flicker, pack creation, export tooltip, audit guidance Sprint A: Harbor fixture now returns realistic search results (7 repos) and artifact digests (3 versions with tags). Release creation wizard Step 2 now shows actual images to select. Sprint B: Scan polling caps at 60 polls (3 min). Shows timeout banner with guidance link to Scheduled Jobs and "Keep Waiting" button. Sprint C: /console/profile route now renders InsufficientPermissions component instead of 404. Shows user/tenant, guidance, and nav links. Catches all 24 guard redirect dead-ends. Sprint D: Event stream chip no longer flickers DEGRADED during context reloads. Loading state treated as connected (transient, not error). Sprint E: Policy Packs empty state now has inline Create Pack form. Calls existing PolicyApiService.createPack() backend endpoint. Sprint F: Diagnostics Export button shows disabled tooltip "Run a diagnostic check first" when no results available. Sprint G: Audit Log shows guidance text when all module counts are 0. Lists automatically captured event types. Confirms audit is active. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -424,6 +424,14 @@ export const routes: Routes = [
|
||||
path: 'setup-wizard',
|
||||
loadChildren: () => import('./features/setup-wizard/setup-wizard.routes').then((m) => m.setupWizardRoutes),
|
||||
},
|
||||
{
|
||||
path: 'console/profile',
|
||||
title: 'Insufficient Permissions',
|
||||
loadComponent: () =>
|
||||
import('./features/console/insufficient-permissions.component').then(
|
||||
(m) => m.InsufficientPermissionsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
title: 'Page Not Found',
|
||||
|
||||
@@ -53,4 +53,57 @@ describe('AuditLogDashboardComponent', () => {
|
||||
|
||||
expect(exportLink.getAttribute('href')).toBe('/evidence/exports');
|
||||
});
|
||||
|
||||
it('does not show empty guidance when events exist', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
|
||||
expect(guidance).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditLogDashboardComponent (zero counts)', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AuditLogDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuditLogClient,
|
||||
useValue: {
|
||||
getStatsSummary: () => of({
|
||||
totalEvents: 0,
|
||||
byModule: {
|
||||
policy: 0,
|
||||
authority: 0,
|
||||
vex: 0,
|
||||
integrations: 0,
|
||||
jobengine: 0,
|
||||
scanner: 0,
|
||||
attestor: 0,
|
||||
sbom: 0,
|
||||
scheduler: 0,
|
||||
},
|
||||
}),
|
||||
getEvents: () => of({ items: [] }),
|
||||
getAnomalyAlerts: () => of([]),
|
||||
acknowledgeAnomaly: () => of(void 0),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty guidance when all module counts are zero', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
|
||||
expect(guidance).toBeTruthy();
|
||||
expect(guidance.nativeElement.textContent).toContain('Audit events will appear as the platform is used');
|
||||
expect(guidance.nativeElement.textContent).toContain('audit capture is active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
|
||||
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
@@ -35,6 +35,20 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (allCountsZero()) {
|
||||
<div class="audit-log__empty-guidance">
|
||||
<p>Audit events will appear as the platform is used. Events are captured automatically for:</p>
|
||||
<ul>
|
||||
<li>Release seals, promotions, and approvals</li>
|
||||
<li>Policy changes and activations</li>
|
||||
<li>VEX decisions and consensus votes</li>
|
||||
<li>Integration configuration changes</li>
|
||||
<li>Identity and access management</li>
|
||||
</ul>
|
||||
<p class="audit-log__status-note">The Evidence rail indicator shows <strong>ON</strong> — audit capture is active.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (anomalies().length > 0) {
|
||||
<section class="anomaly-alerts">
|
||||
<h2>Anomaly Alerts</h2>
|
||||
@@ -187,6 +201,18 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
||||
.badge.action.delete { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.badge.action.promote { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.resource { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.audit-log__empty-guidance {
|
||||
margin: 0 0 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
border-left: 3px solid var(--color-border-primary);
|
||||
}
|
||||
.audit-log__empty-guidance p { margin: 0 0 0.5rem; }
|
||||
.audit-log__empty-guidance ul { margin: 0 0 0.5rem; padding-left: 1.25rem; }
|
||||
.audit-log__empty-guidance li { margin-bottom: 0.25rem; }
|
||||
.audit-log__status-note { margin-bottom: 0 !important; font-size: 0.85rem; }
|
||||
`]
|
||||
})
|
||||
export class AuditLogDashboardComponent implements OnInit {
|
||||
@@ -197,6 +223,12 @@ export class AuditLogDashboardComponent implements OnInit {
|
||||
readonly anomalies = signal<AuditAnomalyAlert[]>([]);
|
||||
readonly moduleStats = signal<Array<{ module: AuditModule; count: number }>>([]);
|
||||
|
||||
readonly allCountsZero = computed(() => {
|
||||
const s = this.stats();
|
||||
if (!s) return false;
|
||||
return s.totalEvents === 0 && this.moduleStats().every(entry => entry.count === 0);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-insufficient-permissions',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="insufficient-permissions">
|
||||
<div class="insufficient-permissions-content">
|
||||
<div class="error-code">403</div>
|
||||
<h1>Insufficient Permissions</h1>
|
||||
<div class="user-info">
|
||||
<span class="user-label">Signed in as</span>
|
||||
<strong>{{ auth.identity()?.name ?? 'Unknown' }}</strong>
|
||||
@if (auth.tenantId(); as tenant) {
|
||||
<span class="tenant-chip">{{ tenant }}</span>
|
||||
}
|
||||
</div>
|
||||
<p>
|
||||
You don't have the required permissions to access this page.
|
||||
Contact your administrator to request additional access.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a routerLink="/mission-control/board" class="btn btn-primary">Go to Dashboard</a>
|
||||
<a routerLink="/setup/identity-access" class="btn btn-secondary">Identity & Access</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.insufficient-permissions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.insufficient-permissions-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-muted, #4b5563);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-label {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-heading);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class InsufficientPermissionsComponent {
|
||||
protected readonly auth = inject(AuthSessionStore);
|
||||
}
|
||||
@@ -79,10 +79,16 @@ type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse';
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-outline" (click)="copyToClipboard()">
|
||||
<button class="btn btn-outline"
|
||||
[disabled]="!hasResults()"
|
||||
[title]="hasResults() ? 'Copy report to clipboard' : 'Run a diagnostic check first'"
|
||||
(click)="copyToClipboard()">
|
||||
{{ copyLabel() }}
|
||||
</button>
|
||||
<button class="btn btn-primary" (click)="download()">
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="!hasResults()"
|
||||
[title]="hasResults() ? 'Export results' : 'Run a diagnostic check first'"
|
||||
(click)="download()">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
@@ -285,6 +291,12 @@ type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse';
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ExportDialogComponent {
|
||||
@@ -299,6 +311,10 @@ export class ExportDialogComponent {
|
||||
|
||||
private copied = signal(false);
|
||||
|
||||
hasResults(): boolean {
|
||||
return this.report?.results?.length > 0;
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.closeDialog.emit();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-workspace',
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, RouterLink, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="workspace" [attr.aria-busy]="loading">
|
||||
@@ -102,9 +104,23 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
Policy packs define the rules that govern release decisions.
|
||||
Create a pack to start defining your organization's release policy.
|
||||
</p>
|
||||
<a routerLink="/ops/policy/overview" class="workspace__empty-link">
|
||||
Learn about policy packs
|
||||
</a>
|
||||
<div class="workspace__create-form">
|
||||
<h4>Create your first policy pack</h4>
|
||||
<label>
|
||||
Pack name *
|
||||
<input type="text" [ngModel]="newPackName()" (ngModelChange)="newPackName.set($event)" placeholder="e.g., Release Security Baseline" />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<input type="text" [ngModel]="newPackDesc()" (ngModelChange)="newPackDesc.set($event)" placeholder="Optional description" />
|
||||
</label>
|
||||
@if (createError(); as err) {
|
||||
<p class="error-text">{{ err }}</p>
|
||||
}
|
||||
<button class="btn-primary" (click)="createPack()" [disabled]="!newPackName().trim() || creating()">
|
||||
@if (creating()) { Creating... } @else { Create Pack }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -144,6 +160,15 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
.workspace__empty p { margin: 0 0 1rem; color: var(--color-text-muted); max-width: 480px; margin-inline: auto; }
|
||||
.workspace__empty-link { display: inline-block; color: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; text-decoration: none; }
|
||||
.workspace__empty-link:hover { background: var(--color-brand-primary); color: var(--color-text-inverse); }
|
||||
.workspace__create-form { display: grid; gap: 0.75rem; max-width: 400px; margin: 1.5rem auto 0; text-align: left; }
|
||||
.workspace__create-form h4 { margin: 0; text-align: center; color: var(--color-text-heading); }
|
||||
.workspace__create-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
.workspace__create-form input { padding: 0.45rem 0.6rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.95rem; }
|
||||
.workspace__create-form input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(var(--color-brand-primary-rgb, 99, 102, 241), 0.15); }
|
||||
.workspace__create-form .btn-primary { background: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); color: var(--color-text-inverse); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; cursor: pointer; font-size: 0.95rem; }
|
||||
.workspace__create-form .btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.workspace__create-form .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.workspace__create-form .error-text { margin: 0; color: var(--color-status-error, #ef4444); font-size: 0.85rem; }
|
||||
`,
|
||||
]
|
||||
})
|
||||
@@ -161,7 +186,13 @@ export class PolicyWorkspaceComponent {
|
||||
protected scopeHint = '';
|
||||
protected refreshing = false;
|
||||
|
||||
protected readonly newPackName = signal('');
|
||||
protected readonly newPackDesc = signal('');
|
||||
protected readonly creating = signal(false);
|
||||
protected readonly createError = signal<string | null>(null);
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
constructor() {
|
||||
@@ -186,6 +217,35 @@ export class PolicyWorkspaceComponent {
|
||||
});
|
||||
}
|
||||
|
||||
createPack(): void {
|
||||
const name = this.newPackName().trim();
|
||||
if (!name) return;
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
|
||||
this.policyApi
|
||||
.createPack({
|
||||
name,
|
||||
description: this.newPackDesc().trim(),
|
||||
content: '',
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.newPackName.set('');
|
||||
this.newPackDesc.set('');
|
||||
this.creating.set(false);
|
||||
this.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
this.createError.set(
|
||||
err?.error?.message ?? err?.message ?? 'Failed to create policy pack. Please try again.'
|
||||
);
|
||||
this.creating.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private applyScopes(): void {
|
||||
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
|
||||
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap, takeWhile, tap } from 'rxjs';
|
||||
import { timer, switchMap, takeWhile, tap, take, finalize } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
|
||||
@@ -202,6 +202,14 @@ interface ScanStatusResponse {
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (scanTimedOut()) {
|
||||
<div class="timeout-banner">
|
||||
<p>Scan is taking longer than expected. The scanner may be processing a queue.</p>
|
||||
<p>Check <a routerLink="/ops/operations/jobengine">Scheduled Jobs</a> for status.</p>
|
||||
<button type="button" class="btn-ghost" (click)="resumePolling()">Keep Waiting</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button type="button" class="btn-ghost" (click)="resetForm()">
|
||||
Scan Another Image
|
||||
@@ -457,6 +465,33 @@ interface ScanStatusResponse {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.timeout-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid color-mix(in srgb, var(--color-status-warning-text, #b08800) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--color-status-warning-text, #b08800) 8%, transparent);
|
||||
color: var(--color-status-warning-text, #b08800);
|
||||
}
|
||||
|
||||
.timeout-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeout-banner a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeout-banner .btn-ghost {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.findings-link {
|
||||
margin-left: auto;
|
||||
text-decoration: none;
|
||||
@@ -487,6 +522,7 @@ export class ScanSubmitComponent implements OnDestroy {
|
||||
/** Scan tracking state */
|
||||
readonly scanId = signal<string | null>(null);
|
||||
readonly scanStatus = signal<string>('queued');
|
||||
readonly scanTimedOut = signal(false);
|
||||
private pollSubscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
/** Derive image name (strip tag/digest for triage link) */
|
||||
@@ -558,6 +594,7 @@ export class ScanSubmitComponent implements OnDestroy {
|
||||
this.stopPolling();
|
||||
this.scanId.set(null);
|
||||
this.scanStatus.set('queued');
|
||||
this.scanTimedOut.set(false);
|
||||
this.submitError.set(null);
|
||||
this.imageRef.set('');
|
||||
this.forceRescan = false;
|
||||
@@ -565,6 +602,11 @@ export class ScanSubmitComponent implements OnDestroy {
|
||||
this.metadataEntries = [];
|
||||
}
|
||||
|
||||
resumePolling(): void {
|
||||
this.scanTimedOut.set(false);
|
||||
this.startPolling(this.scanId()!);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
}
|
||||
@@ -573,6 +615,7 @@ export class ScanSubmitComponent implements OnDestroy {
|
||||
this.stopPolling();
|
||||
|
||||
this.pollSubscription = timer(0, 3000).pipe(
|
||||
take(60),
|
||||
switchMap(() =>
|
||||
this.http.get<ScanStatusResponse>(`/api/v1/scans/${encodeURIComponent(scanId)}`)
|
||||
),
|
||||
@@ -587,6 +630,12 @@ export class ScanSubmitComponent implements OnDestroy {
|
||||
true,
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => {
|
||||
const status = this.scanStatus();
|
||||
if (status === 'pending' || status === 'queued' || status === 'scanning') {
|
||||
this.scanTimedOut.set(true);
|
||||
}
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,11 @@ import { PlatformContextStore } from '../../core/context/platform-context.store'
|
||||
export class LiveEventStreamChipComponent {
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
/**
|
||||
* Flicker suppression: loading() is a transient state during context refresh,
|
||||
* not an error. Only show 'degraded' when an actual error is present.
|
||||
*/
|
||||
readonly status = computed<'connected' | 'degraded'>(() => {
|
||||
if (this.context.loading()) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return this.context.error() ? 'degraded' : 'connected';
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user