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 | 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.
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user