Fix setup integration navigation and failure states
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: `
|
||||
<div class="integration-activity">
|
||||
<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>
|
||||
<p class="subtitle">Audit trail for all integration lifecycle events</p>
|
||||
</header>
|
||||
@@ -127,7 +128,7 @@ export type ActivityEventType =
|
||||
<span class="event-timestamp">{{ formatTimestamp(event.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="event-title">
|
||||
<a [routerLink]="['/platform/integrations', event.integrationId]" class="integration-link">
|
||||
<a [routerLink]="integrationDetailRoute(event.integrationId)" class="integration-link">
|
||||
{{ event.integrationName }}
|
||||
</a>
|
||||
<span class="provider-badge">{{ event.integrationProvider }}</span>
|
||||
@@ -348,6 +349,7 @@ export type ActivityEventType =
|
||||
`]
|
||||
})
|
||||
export class IntegrationActivityComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// 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];
|
||||
|
||||
@@ -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) {
|
||||
<div class="loading">Loading integration details...</div>
|
||||
} @else if (integration) {
|
||||
<div class="integration-detail">
|
||||
<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>
|
||||
<span [class]="'status-badge status-' + getStatusColor(integration.status)">
|
||||
{{ getStatusLabel(integration.status) }}
|
||||
@@ -188,7 +192,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
</section>
|
||||
</div>
|
||||
} @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); }
|
||||
|
||||
.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 = `<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>`;
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<section class="integration-hub">
|
||||
<nav class="tiles">
|
||||
@@ -28,11 +27,11 @@ import { IntegrationType } from './integration.models';
|
||||
<span>Runtimes / Hosts</span>
|
||||
<strong>{{ stats.runtimeHosts }}</strong>
|
||||
</a>
|
||||
<a routerLink="feeds" class="tile">
|
||||
<a routerLink="advisory-vex-sources" class="tile">
|
||||
<span>Advisory Sources</span>
|
||||
<strong>{{ stats.advisorySources }}</strong>
|
||||
</a>
|
||||
<a routerLink="vex-sources" class="tile">
|
||||
<a routerLink="advisory-vex-sources" class="tile">
|
||||
<span>VEX Sources</span>
|
||||
<strong>{{ stats.vexSources }}</strong>
|
||||
</a>
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
<div class="integration-list">
|
||||
<header class="list-header">
|
||||
<h1>{{ typeLabel }} Integrations</h1>
|
||||
<button class="btn-primary" (click)="addIntegration()">+ Add {{ typeLabel }}</button>
|
||||
<button class="btn-primary" (click)="addIntegration()">{{ addActionLabel() }}</button>
|
||||
</header>
|
||||
|
||||
<section class="filters">
|
||||
@@ -49,10 +51,18 @@ import {
|
||||
|
||||
@if (loading) {
|
||||
<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) {
|
||||
<div class="empty-state">
|
||||
<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>
|
||||
} @else {
|
||||
<table class="integration-table">
|
||||
@@ -70,7 +80,7 @@ import {
|
||||
@for (integration of integrations; track integration.integrationId) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/platform/integrations', integration.integrationId]">{{ integration.name }}</a>
|
||||
<a [routerLink]="integrationDetailRoute(integration.integrationId)">{{ integration.name }}</a>
|
||||
</td>
|
||||
<td>{{ getProviderName(integration.provider) }}</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)="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>
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="integration-shell">
|
||||
<header class="integration-shell__header">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { IntegrationWizardComponent } from './integration-wizard.component';
|
||||
import { integrationWorkspaceCommands } from '../integration-hub/integration-route-context';
|
||||
import {
|
||||
IntegrationType,
|
||||
IntegrationDraft,
|
||||
@@ -207,17 +208,17 @@ export class IntegrationsHubComponent implements OnInit {
|
||||
|
||||
openWizard(type: IntegrationType): void {
|
||||
this.activeWizard.set(type);
|
||||
void this.router.navigate(['/integrations/onboarding', type]);
|
||||
void this.router.navigate(this.integrationCommands('onboarding', type));
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.activeWizard.set(null);
|
||||
void this.router.navigate(['/integrations/onboarding']);
|
||||
void this.router.navigate(this.integrationCommands('onboarding'));
|
||||
}
|
||||
|
||||
onIntegrationCreated(draft: IntegrationDraft): void {
|
||||
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 {
|
||||
@@ -242,10 +243,14 @@ export class IntegrationsHubComponent implements OnInit {
|
||||
case 'ci':
|
||||
return 'ci';
|
||||
case 'host':
|
||||
return 'hosts';
|
||||
return 'runtime-hosts';
|
||||
case 'registry':
|
||||
default:
|
||||
return 'registries';
|
||||
}
|
||||
}
|
||||
|
||||
private integrationCommands(...segments: string[]): string[] {
|
||||
return integrationWorkspaceCommands(this.router.url, ...segments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,9 @@ const canonicalRoutes = [
|
||||
'/ops/platform-setup/defaults-guardrails',
|
||||
'/ops/platform-setup/trust-signing',
|
||||
'/setup',
|
||||
'/setup/integrations',
|
||||
'/setup/integrations/advisory-vex-sources',
|
||||
'/setup/integrations/secrets',
|
||||
'/setup/identity-access',
|
||||
'/setup/tenant-branding',
|
||||
'/setup/notifications',
|
||||
@@ -196,6 +199,18 @@ const strictRouteExpectations: Partial<Record<(typeof canonicalRoutes)[number],
|
||||
title: /Trust/i,
|
||||
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': {
|
||||
title: /Policy/i,
|
||||
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[] {
|
||||
const errors: string[] = [];
|
||||
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) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
return route.fulfill({
|
||||
@@ -1223,6 +1420,7 @@ async function setupHarness(page: Page): Promise<void> {
|
||||
requestUrl.includes('/api/v2/context/') ||
|
||||
requestUrl.includes('/api/v2/security/sbom-explorer') ||
|
||||
requestUrl.includes('/policy/api/') ||
|
||||
requestUrl.includes('/api/v1/integrations') ||
|
||||
requestUrl.includes('/api/v1/trust/') ||
|
||||
requestUrl.includes('/api/v1/audit/') ||
|
||||
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');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await page.goto('/mission-control/board', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('aside.sidebar a[href="/releases/overview"]').first().click();
|
||||
|
||||
Reference in New Issue
Block a user