Files
git.stella-ops.org/src/Web/StellaOps.Web/src/app/app.component.ts
master 95357ffbb9 Web UI: feature updates across all modules
Broad UI improvements spanning auth, branding, notifications, agents, analytics,
approvals, audit-log, bundles, configuration, console-admin, dashboard,
deployments, doctor, environments, evidence, feed-mirror, graph, integration-hub,
issuer-trust, lineage, notify, offline-kit, policy, promotions, quota, registry,
release-orchestrator, releases, sbom, scans, secret-detection, security, settings,
setup-wizard, system-health, topology, triage, trust-admin, unknowns, vex-hub,
vulnerabilities, and watchlist features.

Adds new shared components (page-action-outlet, stella-action-card, stella-form-field),
scripts feature module, audit-trust component, e2e test helpers, and release page
e2e specs. Updates auth session model, branding service, color tokens, form styles,
and i18n translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:28:48 +02:00

242 lines
8.6 KiB
TypeScript

import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
ViewChild,
} from '@angular/core';
import { Router, RouterLink, RouterOutlet, NavigationEnd, TitleStrategy } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith, take } from 'rxjs/operators';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
import { NavigationMenuComponent } from './shared/components/navigation-menu/navigation-menu.component';
import { UserMenuComponent } from './shared/components/user-menu/user-menu.component';
import { CommandPaletteComponent } from './shared/components/command-palette/command-palette.component';
import { ToastContainerComponent } from './shared/components/toast/toast-container.component';
import { BreadcrumbComponent } from './shared/components/breadcrumb/breadcrumb.component';
import { KeyboardShortcutsComponent } from './shared/components/keyboard-shortcuts/keyboard-shortcuts.component';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
import { BrandingService } from './core/branding/branding.service';
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
import { TranslatePipe } from './core/i18n';
@Component({
selector: 'app-root',
imports: [
CommonModule,
RouterOutlet,
RouterLink,
NavigationMenuComponent,
UserMenuComponent,
AppShellComponent,
CommandPaletteComponent,
ToastContainerComponent,
BreadcrumbComponent,
KeyboardShortcutsComponent,
LegacyUrlBannerComponent,
TranslatePipe,
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
private static readonly SHELL_EXCLUDED_ROUTES = [
'/setup-wizard',
'/welcome',
'/callback',
'/silent-refresh',
'/auth/callback',
'/auth/silent-refresh',
];
private readonly router = inject(Router);
private readonly titleStrategy = inject(TitleStrategy);
private readonly auth = inject(AuthorityAuthService);
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly brandingService = inject(BrandingService);
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
private readonly contextUrlSync = inject(PlatformContextUrlSyncService);
private readonly doctorTrend = inject(DoctorTrendService);
private readonly doctorNotification = inject(DoctorNotificationService);
private shellBackgroundServicesActive = false;
@ViewChild(CommandPaletteComponent) private commandPalette!: CommandPaletteComponent;
private readonly destroyRef = inject(DestroyRef);
constructor() {
const removeSplash = () => {
const splash = document.getElementById('stella-splash');
if (!splash) {
return;
}
splash.style.opacity = '0';
splash.style.transition = 'opacity 0.3s ease-out';
setTimeout(() => splash.remove(), 350);
};
// Remove the inline splash screen once the first route resolves.
// This keeps the splash visible while route guards (e.g. backend probe)
// are still pending, avoiding a blank screen.
this.router.events
.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => removeSplash());
// Defensive fallback: if first navigation never settles (e.g. test/misconfigured
// backend), remove splash so the shell remains interactive. Use 10s to match
// the silent-refresh iframe timeout so the splash stays visible while OIDC
// session restoration is in-flight.
setTimeout(() => removeSplash(), 10_000);
// Initialize branding on app start
this.brandingService.fetchBranding().subscribe();
effect(() => {
this.brandingService.currentBranding();
this.brandingService.isLoaded();
this.currentUrl();
this.titleStrategy.updateTitle(this.router.routerState.snapshot);
});
// Attempt to silently restore the auth session if the user was
// previously logged in (session cookie may still be active at the Authority).
void this.auth.trySilentRefresh();
// Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize();
effect(() => {
const shouldRunShellBackgroundServices =
this.isAuthenticated() && !this.isShellExcludedRoute(this.currentUrl());
if (shouldRunShellBackgroundServices) {
if (this.shellBackgroundServicesActive) {
return;
}
// Keep global scope in sync with route query parameters only once the
// authenticated shell is active.
this.contextUrlSync.initialize();
// Start Doctor background services (deferred from APP_INITIALIZER
// to avoid NG0200 circular DI with Router during bootstrap).
this.doctorTrend.start();
this.doctorNotification.start();
this.shellBackgroundServicesActive = true;
return;
}
if (!this.shellBackgroundServicesActive) {
return;
}
this.doctorTrend.stop();
this.doctorNotification.stop();
this.shellBackgroundServicesActive = false;
});
}
readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly authStatus = this.sessionStore.status;
readonly activeTenant = this.consoleStore.selectedTenantId;
readonly freshAuthSummary = computed(() => {
const token = this.consoleStore.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
// Legacy route info for banner (ROUTE-003)
readonly legacyRouteInfo = this.legacyRouteTelemetry.currentLegacyRoute;
private readonly currentUrl$ = this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map(event => event.urlAfterRedirects.split('?')[0]),
startWith(this.router.url.split('?')[0])
);
private readonly currentUrl = toSignal(this.currentUrl$, {
initialValue: (typeof window !== 'undefined' ? window.location.pathname : '/'),
});
readonly useShellLayout = computed(() => {
const url = this.currentUrl();
return this.isAuthenticated() && !this.isShellExcludedRoute(url);
});
readonly showBreadcrumb = computed(() => {
const url = this.currentUrl();
const hideRoutes = [
'/',
'/welcome',
...AppComponent.SHELL_EXCLUDED_ROUTES,
];
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
return false;
}
return url.split('/').filter(s => s).length > 0;
});
/** Setup wizard gets a completely chrome-free viewport. */
readonly isFullPageRoute = computed(() => {
const url = this.currentUrl();
return url === '/setup-wizard' || url.startsWith('/setup-wizard/');
});
/** Hide navigation on setup/auth pages and when not authenticated. */
readonly showNavigation = computed(() => {
const url = this.currentUrl();
if (this.isShellExcludedRoute(url)) {
return false;
}
return this.isAuthenticated();
});
/** Show sign-in only on pages where auth makes sense (not setup/callback). */
readonly showSignIn = computed(() => {
const url = this.currentUrl();
return !this.isShellExcludedRoute(url);
});
onSignIn(): void {
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
void this.auth.beginLogin(returnUrl);
}
onLegacyBannerDismissed(): void {
this.legacyRouteTelemetry.clearCurrentLegacyRoute();
}
/** Triggered by the keyboard easter egg (typing d-e-m-o quickly) */
onDemoSeedRequested(): void {
this.commandPalette?.triggerSeedDemo();
}
private isShellExcludedRoute(url: string): boolean {
return AppComponent.SHELL_EXCLUDED_ROUTES.some(
(route) => url === route || url.startsWith(route + '/')
);
}
}