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: `
@@ -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) {
} @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: `
@@ -28,11 +27,11 @@ import { IntegrationType } from './integration.models';
Runtimes / Hosts
{{ stats.runtimeHosts }}
-
+
Advisory Sources
{{ stats.advisorySources }}
-
+
VEX Sources
{{ stats.vexSources }}
@@ -141,6 +140,8 @@ export class IntegrationHubComponent {
private readonly integrationService = inject(IntegrationService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
+ private readonly cdr = inject(ChangeDetectorRef);
+ private readonly zone = inject(NgZone);
stats = {
registries: 0,
@@ -158,34 +159,44 @@ export class IntegrationHubComponent {
private loadStats(): void {
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
- next: (res) => (this.stats.registries = res.totalCount),
- error: () => (this.stats.registries = 0),
+ next: (res) => this.commitUiUpdate(() => (this.stats.registries = res.totalCount)),
+ error: () => this.commitUiUpdate(() => (this.stats.registries = 0)),
});
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
- next: (res) => (this.stats.scm = res.totalCount),
- error: () => (this.stats.scm = 0),
+ next: (res) => this.commitUiUpdate(() => (this.stats.scm = res.totalCount)),
+ error: () => this.commitUiUpdate(() => (this.stats.scm = 0)),
});
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
- next: (res) => (this.stats.ci = res.totalCount),
- error: () => (this.stats.ci = 0),
+ next: (res) => this.commitUiUpdate(() => (this.stats.ci = res.totalCount)),
+ error: () => this.commitUiUpdate(() => (this.stats.ci = 0)),
});
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
- next: (res) => (this.stats.runtimeHosts = res.totalCount),
- error: () => (this.stats.runtimeHosts = 0),
+ next: (res) => this.commitUiUpdate(() => (this.stats.runtimeHosts = res.totalCount)),
+ error: () => this.commitUiUpdate(() => (this.stats.runtimeHosts = 0)),
});
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
next: (res) => {
- this.stats.advisorySources = res.totalCount;
- this.stats.vexSources = res.totalCount;
- },
- error: () => {
- this.stats.advisorySources = 0;
- this.stats.vexSources = 0;
+ this.commitUiUpdate(() => {
+ this.stats.advisorySources = res.totalCount;
+ this.stats.vexSources = res.totalCount;
+ });
},
+ error: () =>
+ this.commitUiUpdate(() => {
+ this.stats.advisorySources = 0;
+ this.stats.vexSources = 0;
+ }),
});
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
- next: (res) => (this.stats.secrets = res.totalCount),
- error: () => (this.stats.secrets = 0),
+ next: (res) => this.commitUiUpdate(() => (this.stats.secrets = res.totalCount)),
+ error: () => this.commitUiUpdate(() => (this.stats.secrets = 0)),
+ });
+ }
+
+ 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-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts
index c765b5d99..05797b02c 100644
--- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts
@@ -1,9 +1,11 @@
-import { Component, inject, OnInit } from '@angular/core';
+import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
+import { timeout } from 'rxjs';
import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
+import { integrationWorkspaceCommands } from './integration-route-context';
import {
Integration,
IntegrationType,
@@ -24,7 +26,7 @@ import {
@@ -49,10 +51,18 @@ import {
@if (loading) {
Loading integrations...
+ } @else if (loadErrorMessage) {
+
+
{{ loadErrorMessage }}
+
+ Retry
+ {{ addActionLabel() }}
+
+
} @else if (integrations.length === 0) {
No {{ typeLabel.toLowerCase() }} integrations found.
-
Add your first {{ typeLabel.toLowerCase() }}
+
{{ emptyStateActionLabel() }}
} @else {
@@ -70,7 +80,7 @@ import {
@for (integration of integrations; track integration.integrationId) {
- {{ integration.name }}
+ {{ integration.name }}
{{ getProviderName(integration.provider) }}
@@ -88,7 +98,7 @@ import {
-
+
}
@@ -235,6 +245,27 @@ import {
color: var(--color-text-secondary);
}
+ .error-state {
+ display: grid;
+ gap: 0.75rem;
+ justify-items: center;
+ text-align: center;
+ padding: 3rem;
+ color: var(--color-text-secondary);
+ }
+
+ .error-state p {
+ margin: 0;
+ max-width: 40rem;
+ }
+
+ .error-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--color-brand-primary);
@@ -244,12 +275,24 @@ import {
font-weight: var(--font-weight-medium);
cursor: pointer;
}
+
+ .btn-secondary {
+ padding: 0.75rem 1.5rem;
+ border: 1px solid var(--color-brand-primary);
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--color-brand-primary);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ }
`]
})
export class IntegrationListComponent 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);
protected readonly IntegrationStatus = IntegrationStatus;
@@ -262,6 +305,7 @@ export class IntegrationListComponent implements OnInit {
pageSize = 20;
totalCount = 0;
totalPages = 1;
+ loadErrorMessage: string | null = null;
private integrationType?: IntegrationType;
@@ -276,22 +320,36 @@ export class IntegrationListComponent implements OnInit {
loadIntegrations(): void {
this.loading = true;
+ this.loadErrorMessage = null;
this.integrationService.list({
type: this.integrationType,
status: this.filterStatus,
search: this.searchQuery || undefined,
page: this.page,
pageSize: this.pageSize,
- }).subscribe({
+ }).pipe(
+ timeout({ first: 12_000 }),
+ ).subscribe({
next: (response) => {
- this.integrations = response.items;
- this.totalCount = response.totalCount;
- this.totalPages = Math.ceil(response.totalCount / this.pageSize) || 1;
- this.loading = false;
+ this.commitUiUpdate(() => {
+ this.integrations = response.items;
+ this.totalCount = response.totalCount;
+ this.totalPages = Math.ceil(response.totalCount / this.pageSize) || 1;
+ this.loadErrorMessage = null;
+ this.loading = false;
+ });
},
error: (err) => {
console.error('Failed to load integrations:', err);
- this.loading = false;
+ this.commitUiUpdate(() => {
+ this.integrations = [];
+ this.totalCount = 0;
+ this.totalPages = 1;
+ this.loadErrorMessage = err?.name === 'TimeoutError'
+ ? 'Integration inventory timed out before the service responded. Retry the request or verify the gateway path.'
+ : 'Failed to load integrations. Check the service connection and try again.';
+ this.loading = false;
+ });
},
});
}
@@ -333,14 +391,33 @@ export class IntegrationListComponent implements OnInit {
return getProviderLabel(provider);
}
+ integrationDetailRoute(integrationId: string): string[] {
+ return this.integrationCommands(integrationId);
+ }
+
+ addActionLabel(): string {
+ return this.supportsTypedOnboarding() ? `+ Add ${this.typeLabel}` : '+ Add Integration';
+ }
+
+ emptyStateActionLabel(): string {
+ return this.supportsTypedOnboarding()
+ ? `Add your first ${this.typeLabel.toLowerCase()}`
+ : 'Open add integration hub';
+ }
+
editIntegration(integration: Integration): void {
- void this.router.navigate(['/platform/integrations', integration.integrationId], { queryParams: { edit: true } });
+ void this.router.navigate(this.integrationCommands(integration.integrationId), {
+ queryParams: { edit: true },
+ queryParamsHandling: 'merge',
+ });
}
addIntegration(): void {
- void this.router.navigate(
- ['/platform/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
- );
+ const commands = this.supportsTypedOnboarding()
+ ? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
+ : this.integrationCommands('onboarding');
+
+ void this.router.navigate(commands);
}
private parseType(typeStr: string): IntegrationType | undefined {
@@ -374,4 +451,24 @@ export class IntegrationListComponent implements OnInit {
return 'registry';
}
}
+
+ private supportsTypedOnboarding(): boolean {
+ return (
+ this.integrationType === IntegrationType.Registry ||
+ this.integrationType === IntegrationType.Scm ||
+ this.integrationType === IntegrationType.CiCd ||
+ this.integrationType === IntegrationType.RuntimeHost
+ );
+ }
+
+ private integrationCommands(...segments: string[]): string[] {
+ return integrationWorkspaceCommands(this.router.url, ...segments);
+ }
+
+ 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-route-context.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-route-context.ts
new file mode 100644
index 000000000..56db16dde
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-route-context.ts
@@ -0,0 +1,15 @@
+export type IntegrationWorkspaceRoot = '/ops/integrations' | '/setup/integrations';
+
+export function resolveIntegrationWorkspaceRoot(url: string): IntegrationWorkspaceRoot {
+ const path = url.split('?')[0].split('#')[0];
+ return path.startsWith('/setup/integrations') ? '/setup/integrations' : '/ops/integrations';
+}
+
+export function integrationWorkspaceCommands(url: string, ...segments: string[]): string[] {
+ const cleanedSegments = segments
+ .map((segment) => segment.trim())
+ .filter((segment) => segment.length > 0)
+ .map((segment) => segment.replace(/^\/+|\/+$/g, ''));
+
+ return [resolveIntegrationWorkspaceRoot(url), ...cleanedSegments];
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts
index 3f8dfd797..29fe31f25 100644
--- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-nav.component';
@@ -7,7 +7,6 @@ import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-n
selector: 'app-integration-shell',
standalone: true,
imports: [RouterOutlet, TabbedNavComponent],
- changeDetection: ChangeDetectionStrategy.OnPush,
template: `