Fix topology scope hydration and live sweep readiness

This commit is contained in:
master
2026-03-10 14:37:38 +02:00
parent b302a5a3d6
commit ec22b8ee46
4 changed files with 320 additions and 77 deletions

View File

@@ -21,6 +21,39 @@ const topologyScope = {
};
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
const STEP_TIMEOUT_MS = 30_000;
const GENERIC_TITLES = new Set(['StellaOps', 'Stella Ops Dashboard']);
const ROUTE_READINESS = [
{
path: '/setup/topology/overview',
title: 'Topology Overview - StellaOps',
markers: ['Open Promotion Paths', 'Open Agents', 'Top Hotspots'],
},
{
path: '/setup/topology/environments',
title: 'Environments - StellaOps',
markers: ['Environment Signals', 'Open Targets', 'Open Runs'],
},
{
path: '/setup/topology/environments/stage/posture',
title: 'Environment Detail - StellaOps',
markers: ['Operator Actions', 'Open Security Triage', 'Data Quality'],
},
{
path: '/setup/topology/targets',
title: 'Targets - StellaOps',
markers: ['No targets for current filters.', 'Select a target row to view its topology mapping details.'],
},
{
path: '/setup/topology/hosts',
title: 'Hosts - StellaOps',
markers: ['No hosts for current filters.', 'Select a host row to inspect runtime drift and impact.'],
},
{
path: '/setup/topology/agents',
title: 'Agent Fleet - StellaOps',
markers: ['No groups for current filters.', 'All Agents', 'View Targets'],
},
];
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
@@ -61,11 +94,16 @@ function attachRuntimeObservers(page, runtime) {
return;
}
const errorText = request.failure()?.errorText ?? 'unknown';
if (errorText === 'net::ERR_ABORTED') {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url: request.url(),
error: request.failure()?.errorText ?? 'unknown',
error: errorText,
});
});
@@ -85,7 +123,66 @@ function attachRuntimeObservers(page, runtime) {
});
}
async function settle(page) {
function routePath(routeOrPath) {
if (routeOrPath.startsWith('http://') || routeOrPath.startsWith('https://')) {
return new URL(routeOrPath).pathname;
}
return new URL(withScope(routeOrPath)).pathname;
}
function routeReadiness(pathname) {
return ROUTE_READINESS.find((entry) => entry.path === pathname) ?? null;
}
async function waitForRouteReady(page, routeOrPath) {
const expectedPath = routePath(routeOrPath);
const readiness = routeReadiness(expectedPath);
if (!readiness) {
await page.waitForFunction(
({ expectedPathValue, genericTitles }) => {
if (window.location.pathname !== expectedPathValue) {
return false;
}
const title = document.title.trim();
return title.length > 0 && !genericTitles.includes(title);
},
{
expectedPathValue: expectedPath,
genericTitles: [...GENERIC_TITLES],
},
{ timeout: 15_000 },
).catch(() => {});
return;
}
await page.waitForFunction(
({ expectedPathValue, expectedTitle, markers, genericTitles }) => {
if (window.location.pathname !== expectedPathValue) {
return false;
}
const title = document.title.trim();
if (title.length === 0 || genericTitles.includes(title) || title !== expectedTitle) {
return false;
}
const bodyText = document.body?.innerText?.replace(/\s+/g, ' ') ?? '';
return markers.some((marker) => bodyText.includes(marker));
},
{
expectedPathValue: expectedPath,
expectedTitle: readiness.title,
markers: readiness.markers,
genericTitles: [...GENERIC_TITLES],
},
{ timeout: 15_000 },
).catch(() => {});
}
async function settle(page, routeOrPath = null) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(750);
@@ -96,6 +193,11 @@ async function settle(page) {
}
await page.waitForTimeout(750);
if (routeOrPath) {
await waitForRouteReady(page, routeOrPath);
await page.waitForTimeout(250);
}
}
async function headingText(page) {
@@ -146,7 +248,7 @@ async function navigate(page, route) {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
await settle(page, route);
}
function hasExpectedQuery(urlString, expectedQuery = {}) {
@@ -184,7 +286,12 @@ async function resolveLink(page, options) {
async function clickLinkAction(page, route, options) {
await navigate(page, route);
const link = await resolveLink(page, options);
let link = await resolveLink(page, options);
if (!link) {
await page.waitForTimeout(1_000);
await settle(page, route);
link = await resolveLink(page, options);
}
if (!link) {
return {
action: options.action,
@@ -195,7 +302,8 @@ async function clickLinkAction(page, route, options) {
}
await link.click({ timeout: 10_000 });
await settle(page);
await page.waitForURL((url) => url.pathname === options.expectedPath, { timeout: 15_000 }).catch(() => {});
await settle(page, options.expectedPath);
const url = new URL(page.url());
const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery);