Fix setup integration navigation and failure states

This commit is contained in:
master
2026-03-07 02:45:54 +02:00
parent b7cfdbd553
commit 5e15ab15b1
9 changed files with 563 additions and 61 deletions

View File

@@ -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 | 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 | 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 | 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 ## Decisions & Risks
- Decision: this sprint stays inside `src/Web/StellaOps.Web` plus required sprint/doc updates only. - 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: 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: 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: 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. - 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. - 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. - 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. - 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 ## Next Checkpoints
- 2026-03-06: Complete first fresh Playwright route/action sweep and defect list. - 2026-03-06: Complete first fresh Playwright route/action sweep and defect list.

View File

@@ -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 { FormsModule } from '@angular/forms';
import { Subject, interval, takeUntil } from 'rxjs'; import { Subject, interval, takeUntil } from 'rxjs';
import { integrationWorkspaceCommands } from './integration-route-context';
/** /**
* Integration activity timeline component. * Integration activity timeline component.
@@ -44,7 +45,7 @@ export type ActivityEventType =
template: ` template: `
<div class="integration-activity"> <div class="integration-activity">
<header class="activity-header"> <header class="activity-header">
<a routerLink="/platform/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a> <a [routerLink]="integrationHubRoute()" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<h1>Integration Activity</h1> <h1>Integration Activity</h1>
<p class="subtitle">Audit trail for all integration lifecycle events</p> <p class="subtitle">Audit trail for all integration lifecycle events</p>
</header> </header>
@@ -127,7 +128,7 @@ export type ActivityEventType =
<span class="event-timestamp">{{ formatTimestamp(event.timestamp) }}</span> <span class="event-timestamp">{{ formatTimestamp(event.timestamp) }}</span>
</div> </div>
<div class="event-title"> <div class="event-title">
<a [routerLink]="['/platform/integrations', event.integrationId]" class="integration-link"> <a [routerLink]="integrationDetailRoute(event.integrationId)" class="integration-link">
{{ event.integrationName }} {{ event.integrationName }}
</a> </a>
<span class="provider-badge">{{ event.integrationProvider }}</span> <span class="provider-badge">{{ event.integrationProvider }}</span>
@@ -348,6 +349,7 @@ export type ActivityEventType =
`] `]
}) })
export class IntegrationActivityComponent implements OnInit, OnDestroy { export class IntegrationActivityComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
// Filters // Filters
@@ -363,6 +365,14 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy {
loading = false; loading = false;
hasMore = false; hasMore = false;
integrationHubRoute(): string[] {
return this.integrationCommands();
}
integrationDetailRoute(integrationId: string): string[] {
return this.integrationCommands(integrationId);
}
// Mock data for development // Mock data for development
private mockEvents: ActivityEvent[] = [ private mockEvents: ActivityEvent[] = [
{ {
@@ -442,6 +452,10 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
private integrationCommands(...segments: string[]): string[] {
return integrationWorkspaceCommands(this.router.url, ...segments);
}
loadEvents(): void { loadEvents(): void {
// In production, fetch from API // In production, fetch from API
this.events = [...this.mockEvents]; this.events = [...this.mockEvents];

View File

@@ -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 { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { timeout } from 'rxjs';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { integrationWorkspaceCommands } from './integration-route-context';
import { import {
Integration, Integration,
IntegrationHealthResponse, IntegrationHealthResponse,
@@ -23,10 +25,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
selector: 'app-integration-detail', selector: 'app-integration-detail',
imports: [CommonModule, RouterModule], imports: [CommonModule, RouterModule],
template: ` template: `
@if (integration) { @if (loading) {
<div class="loading">Loading integration details...</div>
} @else if (integration) {
<div class="integration-detail"> <div class="integration-detail">
<header class="detail-header"> <header class="detail-header">
<a routerLink="/platform/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a> <a [routerLink]="integrationHubRoute()" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<h1>{{ integration.name }}</h1> <h1>{{ integration.name }}</h1>
<span [class]="'status-badge status-' + getStatusColor(integration.status)"> <span [class]="'status-badge status-' + getStatusColor(integration.status)">
{{ getStatusLabel(integration.status) }} {{ getStatusLabel(integration.status) }}
@@ -188,7 +192,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
</section> </section>
</div> </div>
} @else { } @else {
<div class="loading">Loading integration details...</div> <section class="detail-state" role="status">
<a [routerLink]="integrationHubRoute()" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<h2>Integration unavailable</h2>
<p>{{ loadErrorMessage || 'The requested integration could not be loaded.' }}</p>
</section>
} }
`, `,
@@ -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); } .status-disabled, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); }
.placeholder { color: var(--color-text-secondary); font-style: italic; } .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); } .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 route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly integrationService = inject(IntegrationService); 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"'; 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 = `<svg ${this.svgAttrs}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`; readonly successIconSvg = `<svg ${this.svgAttrs}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`;
readonly failureIconSvg = `<svg ${this.svgAttrs}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`; readonly failureIconSvg = `<svg ${this.svgAttrs}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
integration?: Integration; integration?: Integration;
loading = true;
loadErrorMessage: string | null = null;
activeTab: IntegrationDetailTab = 'overview'; activeTab: IntegrationDetailTab = 'overview';
testing = false; testing = false;
checking = false; checking = false;
@@ -420,16 +450,38 @@ export class IntegrationDetailComponent implements OnInit {
const integrationId = this.route.snapshot.paramMap.get('integrationId'); const integrationId = this.route.snapshot.paramMap.get('integrationId');
if (integrationId) { if (integrationId) {
this.loadIntegration(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 { 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) => { next: (integration) => {
this.integration = integration; this.commitUiUpdate(() => {
this.integration = integration;
this.loadErrorMessage = null;
this.loading = false;
});
}, },
error: (err) => { error: (err) => {
console.error('Failed to load integration:', 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 { testConnection(): void {
if (!this.integration) return; if (!this.integration) return;
this.testing = true; this.testing = true;
this.integrationService.testConnection(this.integration.integrationId).subscribe({ this.integrationService.testConnection(this.integration.integrationId).pipe(
timeout({ first: this.requestTimeoutMs }),
).subscribe({
next: (result) => { next: (result) => {
this.lastTestResult = result; this.commitUiUpdate(() => {
this.testing = false; this.lastTestResult = result;
this.testing = false;
});
this.loadIntegration(this.integration!.integrationId); this.loadIntegration(this.integration!.integrationId);
}, },
error: (err) => { error: (err) => {
console.error('Test connection failed:', 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 { checkHealth(): void {
if (!this.integration) return; if (!this.integration) return;
this.checking = true; this.checking = true;
this.integrationService.getHealth(this.integration.integrationId).subscribe({ this.integrationService.getHealth(this.integration.integrationId).pipe(
timeout({ first: this.requestTimeoutMs }),
).subscribe({
next: (result) => { next: (result) => {
this.lastHealthResult = result; this.commitUiUpdate(() => {
this.checking = false; this.lastHealthResult = result;
this.checking = false;
});
this.loadIntegration(this.integration!.integrationId); this.loadIntegration(this.integration!.integrationId);
}, },
error: (err) => { error: (err) => {
console.error('Health check failed:', 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) : []; return tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [];
} }
integrationHubRoute(): string[] {
return this.integrationCommands();
}
editIntegration(): void { editIntegration(): void {
if (!this.integration) return; 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' }, queryParams: { edit: '1' },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
}); });
@@ -500,7 +568,7 @@ export class IntegrationDetailComponent implements OnInit {
if (confirm('Are you sure you want to delete this integration?')) { if (confirm('Are you sure you want to delete this integration?')) {
this.integrationService.delete(this.integration.integrationId).subscribe({ this.integrationService.delete(this.integration.integrationId).subscribe({
next: () => { next: () => {
void this.router.navigate(['/platform/integrations']); void this.router.navigate(this.integrationCommands());
}, },
error: (err) => { error: (err) => {
alert('Failed to delete integration: ' + err.message); 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();
});
}
} }

View File

@@ -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 { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
@@ -8,7 +8,6 @@ import { IntegrationType } from './integration.models';
selector: 'app-integration-hub', selector: 'app-integration-hub',
standalone: true, standalone: true,
imports: [RouterModule], imports: [RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="integration-hub"> <section class="integration-hub">
<nav class="tiles"> <nav class="tiles">
@@ -28,11 +27,11 @@ import { IntegrationType } from './integration.models';
<span>Runtimes / Hosts</span> <span>Runtimes / Hosts</span>
<strong>{{ stats.runtimeHosts }}</strong> <strong>{{ stats.runtimeHosts }}</strong>
</a> </a>
<a routerLink="feeds" class="tile"> <a routerLink="advisory-vex-sources" class="tile">
<span>Advisory Sources</span> <span>Advisory Sources</span>
<strong>{{ stats.advisorySources }}</strong> <strong>{{ stats.advisorySources }}</strong>
</a> </a>
<a routerLink="vex-sources" class="tile"> <a routerLink="advisory-vex-sources" class="tile">
<span>VEX Sources</span> <span>VEX Sources</span>
<strong>{{ stats.vexSources }}</strong> <strong>{{ stats.vexSources }}</strong>
</a> </a>
@@ -141,6 +140,8 @@ export class IntegrationHubComponent {
private readonly integrationService = inject(IntegrationService); private readonly integrationService = inject(IntegrationService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly cdr = inject(ChangeDetectorRef);
private readonly zone = inject(NgZone);
stats = { stats = {
registries: 0, registries: 0,
@@ -158,34 +159,44 @@ export class IntegrationHubComponent {
private loadStats(): void { private loadStats(): void {
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
next: (res) => (this.stats.registries = res.totalCount), next: (res) => this.commitUiUpdate(() => (this.stats.registries = res.totalCount)),
error: () => (this.stats.registries = 0), error: () => this.commitUiUpdate(() => (this.stats.registries = 0)),
}); });
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
next: (res) => (this.stats.scm = res.totalCount), next: (res) => this.commitUiUpdate(() => (this.stats.scm = res.totalCount)),
error: () => (this.stats.scm = 0), error: () => this.commitUiUpdate(() => (this.stats.scm = 0)),
}); });
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
next: (res) => (this.stats.ci = res.totalCount), next: (res) => this.commitUiUpdate(() => (this.stats.ci = res.totalCount)),
error: () => (this.stats.ci = 0), error: () => this.commitUiUpdate(() => (this.stats.ci = 0)),
}); });
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
next: (res) => (this.stats.runtimeHosts = res.totalCount), next: (res) => this.commitUiUpdate(() => (this.stats.runtimeHosts = res.totalCount)),
error: () => (this.stats.runtimeHosts = 0), error: () => this.commitUiUpdate(() => (this.stats.runtimeHosts = 0)),
}); });
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
next: (res) => { next: (res) => {
this.stats.advisorySources = res.totalCount; this.commitUiUpdate(() => {
this.stats.vexSources = res.totalCount; this.stats.advisorySources = res.totalCount;
}, this.stats.vexSources = res.totalCount;
error: () => { });
this.stats.advisorySources = 0;
this.stats.vexSources = 0;
}, },
error: () =>
this.commitUiUpdate(() => {
this.stats.advisorySources = 0;
this.stats.vexSources = 0;
}),
}); });
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({ this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
next: (res) => (this.stats.secrets = res.totalCount), next: (res) => this.commitUiUpdate(() => (this.stats.secrets = res.totalCount)),
error: () => (this.stats.secrets = 0), error: () => this.commitUiUpdate(() => (this.stats.secrets = 0)),
});
}
private commitUiUpdate(update: () => void): void {
this.zone.run(() => {
update();
this.cdr.detectChanges();
}); });
} }

View File

@@ -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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { timeout } from 'rxjs';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { integrationWorkspaceCommands } from './integration-route-context';
import { import {
Integration, Integration,
IntegrationType, IntegrationType,
@@ -24,7 +26,7 @@ import {
<div class="integration-list"> <div class="integration-list">
<header class="list-header"> <header class="list-header">
<h1>{{ typeLabel }} Integrations</h1> <h1>{{ typeLabel }} Integrations</h1>
<button class="btn-primary" (click)="addIntegration()">+ Add {{ typeLabel }}</button> <button class="btn-primary" (click)="addIntegration()">{{ addActionLabel() }}</button>
</header> </header>
<section class="filters"> <section class="filters">
@@ -49,10 +51,18 @@ import {
@if (loading) { @if (loading) {
<div class="loading">Loading integrations...</div> <div class="loading">Loading integrations...</div>
} @else if (loadErrorMessage) {
<div class="error-state" role="status">
<p>{{ loadErrorMessage }}</p>
<div class="error-actions">
<button class="btn-primary" (click)="loadIntegrations()">Retry</button>
<button class="btn-secondary" (click)="addIntegration()">{{ addActionLabel() }}</button>
</div>
</div>
} @else if (integrations.length === 0) { } @else if (integrations.length === 0) {
<div class="empty-state"> <div class="empty-state">
<p>No {{ typeLabel.toLowerCase() }} integrations found.</p> <p>No {{ typeLabel.toLowerCase() }} integrations found.</p>
<button class="btn-primary" (click)="addIntegration()">Add your first {{ typeLabel.toLowerCase() }}</button> <button class="btn-primary" (click)="addIntegration()">{{ emptyStateActionLabel() }}</button>
</div> </div>
} @else { } @else {
<table class="integration-table"> <table class="integration-table">
@@ -70,7 +80,7 @@ import {
@for (integration of integrations; track integration.integrationId) { @for (integration of integrations; track integration.integrationId) {
<tr> <tr>
<td> <td>
<a [routerLink]="['/platform/integrations', integration.integrationId]">{{ integration.name }}</a> <a [routerLink]="integrationDetailRoute(integration.integrationId)">{{ integration.name }}</a>
</td> </td>
<td>{{ getProviderName(integration.provider) }}</td> <td>{{ getProviderName(integration.provider) }}</td>
<td> <td>
@@ -88,7 +98,7 @@ import {
<button (click)="editIntegration(integration)" title="Edit"><svg 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"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button> <button (click)="editIntegration(integration)" title="Edit"><svg 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"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
<button (click)="testConnection(integration)" title="Test Connection"><svg 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"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button> <button (click)="testConnection(integration)" title="Test Connection"><svg 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"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
<button (click)="checkHealth(integration)" title="Check Health"><svg 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"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button> <button (click)="checkHealth(integration)" title="Check Health"><svg 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"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
<a [routerLink]="['/platform/integrations', integration.integrationId]" title="View Details"><svg 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"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a> <a [routerLink]="integrationDetailRoute(integration.integrationId)" title="View Details"><svg 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"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
</td> </td>
</tr> </tr>
} }
@@ -235,6 +245,27 @@ import {
color: var(--color-text-secondary); 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 { .btn-primary {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--color-brand-primary); background: var(--color-brand-primary);
@@ -244,12 +275,24 @@ import {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; 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 { export class IntegrationListComponent implements OnInit {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly integrationService = inject(IntegrationService); private readonly integrationService = inject(IntegrationService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly zone = inject(NgZone);
protected readonly IntegrationStatus = IntegrationStatus; protected readonly IntegrationStatus = IntegrationStatus;
@@ -262,6 +305,7 @@ export class IntegrationListComponent implements OnInit {
pageSize = 20; pageSize = 20;
totalCount = 0; totalCount = 0;
totalPages = 1; totalPages = 1;
loadErrorMessage: string | null = null;
private integrationType?: IntegrationType; private integrationType?: IntegrationType;
@@ -276,22 +320,36 @@ export class IntegrationListComponent implements OnInit {
loadIntegrations(): void { loadIntegrations(): void {
this.loading = true; this.loading = true;
this.loadErrorMessage = null;
this.integrationService.list({ this.integrationService.list({
type: this.integrationType, type: this.integrationType,
status: this.filterStatus, status: this.filterStatus,
search: this.searchQuery || undefined, search: this.searchQuery || undefined,
page: this.page, page: this.page,
pageSize: this.pageSize, pageSize: this.pageSize,
}).subscribe({ }).pipe(
timeout({ first: 12_000 }),
).subscribe({
next: (response) => { next: (response) => {
this.integrations = response.items; this.commitUiUpdate(() => {
this.totalCount = response.totalCount; this.integrations = response.items;
this.totalPages = Math.ceil(response.totalCount / this.pageSize) || 1; this.totalCount = response.totalCount;
this.loading = false; this.totalPages = Math.ceil(response.totalCount / this.pageSize) || 1;
this.loadErrorMessage = null;
this.loading = false;
});
}, },
error: (err) => { error: (err) => {
console.error('Failed to load integrations:', 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); 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 { 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 { addIntegration(): void {
void this.router.navigate( const commands = this.supportsTypedOnboarding()
['/platform/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)] ? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
); : this.integrationCommands('onboarding');
void this.router.navigate(commands);
} }
private parseType(typeStr: string): IntegrationType | undefined { private parseType(typeStr: string): IntegrationType | undefined {
@@ -374,4 +451,24 @@ export class IntegrationListComponent implements OnInit {
return 'registry'; 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();
});
}
} }

View File

@@ -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];
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-nav.component'; 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', selector: 'app-integration-shell',
standalone: true, standalone: true,
imports: [RouterOutlet, TabbedNavComponent], imports: [RouterOutlet, TabbedNavComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="integration-shell"> <section class="integration-shell">
<header class="integration-shell__header"> <header class="integration-shell__header">

View File

@@ -2,6 +2,7 @@
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { IntegrationWizardComponent } from './integration-wizard.component'; import { IntegrationWizardComponent } from './integration-wizard.component';
import { integrationWorkspaceCommands } from '../integration-hub/integration-route-context';
import { import {
IntegrationType, IntegrationType,
IntegrationDraft, IntegrationDraft,
@@ -207,17 +208,17 @@ export class IntegrationsHubComponent implements OnInit {
openWizard(type: IntegrationType): void { openWizard(type: IntegrationType): void {
this.activeWizard.set(type); this.activeWizard.set(type);
void this.router.navigate(['/integrations/onboarding', type]); void this.router.navigate(this.integrationCommands('onboarding', type));
} }
closeWizard(): void { closeWizard(): void {
this.activeWizard.set(null); this.activeWizard.set(null);
void this.router.navigate(['/integrations/onboarding']); void this.router.navigate(this.integrationCommands('onboarding'));
} }
onIntegrationCreated(draft: IntegrationDraft): void { onIntegrationCreated(draft: IntegrationDraft): void {
this.closeWizard(); this.closeWizard();
void this.router.navigate(['/integrations', this.getIntegrationListPath(draft.type)]); void this.router.navigate(this.integrationCommands(this.getIntegrationListPath(draft.type)));
} }
private parseWizardType(type: string | null): IntegrationType | null { private parseWizardType(type: string | null): IntegrationType | null {
@@ -242,10 +243,14 @@ export class IntegrationsHubComponent implements OnInit {
case 'ci': case 'ci':
return 'ci'; return 'ci';
case 'host': case 'host':
return 'hosts'; return 'runtime-hosts';
case 'registry': case 'registry':
default: default:
return 'registries'; return 'registries';
} }
} }
private integrationCommands(...segments: string[]): string[] {
return integrationWorkspaceCommands(this.router.url, ...segments);
}
} }

View File

@@ -158,6 +158,9 @@ const canonicalRoutes = [
'/ops/platform-setup/defaults-guardrails', '/ops/platform-setup/defaults-guardrails',
'/ops/platform-setup/trust-signing', '/ops/platform-setup/trust-signing',
'/setup', '/setup',
'/setup/integrations',
'/setup/integrations/advisory-vex-sources',
'/setup/integrations/secrets',
'/setup/identity-access', '/setup/identity-access',
'/setup/tenant-branding', '/setup/tenant-branding',
'/setup/notifications', '/setup/notifications',
@@ -196,6 +199,18 @@ const strictRouteExpectations: Partial<Record<(typeof canonicalRoutes)[number],
title: /Trust/i, title: /Trust/i,
texts: ['Setup', 'Trust Management'], texts: ['Setup', 'Trust Management'],
}, },
'/setup/integrations': {
title: /Integrations/i,
texts: ['Integrations', 'External system connectors'],
},
'/setup/integrations/advisory-vex-sources': {
title: /Advisory & VEX Sources/i,
texts: ['Integrations', 'FeedMirror Integrations'],
},
'/setup/integrations/secrets': {
title: /Secrets/i,
texts: ['Integrations', 'RepoSource Integrations'],
},
'/ops/policy': { '/ops/policy': {
title: /Policy/i, title: /Policy/i,
texts: ['Policy Governance', 'Risk Budget Overview'], texts: ['Policy Governance', 'Risk Budget Overview'],
@@ -210,6 +225,69 @@ const strictRouteExpectations: Partial<Record<(typeof canonicalRoutes)[number],
}, },
}; };
const integrationFixtures = [
{
integrationId: 'int-reg-1',
tenantId: adminSession.tenant,
name: 'Harbor Registry',
description: 'Primary registry connector.',
type: 1,
provider: 101,
status: 2,
baseUrl: 'https://registry.example.test',
authRef: 'auth-reg-1',
createdAt: '2026-03-01T08:00:00.000Z',
createdBy: 'qa-user',
modifiedAt: '2026-03-02T08:00:00.000Z',
modifiedBy: 'qa-user',
lastTestedAt: '2026-03-03T08:00:00.000Z',
lastTestSuccess: true,
paused: false,
consecutiveFailures: 0,
version: 1,
},
{
integrationId: 'int-feed-1',
tenantId: adminSession.tenant,
name: 'Concelier Mirror',
description: 'Advisory and VEX feed mirror.',
type: 6,
provider: 500,
status: 2,
baseUrl: 'https://feeds.example.test',
authRef: 'auth-feed-1',
createdAt: '2026-03-01T08:00:00.000Z',
createdBy: 'qa-user',
modifiedAt: '2026-03-02T08:00:00.000Z',
modifiedBy: 'qa-user',
lastTestedAt: '2026-03-03T08:00:00.000Z',
lastTestSuccess: true,
paused: false,
consecutiveFailures: 0,
version: 1,
},
{
integrationId: 'int-secret-1',
tenantId: adminSession.tenant,
name: 'Vault Secrets Broker',
description: 'Secrets and repository source connector.',
type: 4,
provider: 600,
status: 2,
baseUrl: 'https://vault.example.test',
authRef: 'auth-secret-1',
createdAt: '2026-03-01T08:00:00.000Z',
createdBy: 'qa-user',
modifiedAt: '2026-03-02T08:00:00.000Z',
modifiedBy: 'qa-user',
lastTestedAt: '2026-03-03T08:00:00.000Z',
lastTestSuccess: true,
paused: false,
consecutiveFailures: 0,
version: 1,
},
] as const;
function collectNgErrors(page: Page): string[] { function collectNgErrors(page: Page): string[] {
const errors: string[] = []; const errors: string[] = [];
page.on('console', (msg) => { page.on('console', (msg) => {
@@ -891,6 +969,125 @@ async function setupHarness(page: Page): Promise<void> {
}), }),
}); });
}); });
await page.route('**/api/v1/integrations**', (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.endsWith('/api/v1/integrations')) {
return route.fallback();
}
if (request.method() !== 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(integrationFixtures[0]),
});
}
const requestUrl = new URL(request.url());
const typeFilter = requestUrl.searchParams.get('type');
const statusFilter = requestUrl.searchParams.get('status');
const searchFilter = requestUrl.searchParams.get('search')?.toLowerCase() ?? '';
const pageNumber = Number(requestUrl.searchParams.get('page') ?? '1');
const pageSize = Number(requestUrl.searchParams.get('pageSize') ?? '20');
const items = integrationFixtures.filter((integration) => {
if (typeFilter && integration.type !== Number(typeFilter)) {
return false;
}
if (statusFilter && integration.status !== Number(statusFilter)) {
return false;
}
if (
searchFilter &&
!`${integration.name} ${integration.description ?? ''}`.toLowerCase().includes(searchFilter)
) {
return false;
}
return true;
});
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: items.slice((pageNumber - 1) * pageSize, pageNumber * pageSize),
totalCount: items.length,
page: pageNumber,
pageSize,
hasMore: pageNumber * pageSize < items.length,
}),
});
});
await page.route('**/api/v1/integrations/**', (route) => {
const request = route.request();
const pathParts = new URL(request.url()).pathname.split('/').filter(Boolean);
const integrationsIndex = pathParts.indexOf('integrations');
const resourceParts = pathParts.slice(integrationsIndex + 1);
const integrationId = resourceParts[0];
const action = resourceParts[1];
const integration = integrationFixtures.find((candidate) => candidate.integrationId === integrationId);
if (!integration) {
return route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ message: 'Integration not found' }),
});
}
if (action === 'health') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
integrationId,
status: integration.status,
lastTestedAt: integration.lastTestedAt,
lastTestSuccess: integration.lastTestSuccess,
lastSyncAt: integration.modifiedAt,
lastEventAt: integration.modifiedAt,
consecutiveFailures: integration.consecutiveFailures,
uptimePercentage: 99.9,
averageLatencyMs: 42,
}),
});
}
if (action === 'test') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
testedAt: integration.lastTestedAt,
latencyMs: 42,
}),
});
}
if (request.method() === 'DELETE') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
}
if (request.method() !== 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(integration),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(integration),
});
});
await page.route('**/policy/api/risk/profiles**', (route) => { await page.route('**/policy/api/risk/profiles**', (route) => {
if (route.request().method() !== 'GET') { if (route.request().method() !== 'GET') {
return route.fulfill({ return route.fulfill({
@@ -1223,6 +1420,7 @@ async function setupHarness(page: Page): Promise<void> {
requestUrl.includes('/api/v2/context/') || requestUrl.includes('/api/v2/context/') ||
requestUrl.includes('/api/v2/security/sbom-explorer') || requestUrl.includes('/api/v2/security/sbom-explorer') ||
requestUrl.includes('/policy/api/') || requestUrl.includes('/policy/api/') ||
requestUrl.includes('/api/v1/integrations') ||
requestUrl.includes('/api/v1/trust/') || requestUrl.includes('/api/v1/trust/') ||
requestUrl.includes('/api/v1/audit/') || requestUrl.includes('/api/v1/audit/') ||
requestUrl.includes('/api/v1/authority/quotas') || requestUrl.includes('/api/v1/authority/quotas') ||
@@ -1422,6 +1620,68 @@ test.describe('Pre-alpha key end-user interactions', () => {
await expect(page.locator('#main-content')).toContainText('Trust Management'); await expect(page.locator('#main-content')).toContainText('Trust Management');
}); });
test('setup integrations advisory tile stays on canonical combined source route', async ({ page }) => {
await page.goto('/setup/integrations', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#main-content')).toContainText('External system connectors');
await page.locator('#main-content a[href="/setup/integrations/advisory-vex-sources"]').first().click();
await expect(page).toHaveURL(/\/setup\/integrations\/advisory-vex-sources$/);
await expect(page.locator('#main-content')).toContainText('FeedMirror Integrations');
});
test('setup integrations unsupported add action opens setup onboarding hub', async ({ page }) => {
await page.goto('/setup/integrations/secrets', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#main-content')).toContainText('RepoSource Integrations');
const addButton = page.locator('#main-content button:has-text("+ Add Integration")').first();
await expect(addButton).toBeVisible();
await addButton.click();
await expect(page).toHaveURL(/\/setup\/integrations\/onboarding$/);
await expect(page.locator('#main-content')).toContainText('Container Registries');
});
test('setup integration detail and back-link stay under setup routes', async ({ page }) => {
await page.goto('/setup/integrations/int-secret-1', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/setup\/integrations\/int-secret-1$/);
await expect(page.locator('#main-content')).toContainText('Vault Secrets Broker');
await page.locator('#main-content a:has-text("Back to Integrations")').first().click();
await expect(page).toHaveURL(/\/setup\/integrations$/);
});
test('setup host onboarding returns to runtime-hosts list after create', async ({ page }) => {
await page.goto('/setup/integrations/onboarding/host', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#main-content')).toContainText('Select Host Provider');
await page.getByRole('button', { name: /kubernetes/i }).click();
await page.getByRole('button', { name: /^next$/i }).click();
await page.locator('label[for="auth-offline"]').click();
await page.getByRole('button', { name: /^next$/i }).click();
await page.locator('#namespaces').fill('production');
await page.getByRole('button', { name: /^next$/i }).click();
await page.getByRole('button', { name: /^next$/i }).click();
await page.getByRole('button', { name: /^next$/i }).click();
await page.locator('#name').fill('QA Host');
await page.getByRole('button', { name: /create integration/i }).click();
await expect(page).toHaveURL(/\/setup\/integrations\/runtime-hosts$/);
await expect(page.locator('#main-content')).toContainText('RuntimeHost Integrations');
await expect(page.locator('#main-content')).not.toContainText('Loading integration details...');
});
test('setup integration detail 404 renders an explicit error state', async ({ page }) => {
await page.goto('/setup/integrations/activity', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#main-content')).toContainText('Integration Activity');
await page.locator('#main-content .integration-link').first().click();
await expect(page).toHaveURL(/\/setup\/integrations\/int-1$/);
await expect(page.locator('#main-content')).toContainText('Integration unavailable');
await expect(page.locator('#main-content')).toContainText('Integration not found.');
await expect(page.locator('#main-content')).not.toContainText('Loading integration details...');
});
test('sidebar root navigation works for all canonical workspaces', async ({ page }) => { test('sidebar root navigation works for all canonical workspaces', async ({ page }) => {
await page.goto('/mission-control/board', { waitUntil: 'domcontentloaded' }); await page.goto('/mission-control/board', { waitUntil: 'domcontentloaded' });
await page.locator('aside.sidebar a[href="/releases/overview"]').first().click(); await page.locator('aside.sidebar a[href="/releases/overview"]').first().click();