fix: add cloud web gate route contract
This commit is contained in:
@@ -32,6 +32,7 @@ diagnostics under secondary routes:
|
||||
| --- | --- | --- | --- |
|
||||
| `/` | Cloud Workbench default. Shows agent conversation, trace, selected resource context, hardware/evidence side panel, and command input. | Yes | Same-origin `/v1`, read-only `/json-rpc`, source/gate report, blocked report. |
|
||||
| `/workbench` | Explicit alias for the default workbench. | Yes, alias | Same as `/`. |
|
||||
| `/gate` | Internal static alias for the read-only gate diagnostics view. | No | Same as `/diagnostics/gate`; source/gate report and same-origin read-only diagnostics only. |
|
||||
| `/diagnostics` | Secondary diagnostics hub that links to status and gate views. | No | Same-origin `/v1` and report fixtures only. |
|
||||
| `/diagnostics/status` | Read-only DEV status: edge, router, API, web, DB readiness, agent service health, and artifact identity. | No | Same-origin `/v1/status` or equivalent read-only status endpoint. |
|
||||
| `/diagnostics/gate` | Read-only gate and M3 diagnostics inherited from `#59`: topology, route, health, blockers, and evidence summary. | No | Gate report and blocked report; read-only `/json-rpc` only when already defined. |
|
||||
@@ -42,6 +43,8 @@ diagnostics under secondary routes:
|
||||
The route relationship is:
|
||||
|
||||
- `/` and `/workbench` are the primary product surface.
|
||||
- `/gate` is a short internal alias for the same hidden gate view as
|
||||
`/diagnostics/gate`; it must not become the default route.
|
||||
- `diagnostics/status` and `diagnostics/gate` are auxiliary views for
|
||||
troubleshooting and acceptance review.
|
||||
- Agent, hardware resources, and trusted records are first-class workbench
|
||||
@@ -151,9 +154,9 @@ Acceptance for the frontend design and later implementation is:
|
||||
|
||||
| Area | Acceptance |
|
||||
| --- | --- |
|
||||
| Default page | `http://74.48.78.17:16666/` opens the Cloud Workbench, not the Gate/M3 Diagnostics dashboard. |
|
||||
| Default page | `http://74.48.78.17:16666/` opens the Cloud Workbench, not the Gate/M3 Diagnostics dashboard; its first screen does not show Gate, `BLOCKED`, `M0-M5`, or acceptance-review copy. |
|
||||
| API status | `http://74.48.78.17:16667` remains the DEV API/edge/status boundary; browser code uses same-origin `/v1` for workbench data and only read-only `/json-rpc` methods for diagnostics/hardware status. |
|
||||
| Routes | `/`, `/workbench`, `/diagnostics`, `/diagnostics/status`, `/diagnostics/gate`, Agent, hardware resources, and trusted records are represented in navigation or route aliases. |
|
||||
| Routes | `/`, `/workbench`, `/gate`, `/diagnostics`, `/diagnostics/status`, `/diagnostics/gate`, Agent, hardware resources, and trusted records are represented in navigation or route aliases. |
|
||||
| Layout | Left activity rail/resource tree, center agent conversation/trace, right hardware/evidence side panel, and bottom command/input area are present on the default workbench. |
|
||||
| Labels | `SOURCE`, `LOCAL`, `DRY-RUN`, `DEV-LIVE`, and `BLOCKED` labels are visible where evidence state is shown and follow the rules above. |
|
||||
| Read-only boundary | Hardware resources and diagnostics are read-only. Live controls are disabled with blocker reasons. |
|
||||
|
||||
@@ -8,7 +8,12 @@ Cloud Workbench is the default user-facing frontend at
|
||||
## Default Route
|
||||
|
||||
- `/` must show the Cloud Workbench, not Gate, status, help, or diagnostics.
|
||||
- The default first screen must not show Gate, `BLOCKED`, `M0-M5`, or
|
||||
acceptance-review copy. Those terms may remain in hidden/internal views.
|
||||
- Gate and diagnostics are secondary routes or right-panel support views.
|
||||
- `/gate` and `/diagnostics/gate` are internal diagnostic aliases served by the
|
||||
same Cloud Web app router. They may carry Gate, blocker, and M0-M5 evidence
|
||||
because they are not the default homepage.
|
||||
- `http://74.48.78.17:16667/` remains the API/edge boundary.
|
||||
- Browser data sources are same-origin `/v1`, read-only `/json-rpc`, source
|
||||
reports, gate reports, and blocked reports.
|
||||
@@ -63,6 +68,11 @@ node web/hwlab-cloud-web/scripts/build.mjs
|
||||
node scripts/dev-cloud-workbench-smoke.mjs --static
|
||||
```
|
||||
|
||||
The static smoke checks both sides of the route contract: `/` remains the
|
||||
user-facing workbench, while `/gate` and `/diagnostics/gate` remain available
|
||||
for internal diagnostics. This PR-class check is intentionally source-only and
|
||||
does not deploy, restart services, or run browser e2e.
|
||||
|
||||
Live checks against `http://74.48.78.17:16666/` are read-only and must report
|
||||
blocked/skip instead of false green when the browser or network path is not
|
||||
available.
|
||||
|
||||
@@ -466,7 +466,8 @@ async function serveCloudWeb() {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
||||
const routePath = url.pathname.replace(/\/+$/, "") || "/";
|
||||
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" ? "index.html" : url.pathname.slice(1);
|
||||
for (const root of roots) {
|
||||
const candidate = path.resolve(root, relativePath);
|
||||
if (!candidate.startsWith(root)) continue;
|
||||
|
||||
@@ -58,6 +58,8 @@ const diagnosticsRequiredTerms = Object.freeze([
|
||||
"patch-panel DO1 -> DI1"
|
||||
]);
|
||||
|
||||
const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]);
|
||||
|
||||
const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]);
|
||||
|
||||
const requiredWiringColumns = Object.freeze([
|
||||
@@ -142,6 +144,8 @@ function runStaticSmoke() {
|
||||
const blockers = [];
|
||||
const files = readStaticFiles();
|
||||
const source = Object.values(files).join("\n");
|
||||
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
|
||||
const buildScript = readText("web/hwlab-cloud-web/scripts/build.mjs");
|
||||
|
||||
addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", {
|
||||
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
|
||||
@@ -177,6 +181,11 @@ function runStaticSmoke() {
|
||||
evidence: diagnosticsRequiredTerms
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: gateRouteAliases
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "feedback-120-wiring-table", wiringTableContract(files.html), "Patch-panel wiring panel is a table with source, target, status, evidence source, and trace/evidence columns.", {
|
||||
blocker: "contract_blocker",
|
||||
evidence: requiredWiringColumns
|
||||
@@ -398,6 +407,15 @@ function gateRetainsDiagnostics(html, app) {
|
||||
return diagnosticsRequiredTerms.every((term) => gateSource.includes(term));
|
||||
}
|
||||
|
||||
function gateRouteAliasesAreServed(app, artifactPublisher, buildScript) {
|
||||
return (
|
||||
/internalGatePathnames\(\)\.has/u.test(app) &&
|
||||
gateRouteAliases.every((route) => app.includes(`"${route}"`)) &&
|
||||
gateRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) &&
|
||||
/\["gate", "diagnostics\/gate"\]/u.test(buildScript)
|
||||
);
|
||||
}
|
||||
|
||||
function wiringTableContract(html) {
|
||||
const wiringPanel = html.match(/<section\b[^>]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? "";
|
||||
return requiredWiringColumns.every((column) => wiringPanel.includes(`<th>${column}</th>`));
|
||||
|
||||
@@ -114,7 +114,7 @@ function initRoutes() {
|
||||
function routeFromLocation() {
|
||||
const hashRoute = window.location.hash.replace(/^#/, "");
|
||||
if (viewIds.has(hashRoute)) return hashRoute;
|
||||
if (window.location.pathname.replace(/\/+$/, "").endsWith("/gate")) return "gate";
|
||||
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
|
||||
return "workspace";
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ async function loadHelpSurface() {
|
||||
el.helpContent.dataset.helpState = "loading";
|
||||
|
||||
try {
|
||||
const response = await fetch("./help.md", { cache: "no-store" });
|
||||
const response = await fetch("/help.md", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`help.md HTTP ${response.status}`);
|
||||
}
|
||||
@@ -960,6 +960,10 @@ function blockerTitle(blocker) {
|
||||
return `阻塞:${scope}`;
|
||||
}
|
||||
|
||||
function internalGatePathnames() {
|
||||
return new Set(["/gate", "/diagnostics/gate"]);
|
||||
}
|
||||
|
||||
function userFacingSummary(text) {
|
||||
return String(text ?? "")
|
||||
.replaceAll("Public DEV route/frp", "公共 DEV 路由/frp")
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HWLAB 云工作台</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script type="module" src="./app.mjs"></script>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<script type="module" src="/app.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="./help.md">
|
||||
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md">
|
||||
<aside class="activity-rail" aria-label="工作台活动栏">
|
||||
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
|
||||
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
|
||||
|
||||
@@ -23,4 +23,10 @@ for (const file of runtimeFiles) {
|
||||
fs.copyFileSync(path.resolve(rootDir, file), targetPath);
|
||||
}
|
||||
|
||||
for (const alias of ["gate", "diagnostics/gate"]) {
|
||||
const targetPath = path.resolve(distDir, alias, "index.html");
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.copyFileSync(path.resolve(rootDir, "index.html"), targetPath);
|
||||
}
|
||||
|
||||
console.log(`hwlab-cloud-web build ok: ${distDir}`);
|
||||
|
||||
@@ -39,11 +39,13 @@ const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-ar
|
||||
const frontendSource = `${html}\n${app}\n${artifactPublisher}`;
|
||||
const workbenchSmoke = runDevCloudWorkbenchStaticSmoke();
|
||||
const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract");
|
||||
const gateAliasCheck = workbenchSmoke.checks.find((check) => check.id === "internal-gate-route-aliases");
|
||||
|
||||
assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2));
|
||||
assert.equal(workbenchSmoke.evidenceLevel, "SOURCE");
|
||||
assert.equal(workbenchSmoke.devLive, false);
|
||||
assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready");
|
||||
assert.equal(gateAliasCheck?.status, "pass", "internal Gate aliases must be directly served");
|
||||
assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending");
|
||||
|
||||
assert.match(html, /HWLAB 云工作台/);
|
||||
@@ -76,6 +78,9 @@ for (const legacyEnglishTitle of [
|
||||
}
|
||||
assert.match(html, /硬件状态:BOX-SIMU \/ Gateway-SIMU \/ hwlab-patch-panel/);
|
||||
assert.match(html, /Gate \/ 诊断 \/ 验收/);
|
||||
assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/);
|
||||
assert.match(html, /href="\/styles\.css"/);
|
||||
assert.match(html, /src="\/app\.mjs"/);
|
||||
for (const workbenchElement of [
|
||||
"activity-rail",
|
||||
"explorer",
|
||||
@@ -103,7 +108,7 @@ assert.match(html, /data-help-route-policy="non-default-internal-help"/);
|
||||
assert.match(app, /from "\.\/third_party\/marked\/marked\.esm\.js"/);
|
||||
assert.match(app, /marked\.parse\(/);
|
||||
assert.doesNotMatch(app, /function\s+parseMarkdown|markedRegex|markdownRegex/);
|
||||
assert.match(app, /fetch\("\.\/help\.md"/);
|
||||
assert.match(app, /fetch\("\/help\.md"/);
|
||||
assert.match(app, /帮助内容加载失败/);
|
||||
assert.match(app, /return "workspace";/);
|
||||
assert.match(styles, /\.help-view\s*{[^}]*overflow:\s*hidden;/s);
|
||||
@@ -159,6 +164,8 @@ assert.match(app, /patch-panel DO1 -> DI1/);
|
||||
assert.match(buildScript, /"help\.md"/);
|
||||
assert.match(buildScript, /"third_party\/marked\/marked\.esm\.js"/);
|
||||
assert.match(buildScript, /"third_party\/marked\/LICENSE"/);
|
||||
assert.match(buildScript, /\["gate", "diagnostics\/gate"\]/);
|
||||
assert.match(buildScript, /alias,\s*"index\.html"/);
|
||||
assert.match(markedLicense, /MarkedJS/);
|
||||
assert.match(markedLicense, /Permission is hereby granted, free of charge/);
|
||||
assert.match(html, /live-status/);
|
||||
@@ -190,6 +197,9 @@ assert.match(artifactPublisher, /"audit\.event\.query"/);
|
||||
assert.match(artifactPublisher, /"evidence\.record\.query"/);
|
||||
assert.match(artifactPublisher, /readonly_rpc_required/);
|
||||
assert.match(artifactPublisher, /cloud_api_proxy_failed/);
|
||||
assert.match(artifactPublisher, /url\.pathname\.replace\(\/\\\/\+\$\/,\s*""\) \|\| "\/"/);
|
||||
assert.match(artifactPublisher, /routePath === "\/gate"/);
|
||||
assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/);
|
||||
assert.match(app, /degraded/);
|
||||
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/);
|
||||
assert.doesNotMatch(app, /callRpc\("hardware\./);
|
||||
|
||||
Reference in New Issue
Block a user