diff --git a/docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md b/docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md index d69e7484b..b77992dbb 100644 --- a/docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md +++ b/docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md @@ -84,16 +84,23 @@ Completion criteria: | 2026-03-07 | Verification: the new setup trust-signing Playwright slice initially exposed a harness gap in trust API fixtures; added deterministic trust dashboard/key stubs to `prealpha-canonical-full-sweep.spec.ts`, then reran the slice successfully (`npx playwright test tests/e2e/prealpha-canonical-full-sweep.spec.ts --grep \"route works: /setup/trust-signing|setup trust-signing tabs stay under setup routes\"` -> 2/2 pass). Frontend bundle was synced into `compose_console-dist`, and live authenticated Playwright confirmed `Signing Keys` now keeps users on `https://stella-ops.local/setup/trust-signing/keys`. | QA | | 2026-03-07 | A follow-up live authenticated Playwright probe on `https://stella-ops.local/setup/trust-signing/keys` exposed a second trust-signing defect: the canonical setup route still rendered an `Administration` eyebrow because `trust-admin.component.ts` hard-coded the workspace label instead of deriving it from the mounted route root. | QA | | 2026-03-07 | Fixed the trust workspace branding leak by deriving the eyebrow label from the current route root (`setup` vs `administration`) in `trust-admin.component.ts`, tightened the canonical route expectation to require `Setup`, reran the targeted Playwright slice successfully (`npx playwright test tests/e2e/prealpha-canonical-full-sweep.spec.ts --grep \"route works: /setup/trust-signing|setup trust-signing tabs stay under setup routes\"` -> 2/2 pass), rebuilt the frontend bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and live authenticated Playwright confirmed `/setup/trust-signing/keys` now shows `Setup` above `Trust Management`. | Developer (FE) | +| 2026-03-07 | A deeper authenticated Playwright pass over Setup Integrations found two new action defects: completing host onboarding navigated to `/setup/integrations/hosts`, which the router treated as `:integrationId` and left on a detail spinner, and clicking a missing activity/detail route such as `/setup/integrations/int-1` also stayed on an infinite loading state. | QA | +| 2026-03-07 | Root cause for the host flow was `integrations-hub.component.ts` mapping `host -> hosts` even though the canonical list route is `/runtime-hosts`. Root cause for the dead detail flow was `integration-detail.component.ts` never leaving `loading` when the backing GET failed or stalled. Additional live triage showed the direct Integrations service answered immediately, but the authenticated `stella-ops.local` path could stall long enough that the browser request had to be aborted client-side. | Developer (FE) | +| 2026-03-07 | Fixed the host onboarding post-create route to `runtime-hosts`, added explicit unavailable/error rendering to integration detail, and added bounded request timeouts plus retry/error states to integration list/detail pages so authenticated frontdoor stalls no longer trap operators on indefinite spinners. Targeted Playwright harness regressions passed after restarting the reused local source server (`npx playwright test tests/e2e/prealpha-canonical-full-sweep.spec.ts --grep \"setup host onboarding returns to runtime-hosts list after create|setup integration detail 404 renders an explicit error state\"` -> 2/2 pass). | Developer (FE) | +| 2026-03-07 | Live authenticated Playwright confirmed the repaired host action now lands on `https://stella-ops.local/setup/integrations/runtime-hosts` without entering detail fallback, confirmed missing detail routes now render an explicit unavailable/timeout state with a back-link instead of a permanent spinner, and verified previously hanging list pages such as `/setup/integrations/registries` and `/setup/integrations/secrets` now fail closed with retryable timeout messaging when the authenticated frontdoor path stalls. | QA | ## Decisions & Risks - Decision: this sprint stays inside `src/Web/StellaOps.Web` plus required sprint/doc updates only. - Decision: Playwright is the primary behavioral verification tool; existing shallow sweep scripts are reference material, not acceptance evidence. - Decision: avoid heavy solution-wide builds/tests due to memory constraints; use targeted FE checks only when a fix requires them. - Decision: canonical route regressions must assert route-specific titles/headings, not only that the URL and shell remain visible. This aligns the implementation with `docs/modules/ui/v2-rewire/S00_route_deprecation_map.md`. +- Decision: setup/ops integration pages must fail closed on request stalls with explicit retryable states; the operator experience cannot depend on an eventually-resolving gateway path. - Risk: concurrent agents are actively modifying search-related Web files. - Mitigation: avoid those files unless a reproduced defect proves they are the root cause; record any overlap before editing. - Risk: some visible failures may originate from backend APIs rather than Web code. - Mitigation: capture the exact failing route/action and stop at triage if the root cause leaves Web scope. +- Risk: the authenticated `stella-ops.local` frontdoor path for `/api/v1/integrations*` can still stall longer than the direct Integrations host path even after the backend service was repaired. +- Mitigation: Web now surfaces explicit timeout/retry states instead of indefinite spinners; a later cross-module iteration should trace the frontdoor/gateway hop if flawless live behavior remains the goal. ## Next Checkpoints - 2026-03-06: Complete first fresh Playwright route/action sweep and defect list. diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts index 7169734a3..59425f9a9 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { Subject, interval, takeUntil } from 'rxjs'; +import { integrationWorkspaceCommands } from './integration-route-context'; /** * Integration activity timeline component. @@ -44,7 +45,7 @@ export type ActivityEventType = template: `
- Back to Integrations + Back to Integrations

Integration Activity

Audit trail for all integration lifecycle events

@@ -127,7 +128,7 @@ export type ActivityEventType = {{ formatTimestamp(event.timestamp) }}
- + {{ event.integrationName }} {{ event.integrationProvider }} @@ -348,6 +349,7 @@ export type ActivityEventType = `] }) export class IntegrationActivityComponent implements OnInit, OnDestroy { + private readonly router = inject(Router); private destroy$ = new Subject(); // Filters @@ -363,6 +365,14 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy { loading = false; hasMore = false; + integrationHubRoute(): string[] { + return this.integrationCommands(); + } + + integrationDetailRoute(integrationId: string): string[] { + return this.integrationCommands(integrationId); + } + // Mock data for development private mockEvents: ActivityEvent[] = [ { @@ -442,6 +452,10 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private integrationCommands(...segments: string[]): string[] { + return integrationWorkspaceCommands(this.router.url, ...segments); + } + loadEvents(): void { // In production, fetch from API this.events = [...this.mockEvents]; diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index 060e4f01a..3a485f80a 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -1,7 +1,9 @@ -import { Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { timeout } from 'rxjs'; import { IntegrationService } from './integration.service'; +import { integrationWorkspaceCommands } from './integration-route-context'; import { Integration, IntegrationHealthResponse, @@ -23,10 +25,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event selector: 'app-integration-detail', imports: [CommonModule, RouterModule], template: ` - @if (integration) { + @if (loading) { +
Loading integration details...
+ } @else if (integration) {
- Back to Integrations + Back to Integrations

{{ integration.name }}

{{ getStatusLabel(integration.status) }} @@ -188,7 +192,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
} @else { -
Loading integration details...
+
+ Back to Integrations +

Integration unavailable

+

{{ loadErrorMessage || 'The requested integration could not be loaded.' }}

+
} `, @@ -387,6 +395,23 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event .status-disabled, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); } .placeholder { color: var(--color-text-secondary); font-style: italic; } + .detail-state { + display: grid; + gap: 0.75rem; + max-width: 720px; + margin: 0 auto; + padding: 2rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + .detail-state h2, + .detail-state p { + margin: 0; + } + .detail-state p { + color: var(--color-text-secondary); + } .loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); } `] }) @@ -394,12 +419,17 @@ export class IntegrationDetailComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly integrationService = inject(IntegrationService); + private readonly cdr = inject(ChangeDetectorRef); + private readonly zone = inject(NgZone); + private readonly requestTimeoutMs = 12_000; private readonly svgAttrs = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; readonly successIconSvg = ``; readonly failureIconSvg = ``; integration?: Integration; + loading = true; + loadErrorMessage: string | null = null; activeTab: IntegrationDetailTab = 'overview'; testing = false; checking = false; @@ -420,16 +450,38 @@ export class IntegrationDetailComponent implements OnInit { const integrationId = this.route.snapshot.paramMap.get('integrationId'); if (integrationId) { this.loadIntegration(integrationId); + return; } + + this.commitUiUpdate(() => { + this.loading = false; + this.loadErrorMessage = 'No integration identifier was provided for this route.'; + }); } private loadIntegration(id: string): void { - this.integrationService.get(id).subscribe({ + this.commitUiUpdate(() => { + this.loading = true; + this.loadErrorMessage = null; + }); + + this.integrationService.get(id).pipe( + timeout({ first: this.requestTimeoutMs }), + ).subscribe({ next: (integration) => { - this.integration = integration; + this.commitUiUpdate(() => { + this.integration = integration; + this.loadErrorMessage = null; + this.loading = false; + }); }, error: (err) => { console.error('Failed to load integration:', err); + this.commitUiUpdate(() => { + this.integration = undefined; + this.loading = false; + this.loadErrorMessage = this.resolveLoadErrorMessage(err); + }); }, }); } @@ -437,15 +489,21 @@ export class IntegrationDetailComponent implements OnInit { testConnection(): void { if (!this.integration) return; this.testing = true; - this.integrationService.testConnection(this.integration.integrationId).subscribe({ + this.integrationService.testConnection(this.integration.integrationId).pipe( + timeout({ first: this.requestTimeoutMs }), + ).subscribe({ next: (result) => { - this.lastTestResult = result; - this.testing = false; + this.commitUiUpdate(() => { + this.lastTestResult = result; + this.testing = false; + }); this.loadIntegration(this.integration!.integrationId); }, error: (err) => { console.error('Test connection failed:', err); - this.testing = false; + this.commitUiUpdate(() => { + this.testing = false; + }); }, }); } @@ -453,15 +511,21 @@ export class IntegrationDetailComponent implements OnInit { checkHealth(): void { if (!this.integration) return; this.checking = true; - this.integrationService.getHealth(this.integration.integrationId).subscribe({ + this.integrationService.getHealth(this.integration.integrationId).pipe( + timeout({ first: this.requestTimeoutMs }), + ).subscribe({ next: (result) => { - this.lastHealthResult = result; - this.checking = false; + this.commitUiUpdate(() => { + this.lastHealthResult = result; + this.checking = false; + }); this.loadIntegration(this.integration!.integrationId); }, error: (err) => { console.error('Health check failed:', err); - this.checking = false; + this.commitUiUpdate(() => { + this.checking = false; + }); }, }); } @@ -487,9 +551,13 @@ export class IntegrationDetailComponent implements OnInit { return tags ? tags.split(',').map(t => t.trim()).filter(t => t) : []; } + integrationHubRoute(): string[] { + return this.integrationCommands(); + } + editIntegration(): void { if (!this.integration) return; - void this.router.navigate(['/platform/integrations', this.integration.integrationId], { + void this.router.navigate(this.integrationCommands(this.integration.integrationId), { queryParams: { edit: '1' }, queryParamsHandling: 'merge', }); @@ -500,7 +568,7 @@ export class IntegrationDetailComponent implements OnInit { if (confirm('Are you sure you want to delete this integration?')) { this.integrationService.delete(this.integration.integrationId).subscribe({ next: () => { - void this.router.navigate(['/platform/integrations']); + void this.router.navigate(this.integrationCommands()); }, error: (err) => { alert('Failed to delete integration: ' + err.message); @@ -508,4 +576,30 @@ export class IntegrationDetailComponent implements OnInit { }); } } + + private integrationCommands(...segments: string[]): string[] { + return integrationWorkspaceCommands(this.router.url, ...segments); + } + + private resolveLoadErrorMessage(err: unknown): string { + const status = typeof err === 'object' && err && 'status' in err ? (err as { status?: number }).status : undefined; + const name = typeof err === 'object' && err && 'name' in err ? (err as { name?: string }).name : undefined; + + if (status === 404) { + return 'Integration not found. It may have been removed or never existed in this workspace.'; + } + + if (name === 'TimeoutError') { + return 'Integration details timed out before the service responded. Retry the request or verify the gateway path.'; + } + + return 'Failed to load integration details. Check the service connection and try again.'; + } + + private commitUiUpdate(update: () => void): void { + this.zone.run(() => { + update(); + this.cdr.detectChanges(); + }); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts index de1bf1c27..d48791be2 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone, inject } from '@angular/core'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { IntegrationService } from './integration.service'; @@ -8,7 +8,6 @@ import { IntegrationType } from './integration.models'; selector: 'app-integration-hub', standalone: true, imports: [RouterModule], - changeDetection: ChangeDetectionStrategy.OnPush, template: `