Preserve topology and triage scope in live setup flows
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
# Sprint 20260310_004 - Setup Topology Live Action Sweep
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Verify the Setup/Topology slice against the rebuilt `https://stella-ops.local` stack with real Playwright interactions, not route-only checks.
|
||||||
|
- Treat scope preservation as part of correctness: topology tabs and operator actions must keep the active tenant/region/environment/time-window context.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Expected evidence: live Playwright sweep JSON, focused Angular tests, execution log updates, and a scoped commit.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on the rebuilt web bundle and healthy frontdoor stack already running through `devops/compose/docker-compose.stella-ops.yml`.
|
||||||
|
- Safe to run in parallel with backend/search work as long as edits stay inside `src/Web/StellaOps.Web` and this sprint file.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/qa/feature-checks/FLOW.md`
|
||||||
|
- `docs/modules/platform/architecture-overview.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-TOPO-LIVE-001 - Capture live topology action evidence
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: QA, Developer
|
||||||
|
Task description:
|
||||||
|
- Add a dedicated live Playwright script for Setup/Topology that exercises the shell tabs, overview CTAs, environment inventory actions, and environment detail actions on the authenticated frontdoor.
|
||||||
|
- The sweep must fail when routes misnavigate, when runtime errors surface, or when actions drop active scope query parameters that should remain stable across topology flows.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] A committed live sweep script exists under `src/Web/StellaOps.Web/scripts/`.
|
||||||
|
- [x] The sweep captures fresh evidence under `src/Web/StellaOps.Web/output/playwright/`.
|
||||||
|
- [x] Any failures are diagnosed to code-level root causes before implementation changes begin.
|
||||||
|
|
||||||
|
### FE-TOPO-LIVE-002 - Repair topology scope-preserving navigation
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-TOPO-LIVE-001
|
||||||
|
Owners: Developer
|
||||||
|
Task description:
|
||||||
|
- Apply the existing Stella Ops scope-preserving navigation pattern to the topology shell and topology operator actions so the live context survives shell navigation and drilldowns.
|
||||||
|
- Keep the fix scoped to topology unless a broader shared change is clearly required and low risk.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Topology shell navigation preserves active scope.
|
||||||
|
- [x] Topology CTA/drilldown actions preserve active scope while adding route-specific parameters.
|
||||||
|
- [x] Focused tests cover the changed navigation contracts.
|
||||||
|
|
||||||
|
### FE-TOPO-LIVE-003 - Reverify live topology slice after fixes
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-TOPO-LIVE-002
|
||||||
|
Owners: QA
|
||||||
|
Task description:
|
||||||
|
- Rebuild the web bundle if needed, sync it into the live stack, rerun the exact topology sweep, and confirm the slice is clean.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] The topology sweep passes with zero failed actions.
|
||||||
|
- [x] The topology sweep reports zero runtime issues.
|
||||||
|
- [x] Execution Log records the before/after evidence and the commit hash.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-10 | Sprint created for the next live QA/developer iteration on Setup/Topology after the clean canonical frontdoor, policy, releases, notifications, and mission-control sweeps. | QA |
|
||||||
|
| 2026-03-10 | First authenticated live topology sweep failed 25 actions with 0 runtime issues. Every failure traced to scope loss across topology shell tabs, CTA/drilldown links, or environment detail operator actions; two tab failures were harness selector collisions on partial `Security` and `Evidence` matches. | QA |
|
||||||
|
| 2026-03-10 | Root cause analysis found two product defects: Topology links were not consistently using scope-preserving navigation, and `SecurityFindingsPageComponent.reloadFromFilters()` rewrote `/security/triage` without merge semantics, stripping the incoming topology scope. | Developer |
|
||||||
|
| 2026-03-10 | Added focused navigation regressions for topology and security findings. Focused Angular run passed `6/6` assertions across `2` spec files. | Test Automation |
|
||||||
|
| 2026-03-10 | Rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and reran the same live topology sweep. Final evidence is clean with `0` failed actions and `0` runtime issues in `src/Web/StellaOps.Web/output/playwright/live-setup-topology-action-sweep.json`. Commit hash pending local commit. | QA |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: treat scope preservation as a correctness requirement in topology because the active platform context changes the data surface on every page and drilldown.
|
||||||
|
- Risk: `TabbedNavComponent` is shared across multiple shells. If topology needs scope-preserving shell tabs, prefer an opt-in contract instead of a silent repo-wide behavior change.
|
||||||
|
- Decision: `TabItem` now supports opt-in `queryParamsHandling`, and Topology explicitly sets `merge` on its shell tabs. This preserves scope without changing every other shared tabbed navigation surface.
|
||||||
|
- Decision: fixing the destination rewrite in `/security/triage` is mandatory. Accepting a scoped entry link is not sufficient if the landing page immediately discards the topology context.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Capture the first failing topology live sweep.
|
||||||
|
- Repair the navigation contracts and re-run the same sweep before committing.
|
||||||
@@ -156,6 +156,7 @@ Implementation update (2026-02-20):
|
|||||||
- `/topology/promotion-paths`.
|
- `/topology/promotion-paths`.
|
||||||
- Generic inventory fallback remains only for non-primary Topology routes (`/topology/workflows`, `/topology/gate-profiles`).
|
- Generic inventory fallback remains only for non-primary Topology routes (`/topology/workflows`, `/topology/gate-profiles`).
|
||||||
- Region/environment global multi-select filters propagate as comma-joined query scope on Topology reads.
|
- Region/environment global multi-select filters propagate as comma-joined query scope on Topology reads.
|
||||||
|
- Topology shell tabs, drilldowns, and downstream triage handoffs preserve the active query scope so operator flows stay bound to the same tenant/region/environment/time-window context.
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||||
|
|
||||||
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||||
|
const outputPath = path.join(outputDir, 'live-setup-topology-action-sweep.json');
|
||||||
|
const authStatePath = path.join(outputDir, 'live-setup-topology-action-sweep.state.json');
|
||||||
|
const authReportPath = path.join(outputDir, 'live-setup-topology-action-sweep.auth.json');
|
||||||
|
const topologyScope = {
|
||||||
|
tenant: 'demo-prod',
|
||||||
|
regions: 'us-east',
|
||||||
|
environments: 'stage',
|
||||||
|
timeWindow: '7d',
|
||||||
|
};
|
||||||
|
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
|
||||||
|
const STEP_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function isStaticAsset(url) {
|
||||||
|
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime() {
|
||||||
|
return {
|
||||||
|
consoleErrors: [],
|
||||||
|
pageErrors: [],
|
||||||
|
requestFailures: [],
|
||||||
|
responseErrors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRuntimeObservers(page, runtime) {
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
runtime.consoleErrors.push({
|
||||||
|
page: page.url(),
|
||||||
|
text: message.text(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('pageerror', (error) => {
|
||||||
|
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.pageErrors.push({
|
||||||
|
page: page.url(),
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
if (isStaticAsset(request.url())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.requestFailures.push({
|
||||||
|
page: page.url(),
|
||||||
|
method: request.method(),
|
||||||
|
url: request.url(),
|
||||||
|
error: request.failure()?.errorText ?? 'unknown',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (isStaticAsset(response.url())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
runtime.responseErrors.push({
|
||||||
|
page: page.url(),
|
||||||
|
method: response.request().method(),
|
||||||
|
status: response.status(),
|
||||||
|
url: response.url(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(page) {
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
|
||||||
|
const loadingBanners = page.locator('text=/Loading /i');
|
||||||
|
const count = await loadingBanners.count().catch(() => 0);
|
||||||
|
if (count > 0) {
|
||||||
|
await loadingBanners.first().waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function headingText(page) {
|
||||||
|
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
|
||||||
|
const count = await headings.count();
|
||||||
|
for (let index = 0; index < Math.min(count, 5); index += 1) {
|
||||||
|
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSnapshot(page, label) {
|
||||||
|
const alerts = await page
|
||||||
|
.locator('[role="alert"], .banner--error, .error-banner, .toast, .notification')
|
||||||
|
.evaluateAll((nodes) =>
|
||||||
|
nodes
|
||||||
|
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8),
|
||||||
|
)
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
url: page.url(),
|
||||||
|
title: await page.title(),
|
||||||
|
heading: await headingText(page),
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSummary(summary) {
|
||||||
|
summary.lastUpdatedAtUtc = new Date().toISOString();
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withScope(route) {
|
||||||
|
const separator = route.includes('?') ? '&' : '?';
|
||||||
|
return `https://stella-ops.local${route}${separator}${topologyScopeQuery}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(page, route) {
|
||||||
|
await page.goto(withScope(route), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await settle(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExpectedQuery(urlString, expectedQuery = {}) {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
for (const [key, value] of Object.entries(expectedQuery)) {
|
||||||
|
if (url.searchParams.get(key) !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLink(page, options) {
|
||||||
|
if (options.hrefIncludes) {
|
||||||
|
const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`);
|
||||||
|
const count = await candidates.count();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const candidate = candidates.nth(index);
|
||||||
|
const text = ((await candidate.innerText().catch(() => '')) || '').trim();
|
||||||
|
if (!options.name || text === options.name || text.includes(options.name)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.name) {
|
||||||
|
const link = page.getByRole('link', { name: options.name }).first();
|
||||||
|
if ((await link.count()) > 0) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickLinkAction(page, route, options) {
|
||||||
|
await navigate(page, route);
|
||||||
|
const link = await resolveLink(page, options);
|
||||||
|
if (!link) {
|
||||||
|
return {
|
||||||
|
action: options.action,
|
||||||
|
ok: false,
|
||||||
|
reason: 'missing-link',
|
||||||
|
snapshot: await captureSnapshot(page, `missing:${options.action}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await link.click({ timeout: 10_000 });
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
const url = new URL(page.url());
|
||||||
|
const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery);
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: options.action,
|
||||||
|
ok,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
snapshot: await captureSnapshot(page, `after:${options.action}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillOverviewSearch(page) {
|
||||||
|
await navigate(page, '/setup/topology/overview');
|
||||||
|
const input = page.locator('#topology-overview-search');
|
||||||
|
await input.fill('stage');
|
||||||
|
await page.getByRole('button', { name: 'Go' }).click({ timeout: 10_000 });
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
new URL(page.url()).pathname === '/setup/topology/environments/stage/posture' &&
|
||||||
|
hasExpectedQuery(page.url(), topologyScope);
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'overview-search:Go',
|
||||||
|
ok,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
snapshot: await captureSnapshot(page, 'after:overview-search:Go'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickEnvironmentDetailTab(page, tabLabel, expectedText) {
|
||||||
|
await navigate(page, '/setup/topology/environments/stage/posture');
|
||||||
|
await page.getByRole('button', { name: tabLabel, exact: true }).click({ timeout: 10_000 });
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
|
||||||
|
return {
|
||||||
|
action: `environment-tab:${tabLabel}`,
|
||||||
|
ok,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
snapshot: await captureSnapshot(page, `after:environment-tab:${tabLabel}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmptyInventoryState(page, route, expectedText) {
|
||||||
|
await navigate(page, route);
|
||||||
|
const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
|
||||||
|
return {
|
||||||
|
action: `empty-state:${route}`,
|
||||||
|
ok,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
snapshot: await captureSnapshot(page, `after:empty-state:${route}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(page, route, actionFactory) {
|
||||||
|
const startedAtUtc = new Date().toISOString();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
process.stdout.write(`[live-setup-topology-action-sweep] START ${route}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
actionFactory(page, route),
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)), STEP_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const completed = {
|
||||||
|
...result,
|
||||||
|
startedAtUtc,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
process.stdout.write(
|
||||||
|
`[live-setup-topology-action-sweep] DONE ${completed.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`,
|
||||||
|
);
|
||||||
|
return completed;
|
||||||
|
} catch (error) {
|
||||||
|
const failed = {
|
||||||
|
action: route,
|
||||||
|
ok: false,
|
||||||
|
reason: 'exception',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
startedAtUtc,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
snapshot: await captureSnapshot(page, `failure:${route}`),
|
||||||
|
};
|
||||||
|
process.stdout.write(
|
||||||
|
`[live-setup-topology-action-sweep] FAIL ${route} error=${failed.error} durationMs=${failed.durationMs}\n`,
|
||||||
|
);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const authReport = await authenticateFrontdoor({
|
||||||
|
statePath: authStatePath,
|
||||||
|
reportPath: authReportPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await createAuthenticatedContext(browser, authReport, {
|
||||||
|
statePath: authStatePath,
|
||||||
|
});
|
||||||
|
const runtime = createRuntime();
|
||||||
|
context.on('page', (page) => attachRuntimeObservers(page, runtime));
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
attachRuntimeObservers(page, runtime);
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Map',
|
||||||
|
hrefIncludes: '/setup/topology/map',
|
||||||
|
expectedPath: '/setup/topology/map',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Regions & Environments',
|
||||||
|
hrefIncludes: '/setup/topology/regions',
|
||||||
|
expectedPath: '/setup/topology/regions',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Targets',
|
||||||
|
name: 'Targets',
|
||||||
|
hrefIncludes: '/setup/topology/targets',
|
||||||
|
expectedPath: '/setup/topology/targets',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Hosts',
|
||||||
|
hrefIncludes: '/setup/topology/hosts',
|
||||||
|
expectedPath: '/setup/topology/hosts',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Agents',
|
||||||
|
hrefIncludes: '/setup/topology/agents',
|
||||||
|
expectedPath: '/setup/topology/agents',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Promotion Graph',
|
||||||
|
hrefIncludes: '/setup/topology/promotion-graph',
|
||||||
|
expectedPath: '/setup/topology/promotion-graph',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Workflows',
|
||||||
|
hrefIncludes: '/setup/topology/workflows',
|
||||||
|
expectedPath: '/setup/topology/workflows',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Gate Profiles',
|
||||||
|
hrefIncludes: '/setup/topology/gate-profiles',
|
||||||
|
expectedPath: '/setup/topology/gate-profiles',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Connectivity',
|
||||||
|
hrefIncludes: '/setup/topology/connectivity',
|
||||||
|
expectedPath: '/setup/topology/connectivity',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'tab:Runtime Drift',
|
||||||
|
hrefIncludes: '/setup/topology/runtime-drift',
|
||||||
|
expectedPath: '/setup/topology/runtime-drift',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'overview:Open Regions & Environments',
|
||||||
|
name: 'Open Regions & Environments',
|
||||||
|
expectedPath: '/setup/topology/regions',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'overview:Open Environment Inventory',
|
||||||
|
name: 'Open Environment Inventory',
|
||||||
|
expectedPath: '/setup/topology/environments',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'overview:Open Agents',
|
||||||
|
name: 'Open Agents',
|
||||||
|
expectedPath: '/setup/topology/agents',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||||
|
action: 'overview:Open Promotion Paths',
|
||||||
|
name: 'Open Promotion Paths',
|
||||||
|
expectedPath: '/setup/topology/promotion-graph',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/overview', (currentPage) => fillOverviewSearch(currentPage)],
|
||||||
|
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||||
|
action: 'environments:Open',
|
||||||
|
name: 'Open',
|
||||||
|
hrefIncludes: '/setup/topology/environments/stage/posture',
|
||||||
|
expectedPath: '/setup/topology/environments/stage/posture',
|
||||||
|
expectedQuery: topologyScope,
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||||
|
action: 'environments:Open Targets',
|
||||||
|
name: 'Open Targets',
|
||||||
|
hrefIncludes: '/setup/topology/targets',
|
||||||
|
expectedPath: '/setup/topology/targets',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||||
|
action: 'environments:Open Agents',
|
||||||
|
name: 'Open Agents',
|
||||||
|
hrefIncludes: '/setup/topology/agents',
|
||||||
|
expectedPath: '/setup/topology/agents',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||||
|
action: 'environments:Open Runs',
|
||||||
|
name: 'Open Runs',
|
||||||
|
hrefIncludes: '/releases/runs',
|
||||||
|
expectedPath: '/releases/runs',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Overview', 'Operator Actions')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Targets', 'Targets')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Runs', 'Runs')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Agents', 'Agents')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Security', 'Security')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Evidence', 'Evidence')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Data Quality', 'Data Quality')],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||||
|
action: 'environment-detail:Open Targets',
|
||||||
|
name: 'Open Targets',
|
||||||
|
hrefIncludes: '/setup/topology/targets',
|
||||||
|
expectedPath: '/setup/topology/targets',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||||
|
action: 'environment-detail:Open Agents',
|
||||||
|
name: 'Open Agents',
|
||||||
|
hrefIncludes: '/setup/topology/agents',
|
||||||
|
expectedPath: '/setup/topology/agents',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||||
|
action: 'environment-detail:Open Runs',
|
||||||
|
name: 'Open Runs',
|
||||||
|
hrefIncludes: '/releases/runs',
|
||||||
|
expectedPath: '/releases/runs',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||||
|
action: 'environment-detail:Open Security Triage',
|
||||||
|
name: 'Open Security Triage',
|
||||||
|
hrefIncludes: '/security/triage',
|
||||||
|
expectedPath: '/security/triage',
|
||||||
|
expectedQuery: {
|
||||||
|
...topologyScope,
|
||||||
|
environment: 'stage',
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')],
|
||||||
|
['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')],
|
||||||
|
['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')],
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
generatedAtUtc: new Date().toISOString(),
|
||||||
|
baseUrl: 'https://stella-ops.local',
|
||||||
|
scope: topologyScope,
|
||||||
|
actions: [],
|
||||||
|
runtime: runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [route, actionFactory] of actions) {
|
||||||
|
const result = await runAction(page, route, actionFactory);
|
||||||
|
summary.actions.push(result);
|
||||||
|
await persistSummary(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.failedActionCount = summary.actions.filter((action) => !action.ok).length;
|
||||||
|
summary.runtimeIssueCount =
|
||||||
|
runtime.consoleErrors.length +
|
||||||
|
runtime.pageErrors.length +
|
||||||
|
runtime.requestFailures.length +
|
||||||
|
runtime.responseErrors.length;
|
||||||
|
|
||||||
|
await persistSummary(summary);
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { PlatformContextStore } from '../context/platform-context.store';
|
||||||
|
import { SecurityFindingsPageComponent } from '../../features/security/security-findings-page.component';
|
||||||
|
|
||||||
|
describe('SecurityFindingsPageComponent', () => {
|
||||||
|
const queryParamMap$ = new BehaviorSubject(
|
||||||
|
convertToParamMap({
|
||||||
|
tenant: 'demo-prod',
|
||||||
|
regions: 'us-east',
|
||||||
|
environments: 'stage',
|
||||||
|
timeWindow: '7d',
|
||||||
|
environment: 'stage',
|
||||||
|
pivot: 'cve',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryParamMap$.next(
|
||||||
|
convertToParamMap({
|
||||||
|
tenant: 'demo-prod',
|
||||||
|
regions: 'us-east',
|
||||||
|
environments: 'stage',
|
||||||
|
timeWindow: '7d',
|
||||||
|
environment: 'stage',
|
||||||
|
pivot: 'cve',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SecurityFindingsPageComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
queryParamMap: queryParamMap$.asObservable(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HttpClient,
|
||||||
|
useValue: {
|
||||||
|
get: () => of({ items: [], total: 0, pivot: 'cve', facets: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformContextStore,
|
||||||
|
useValue: {
|
||||||
|
initialize: () => undefined,
|
||||||
|
contextVersion: signal(0),
|
||||||
|
selectedRegions: () => ['us-east'],
|
||||||
|
selectedEnvironments: () => ['stage'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges the active query scope when triage filters rewrite the url', () => {
|
||||||
|
const fixture = TestBed.createComponent(SecurityFindingsPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const route = TestBed.inject(ActivatedRoute);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.reloadFromFilters();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith([], {
|
||||||
|
relativeTo: route,
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
queryParams: {
|
||||||
|
pivot: 'cve',
|
||||||
|
q: null,
|
||||||
|
severity: null,
|
||||||
|
reachability: null,
|
||||||
|
vex: null,
|
||||||
|
exception: null,
|
||||||
|
blocks: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { signal, Type } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, Router, RouterLink, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { PlatformContextStore } from '../context/platform-context.store';
|
||||||
|
import { TopologyDataService } from '../../features/topology/topology-data.service';
|
||||||
|
import { EnvironmentPosturePageComponent } from '../../features/topology/environment-posture-page.component';
|
||||||
|
import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
|
||||||
|
import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
|
||||||
|
import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component';
|
||||||
|
import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component';
|
||||||
|
import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component';
|
||||||
|
import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component';
|
||||||
|
import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component';
|
||||||
|
import { TopologyShellComponent } from '../../features/topology/topology-shell.component';
|
||||||
|
import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component';
|
||||||
|
|
||||||
|
const routeData$ = new BehaviorSubject<Record<string, unknown>>({});
|
||||||
|
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||||
|
const paramMap$ = new BehaviorSubject(convertToParamMap({ environmentId: 'stage' }));
|
||||||
|
|
||||||
|
const mockContextStore = {
|
||||||
|
initialize: () => undefined,
|
||||||
|
contextVersion: signal(0),
|
||||||
|
regionSummary: () => 'US East',
|
||||||
|
environmentSummary: () => 'Staging',
|
||||||
|
selectedRegions: () => ['us-east'],
|
||||||
|
selectedEnvironments: () => ['stage'],
|
||||||
|
regions: () => [
|
||||||
|
{ regionId: 'us-east', displayName: 'US East', sortOrder: 10, enabled: true },
|
||||||
|
],
|
||||||
|
environments: () => [
|
||||||
|
{
|
||||||
|
environmentId: 'stage',
|
||||||
|
regionId: 'us-east',
|
||||||
|
environmentType: 'staging',
|
||||||
|
displayName: 'Staging',
|
||||||
|
sortOrder: 20,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTopologyDataService = {
|
||||||
|
list: jasmine.createSpy('list').and.callFake((endpoint: string) => {
|
||||||
|
switch (endpoint) {
|
||||||
|
case '/api/v2/topology/regions':
|
||||||
|
return of([{ regionId: 'us-east', displayName: 'US East', environmentCount: 1, targetCount: 1 }]);
|
||||||
|
case '/api/v2/topology/environments':
|
||||||
|
return of([
|
||||||
|
{
|
||||||
|
environmentId: 'stage',
|
||||||
|
displayName: 'Staging',
|
||||||
|
regionId: 'us-east',
|
||||||
|
environmentType: 'staging',
|
||||||
|
targetCount: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
case '/api/v2/topology/targets':
|
||||||
|
return of([
|
||||||
|
{
|
||||||
|
targetId: 'target-1',
|
||||||
|
name: 'api-web',
|
||||||
|
regionId: 'us-east',
|
||||||
|
environmentId: 'stage',
|
||||||
|
targetType: 'vm',
|
||||||
|
healthStatus: 'healthy',
|
||||||
|
hostId: 'host-1',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
componentVersionId: 'component-1',
|
||||||
|
lastSyncAt: '2026-03-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
case '/api/v2/topology/hosts':
|
||||||
|
return of([
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
hostName: 'host-1',
|
||||||
|
regionId: 'us-east',
|
||||||
|
environmentId: 'stage',
|
||||||
|
runtimeType: 'containerd',
|
||||||
|
status: 'healthy',
|
||||||
|
targetCount: 1,
|
||||||
|
agentId: 'agent-1',
|
||||||
|
lastSeenAt: '2026-03-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
case '/api/v2/topology/agents':
|
||||||
|
return of([
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
agentName: 'agent-1',
|
||||||
|
regionId: 'us-east',
|
||||||
|
environmentId: 'stage',
|
||||||
|
status: 'active',
|
||||||
|
assignedTargetCount: 1,
|
||||||
|
capabilities: ['deploy'],
|
||||||
|
lastHeartbeatAt: '2026-03-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
case '/api/v2/topology/promotion-paths':
|
||||||
|
return of([
|
||||||
|
{
|
||||||
|
pathId: 'path-1',
|
||||||
|
regionId: 'us-east',
|
||||||
|
sourceEnvironmentId: 'dev',
|
||||||
|
targetEnvironmentId: 'stage',
|
||||||
|
status: 'running',
|
||||||
|
requiredApprovals: 1,
|
||||||
|
gateProfileId: 'gate-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
default:
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockHttpClient = {
|
||||||
|
get: jasmine.createSpy('get').and.callFake((url: string) => {
|
||||||
|
switch (url) {
|
||||||
|
case '/api/v2/releases/activity':
|
||||||
|
return of({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
activityId: 'run-1',
|
||||||
|
releaseId: 'release-1',
|
||||||
|
releaseName: 'Release 1',
|
||||||
|
status: 'blocked',
|
||||||
|
correlationKey: 'corr-1',
|
||||||
|
occurredAt: '2026-03-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case '/api/v2/security/findings':
|
||||||
|
return of({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
findingId: 'finding-1',
|
||||||
|
cveId: 'CVE-2026-0001',
|
||||||
|
severity: 'high',
|
||||||
|
effectiveDisposition: 'action_required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case '/api/v2/evidence/packs':
|
||||||
|
return of({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
capsuleId: 'capsule-1',
|
||||||
|
status: 'fresh',
|
||||||
|
updatedAt: '2026-03-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case '/api/v2/topology/environments':
|
||||||
|
return of({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
environmentId: 'stage',
|
||||||
|
displayName: 'Staging',
|
||||||
|
regionId: 'us-east',
|
||||||
|
status: 'healthy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return of({ items: [] });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function configureTestingModule<T>(component: Type<T>): void {
|
||||||
|
routeData$.next({});
|
||||||
|
queryParamMap$.next(convertToParamMap({}));
|
||||||
|
paramMap$.next(convertToParamMap({ environmentId: 'stage' }));
|
||||||
|
mockTopologyDataService.list.calls.reset();
|
||||||
|
mockHttpClient.get.calls.reset();
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [component],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
data: routeData$.asObservable(),
|
||||||
|
queryParamMap: queryParamMap$.asObservable(),
|
||||||
|
paramMap: paramMap$.asObservable(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: PlatformContextStore, useValue: mockContextStore },
|
||||||
|
{ provide: TopologyDataService, useValue: mockTopologyDataService },
|
||||||
|
{ provide: HttpClient, useValue: mockHttpClient },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routerLinksFor<T>(component: Type<T>): RouterLink[] {
|
||||||
|
const fixture = TestBed.createComponent(component);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => debugElement.injector.get(RouterLink));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Topology scope-preserving links', () => {
|
||||||
|
it('marks topology shell tabs to merge the active query scope', () => {
|
||||||
|
configureTestingModule(TopologyShellComponent);
|
||||||
|
|
||||||
|
const links = routerLinksFor(TopologyShellComponent);
|
||||||
|
|
||||||
|
expect(links.length).toBe(11);
|
||||||
|
expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks topology page links to merge the active query scope', () => {
|
||||||
|
const cases: Array<{ component: Type<unknown>; routeData?: Record<string, unknown>; expectedMinCount: number }> = [
|
||||||
|
{ component: TopologyOverviewPageComponent, expectedMinCount: 4 },
|
||||||
|
{ component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 },
|
||||||
|
{ component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 },
|
||||||
|
{ component: TopologyTargetsPageComponent, expectedMinCount: 3 },
|
||||||
|
{ component: TopologyHostsPageComponent, expectedMinCount: 3 },
|
||||||
|
{ component: TopologyAgentsPageComponent, expectedMinCount: 3 },
|
||||||
|
{ component: EnvironmentPosturePageComponent, expectedMinCount: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
configureTestingModule(testCase.component);
|
||||||
|
routeData$.next(testCase.routeData ?? {});
|
||||||
|
|
||||||
|
const links = routerLinksFor(testCase.component);
|
||||||
|
|
||||||
|
expect(links.length).toBeGreaterThanOrEqual(testCase.expectedMinCount);
|
||||||
|
expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks promotion inventory links to merge the active query scope', () => {
|
||||||
|
configureTestingModule(TopologyPromotionPathsPageComponent);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TopologyPromotionPathsPageComponent);
|
||||||
|
fixture.componentInstance.viewMode.set('inventory');
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => debugElement.injector.get(RouterLink));
|
||||||
|
|
||||||
|
expect(links.length).toBeGreaterThan(0);
|
||||||
|
expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges query scope for topology overview drilldowns', () => {
|
||||||
|
configureTestingModule(TopologyOverviewPageComponent);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TopologyOverviewPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component.openHit({
|
||||||
|
id: 'env:stage',
|
||||||
|
label: 'Staging',
|
||||||
|
sublabel: 'us-east',
|
||||||
|
type: 'environment',
|
||||||
|
} as never);
|
||||||
|
component.openHit({
|
||||||
|
id: 'target:target-1',
|
||||||
|
label: 'api-web',
|
||||||
|
sublabel: 'vm',
|
||||||
|
type: 'target',
|
||||||
|
} as never);
|
||||||
|
component.openHit({
|
||||||
|
id: 'host:host-1',
|
||||||
|
label: 'host-1',
|
||||||
|
sublabel: 'containerd',
|
||||||
|
type: 'host',
|
||||||
|
} as never);
|
||||||
|
component.openHit({
|
||||||
|
id: 'agent:agent-1',
|
||||||
|
label: 'agent-1',
|
||||||
|
sublabel: 'deploy',
|
||||||
|
type: 'agent',
|
||||||
|
} as never);
|
||||||
|
component.openTarget('target-1');
|
||||||
|
|
||||||
|
expect(navigateSpy.calls.allArgs()).toEqual([
|
||||||
|
[['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/targets'], { queryParams: { targetId: 'target-1' }, queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/hosts'], { queryParams: { hostId: 'host-1' }, queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/targets'], { queryParams: { targetId: 'target-1' }, queryParamsHandling: 'merge' }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges query scope for topology map node navigation', () => {
|
||||||
|
configureTestingModule(TopologyMapPageComponent);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TopologyMapPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component['navigateToNode']({ id: 'region:us-east', kind: 'region', label: 'US East', sublabel: '1 env' });
|
||||||
|
component['navigateToNode']({
|
||||||
|
id: 'env:stage',
|
||||||
|
kind: 'environment',
|
||||||
|
label: 'Staging',
|
||||||
|
sublabel: 'us-east',
|
||||||
|
environmentId: 'stage',
|
||||||
|
});
|
||||||
|
component['navigateToNode']({ id: 'agent:agent-1', kind: 'agent', label: 'agent-1', sublabel: 'active' });
|
||||||
|
|
||||||
|
expect(navigateSpy.calls.allArgs()).toEqual([
|
||||||
|
[['/setup/topology/regions'], { queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }],
|
||||||
|
[['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,7 +173,7 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy'
|
|||||||
<h2>Evidence Rail</h2>
|
<h2>Evidence Rail</h2>
|
||||||
@if (selected()) {
|
@if (selected()) {
|
||||||
<p class="identity">
|
<p class="identity">
|
||||||
<strong>{{ selected()!.cveId }}</strong> <EFBFBD> {{ selected()!.componentName }} <EFBFBD> {{ selected()!.region }}/{{ selected()!.environment }}
|
<strong>{{ selected()!.cveId }}</strong> · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<nav class="rail-tabs" aria-label="Evidence rail tabs">
|
<nav class="rail-tabs" aria-label="Evidence rail tabs">
|
||||||
@@ -454,6 +454,7 @@ export class SecurityFindingsPageComponent {
|
|||||||
void this.router.navigate([], {
|
void this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
queryParams: {
|
queryParams: {
|
||||||
pivot: this.pivot,
|
pivot: this.pivot,
|
||||||
q: this.search || null,
|
q: this.search || null,
|
||||||
@@ -558,4 +559,4 @@ export class SecurityFindingsPageComponent {
|
|||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,19 +63,19 @@ interface EvidenceCapsuleRow {
|
|||||||
<article>
|
<article>
|
||||||
<h3>Release Run Health</h3>
|
<h3>Release Run Health</h3>
|
||||||
<p>{{ runSummary() }}</p>
|
<p>{{ runSummary() }}</p>
|
||||||
<a [routerLink]="['/releases/runs']" [queryParams]="{ env: environmentId() }">Open Release Runs</a>
|
<a [routerLink]="['/releases/runs']" [queryParams]="{ env: environmentId() }" queryParamsHandling="merge">Open Release Runs</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<h3>Security Posture</h3>
|
<h3>Security Posture</h3>
|
||||||
<p>{{ securitySummary() }}</p>
|
<p>{{ securitySummary() }}</p>
|
||||||
<a [routerLink]="['/security/findings']" [queryParams]="{ environment: environmentId() }">Open Findings</a>
|
<a [routerLink]="['/security/findings']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Findings</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<h3>Decision Capsule Confidence</h3>
|
<h3>Decision Capsule Confidence</h3>
|
||||||
<p>{{ evidenceSummary() }}</p>
|
<p>{{ evidenceSummary() }}</p>
|
||||||
<a [routerLink]="['/evidence/capsules']" [queryParams]="{ environment: environmentId() }">Open Decision Capsules</a>
|
<a [routerLink]="['/evidence/capsules']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Decision Capsules</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ interface AgentGroupRow {
|
|||||||
<p>Targets: {{ selectedGroup()!.targetCount }}</p>
|
<p>Targets: {{ selectedGroup()!.targetCount }}</p>
|
||||||
<p>Drift: {{ selectedGroup()!.degradedCount + selectedGroup()!.offlineCount }}</p>
|
<p>Drift: {{ selectedGroup()!.degradedCount + selectedGroup()!.offlineCount }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedGroup()!.environmentId }">View Targets</a>
|
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedGroup()!.environmentId }" queryParamsHandling="merge">View Targets</a>
|
||||||
<a [routerLink]="['/setup/topology/environments', selectedGroup()!.environmentId, 'posture']">View Environment</a>
|
<a [routerLink]="['/setup/topology/environments', selectedGroup()!.environmentId, 'posture']" queryParamsHandling="merge">View Environment</a>
|
||||||
<a [routerLink]="['/ops/operations/doctor']">Open Diagnostics</a>
|
<a [routerLink]="['/ops/operations/doctor']" queryParamsHandling="merge">Open Diagnostics</a>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="muted">Select a group row to inspect fleet impact.</p>
|
<p class="muted">Select a group row to inspect fleet impact.</p>
|
||||||
@@ -153,9 +153,9 @@ interface AgentGroupRow {
|
|||||||
<p>Targets: {{ selectedAgent()!.assignedTargetCount }}</p>
|
<p>Targets: {{ selectedAgent()!.assignedTargetCount }}</p>
|
||||||
<p>Heartbeat: {{ selectedAgent()!.lastHeartbeatAt ?? '-' }}</p>
|
<p>Heartbeat: {{ selectedAgent()!.lastHeartbeatAt ?? '-' }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ agentId: selectedAgent()!.agentId }">View Targets</a>
|
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ agentId: selectedAgent()!.agentId }" queryParamsHandling="merge">View Targets</a>
|
||||||
<a [routerLink]="['/setup/topology/environments', selectedAgent()!.environmentId, 'posture']">View Environment</a>
|
<a [routerLink]="['/setup/topology/environments', selectedAgent()!.environmentId, 'posture']" queryParamsHandling="merge">View Environment</a>
|
||||||
<a [routerLink]="['/ops/operations/doctor']">Open Diagnostics</a>
|
<a [routerLink]="['/ops/operations/doctor']" queryParamsHandling="merge">Open Diagnostics</a>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="muted">Select an agent row to inspect details.</p>
|
<p class="muted">Select an agent row to inspect details.</p>
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<h2>Operator Actions</h2>
|
<h2>Operator Actions</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: environmentId() }">Open Targets</a>
|
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Targets</a>
|
||||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: environmentId() }">Open Agents</a>
|
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Agents</a>
|
||||||
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: environmentId() }">Open Runs</a>
|
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Runs</a>
|
||||||
<a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }">Open Security Triage</a>
|
<a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }" queryParamsHandling="merge">Open Security Triage</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
<p>Impacted targets: {{ selectedHostTargets().length }}</p>
|
<p>Impacted targets: {{ selectedHostTargets().length }}</p>
|
||||||
<p>Upgrade window: Fri 23:00 UTC</p>
|
<p>Upgrade window: Fri 23:00 UTC</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }">Open Targets</a>
|
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }" queryParamsHandling="merge">Open Targets</a>
|
||||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }">Open Agent</a>
|
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }" queryParamsHandling="merge">Open Agent</a>
|
||||||
<a [routerLink]="['/ops/integrations']">Open Host Integrations</a>
|
<a [routerLink]="['/ops/integrations']" queryParamsHandling="merge">Open Host Integrations</a>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="muted">Select a host row to inspect runtime drift and impact.</p>
|
<p class="muted">Select a host row to inspect runtime drift and impact.</p>
|
||||||
|
|||||||
@@ -617,16 +617,19 @@ export class TopologyMapPageComponent implements AfterViewInit, OnDestroy {
|
|||||||
private navigateToNode(node: TopoNode): void {
|
private navigateToNode(node: TopoNode): void {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'region':
|
case 'region':
|
||||||
void this.router.navigate(['/setup/topology/regions']);
|
void this.router.navigate(['/setup/topology/regions'], { queryParamsHandling: 'merge' });
|
||||||
break;
|
break;
|
||||||
case 'environment':
|
case 'environment':
|
||||||
if (node.environmentId) {
|
if (node.environmentId) {
|
||||||
void this.router.navigate(['/setup/topology/environments', node.environmentId, 'posture']);
|
void this.router.navigate(['/setup/topology/environments', node.environmentId, 'posture'], {
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'agent':
|
case 'agent':
|
||||||
void this.router.navigate(['/setup/topology/agents'], {
|
void this.router.navigate(['/setup/topology/agents'], {
|
||||||
queryParams: { agentId: node.id.replace('agent:', '') },
|
queryParams: { agentId: node.id.replace('agent:', '') },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ interface SearchHit {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<a [routerLink]="['/setup/topology/regions']">Open Regions & Environments</a>
|
<a [routerLink]="['/setup/topology/regions']" queryParamsHandling="merge">Open Regions & Environments</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@@ -99,7 +99,7 @@ interface SearchHit {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<a [routerLink]="['/setup/topology/environments']">Open Environment Inventory</a>
|
<a [routerLink]="['/setup/topology/environments']" queryParamsHandling="merge">Open Environment Inventory</a>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ interface SearchHit {
|
|||||||
· Offline {{ agentHealth().offline }}
|
· Offline {{ agentHealth().offline }}
|
||||||
</p>
|
</p>
|
||||||
<p>Targets under non-active agents: {{ impactedTargetsByAgentHealth() }}</p>
|
<p>Targets under non-active agents: {{ impactedTargetsByAgentHealth() }}</p>
|
||||||
<a [routerLink]="['/setup/topology/agents']">Open Agents</a>
|
<a [routerLink]="['/setup/topology/agents']" queryParamsHandling="merge">Open Agents</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@@ -123,7 +123,7 @@ interface SearchHit {
|
|||||||
· Failed {{ promotionSummary().failed }}
|
· Failed {{ promotionSummary().failed }}
|
||||||
</p>
|
</p>
|
||||||
<p>Manual approvals required: {{ promotionSummary().manualApprovalCount }}</p>
|
<p>Manual approvals required: {{ promotionSummary().manualApprovalCount }}</p>
|
||||||
<a [routerLink]="['/setup/topology/promotion-graph']">Open Promotion Paths</a>
|
<a [routerLink]="['/setup/topology/promotion-graph']" queryParamsHandling="merge">Open Promotion Paths</a>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -507,27 +507,39 @@ export class TopologyOverviewPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'env') {
|
if (kind === 'env') {
|
||||||
void this.router.navigate(['/setup/topology/environments', id, 'posture']);
|
void this.router.navigate(['/setup/topology/environments', id, 'posture'], { queryParamsHandling: 'merge' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'target') {
|
if (kind === 'target') {
|
||||||
void this.router.navigate(['/setup/topology/targets'], { queryParams: { targetId: id } });
|
void this.router.navigate(['/setup/topology/targets'], {
|
||||||
|
queryParams: { targetId: id },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'host') {
|
if (kind === 'host') {
|
||||||
void this.router.navigate(['/setup/topology/hosts'], { queryParams: { hostId: id } });
|
void this.router.navigate(['/setup/topology/hosts'], {
|
||||||
|
queryParams: { hostId: id },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'agent') {
|
if (kind === 'agent') {
|
||||||
void this.router.navigate(['/setup/topology/agents'], { queryParams: { agentId: id } });
|
void this.router.navigate(['/setup/topology/agents'], {
|
||||||
|
queryParams: { agentId: id },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openTarget(targetId: string): void {
|
openTarget(targetId: string): void {
|
||||||
void this.router.navigate(['/setup/topology/targets'], { queryParams: { targetId } });
|
void this.router.navigate(['/setup/topology/targets'], {
|
||||||
|
queryParams: { targetId },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private load(): void {
|
private load(): void {
|
||||||
@@ -624,4 +636,3 @@ export class TopologyOverviewPageComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ interface PathRow extends TopologyPromotionPath {
|
|||||||
<td>{{ entry.inbound }}</td>
|
<td>{{ entry.inbound }}</td>
|
||||||
<td>{{ entry.outbound }}</td>
|
<td>{{ entry.outbound }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a [routerLink]="['/setup/topology/environments', entry.environmentId, 'posture']">Open</a>
|
<a [routerLink]="['/setup/topology/environments', entry.environmentId, 'posture']" queryParamsHandling="merge">Open</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
|||||||
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
||||||
<td>{{ env.targetCount }}</td>
|
<td>{{ env.targetCount }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']">Open</a>
|
<a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
@@ -126,7 +126,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
|||||||
<td>{{ env.environmentType }}</td>
|
<td>{{ env.environmentType }}</td>
|
||||||
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
||||||
<td>{{ env.targetCount }}</td>
|
<td>{{ env.targetCount }}</td>
|
||||||
<td><a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']">Open</a></td>
|
<td><a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
@@ -158,10 +158,10 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
|||||||
· targets {{ selectedEnvironmentTargetCount() }}
|
· targets {{ selectedEnvironmentTargetCount() }}
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']">Open Environment</a>
|
<a [routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']" queryParamsHandling="merge">Open Environment</a>
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Targets</a>
|
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Targets</a>
|
||||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Agents</a>
|
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Agents</a>
|
||||||
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Runs</a>
|
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Runs</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,16 +50,16 @@ import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-n
|
|||||||
})
|
})
|
||||||
export class TopologyShellComponent {
|
export class TopologyShellComponent {
|
||||||
readonly tabs: TabItem[] = [
|
readonly tabs: TabItem[] = [
|
||||||
{ id: 'overview', label: 'Overview', route: 'overview' },
|
{ id: 'overview', label: 'Overview', route: 'overview', queryParamsHandling: 'merge' },
|
||||||
{ id: 'map', label: 'Map', route: 'map' },
|
{ id: 'map', label: 'Map', route: 'map', queryParamsHandling: 'merge' },
|
||||||
{ id: 'regions', label: 'Regions & Environments', route: 'regions' },
|
{ id: 'regions', label: 'Regions & Environments', route: 'regions', queryParamsHandling: 'merge' },
|
||||||
{ id: 'targets', label: 'Targets', route: 'targets' },
|
{ id: 'targets', label: 'Targets', route: 'targets', queryParamsHandling: 'merge' },
|
||||||
{ id: 'hosts', label: 'Hosts', route: 'hosts' },
|
{ id: 'hosts', label: 'Hosts', route: 'hosts', queryParamsHandling: 'merge' },
|
||||||
{ id: 'agents', label: 'Agents', route: 'agents' },
|
{ id: 'agents', label: 'Agents', route: 'agents', queryParamsHandling: 'merge' },
|
||||||
{ id: 'promotion', label: 'Promotion Graph', route: 'promotion-graph' },
|
{ id: 'promotion', label: 'Promotion Graph', route: 'promotion-graph', queryParamsHandling: 'merge' },
|
||||||
{ id: 'workflows', label: 'Workflows', route: 'workflows' },
|
{ id: 'workflows', label: 'Workflows', route: 'workflows', queryParamsHandling: 'merge' },
|
||||||
{ id: 'gate-profiles', label: 'Gate Profiles', route: 'gate-profiles' },
|
{ id: 'gate-profiles', label: 'Gate Profiles', route: 'gate-profiles', queryParamsHandling: 'merge' },
|
||||||
{ id: 'connectivity', label: 'Connectivity', route: 'connectivity' },
|
{ id: 'connectivity', label: 'Connectivity', route: 'connectivity', queryParamsHandling: 'merge' },
|
||||||
{ id: 'drift', label: 'Runtime Drift', route: 'runtime-drift' },
|
{ id: 'drift', label: 'Runtime Drift', route: 'runtime-drift', queryParamsHandling: 'merge' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
<p>Host: {{ selectedHostName() }}</p>
|
<p>Host: {{ selectedHostName() }}</p>
|
||||||
<p>Agent: {{ selectedAgentName() }}</p>
|
<p>Agent: {{ selectedAgentName() }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/hosts']" [queryParams]="{ hostId: selectedTarget()!.hostId }">Open Host</a>
|
<a [routerLink]="['/setup/topology/hosts']" [queryParams]="{ hostId: selectedTarget()!.hostId }" queryParamsHandling="merge">Open Host</a>
|
||||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedTarget()!.agentId }">Open Agent</a>
|
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedTarget()!.agentId }" queryParamsHandling="merge">Open Agent</a>
|
||||||
<a [routerLink]="['/ops/integrations']">Go to Integrations</a>
|
<a [routerLink]="['/ops/integrations']" queryParamsHandling="merge">Go to Integrations</a>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="muted">Select a target row to view its topology mapping details.</p>
|
<p class="muted">Select a target row to view its topology mapping details.</p>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { QueryParamsHandling, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
|
||||||
export interface TabItem {
|
export interface TabItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +15,7 @@ export interface TabItem {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
route?: string | readonly unknown[]; // If set, uses router navigation
|
route?: string | readonly unknown[]; // If set, uses router navigation
|
||||||
queryParams?: Record<string, unknown>;
|
queryParams?: Record<string, unknown>;
|
||||||
|
queryParamsHandling?: QueryParamsHandling;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export interface TabItem {
|
|||||||
class="tabbed-nav__tab"
|
class="tabbed-nav__tab"
|
||||||
[routerLink]="tab.route"
|
[routerLink]="tab.route"
|
||||||
[queryParams]="tab.queryParams"
|
[queryParams]="tab.queryParams"
|
||||||
|
[queryParamsHandling]="tab.queryParamsHandling || null"
|
||||||
routerLinkActive="tabbed-nav__tab--active"
|
routerLinkActive="tabbed-nav__tab--active"
|
||||||
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
||||||
[attr.role]="variant === 'submenu' ? null : 'tab'"
|
[attr.role]="variant === 'submenu' ? null : 'tab'"
|
||||||
|
|||||||
Reference in New Issue
Block a user