fix: polish hwpod groups and performance probes (#1494)
This commit is contained in:
@@ -311,6 +311,17 @@ export async function runProbe(args) {
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
const finalUrl = page ? page.url() : null;
|
||||
const failureDom = page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null;
|
||||
let screenshotCapture = null;
|
||||
if (page && !page.isClosed()) {
|
||||
try {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
screenshotCapture = { ok: true };
|
||||
} catch (screenshotError) {
|
||||
screenshotCapture = { ok: false, error: screenshotError instanceof Error ? screenshotError.message : String(screenshotError) };
|
||||
}
|
||||
}
|
||||
result = {
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
@@ -322,10 +333,10 @@ export async function runProbe(args) {
|
||||
actions,
|
||||
credentials: args.credentials,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
degradedReason: classifyRunProbeError(error),
|
||||
finalUrl: page ? page.url() : null,
|
||||
failureDom: page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null,
|
||||
artifacts: { reportPath, screenshotPath }
|
||||
degradedReason: classifyRunProbeError(error, { finalUrl, failureDom }),
|
||||
finalUrl,
|
||||
failureDom,
|
||||
artifacts: { reportPath, screenshotPath, screenshotCapture }
|
||||
};
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
@@ -889,12 +900,16 @@ async function submitPrompt(page, args, actions) {
|
||||
const targetConversationId = firstNonEmpty(fresh?.alignment?.check?.conversationId, fresh?.alignment?.after?.routeConversationId, fresh?.after?.routeConversationId);
|
||||
if (targetConversationId) await ensureConversationAligned(page, args, targetConversationId, actions, "pre-submit-realign");
|
||||
await page.locator("#command-input").fill(args.message);
|
||||
await page.waitForFunction((expected) => {
|
||||
const enabled = await page.waitForFunction((expected) => {
|
||||
const input = document.querySelector("#command-input");
|
||||
const send = document.querySelector("#command-send");
|
||||
return input?.value === expected && send && !send.disabled;
|
||||
}, args.message, { timeout: Math.min(args.timeoutMs, 5000) });
|
||||
}, args.message, { timeout: Math.min(args.timeoutMs, 5000) }).catch(() => null);
|
||||
const afterFill = await collectCommandState(page);
|
||||
if (!enabled) {
|
||||
const session = await collectSessionState(page, args).catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
|
||||
throw new Error(`command composer is not ready for submit: ${JSON.stringify({ readyBefore, afterFill, session: compactSessionState(session) })}`);
|
||||
}
|
||||
const submittedAt = new Date().toISOString();
|
||||
await page.locator("#command-send").click();
|
||||
const afterClick = await collectCommandState(page);
|
||||
@@ -1132,8 +1147,14 @@ function runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyRunProbeError(error) {
|
||||
function classifyRunProbeError(error, context = {}) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const finalUrl = typeof context.finalUrl === "string" ? context.finalUrl : "";
|
||||
const failureDom = context.failureDom && typeof context.failureDom === "object" ? context.failureDom : {};
|
||||
const login = failureDom.login && typeof failureDom.login === "object" ? failureDom.login : {};
|
||||
if (/\/login(?:\?|$)/u.test(finalUrl) || failureDom.authState === "login" || login.cardVisible === true) return "auth-redirect-login";
|
||||
if (/session_required|session-not-selected/u.test(message)) return "session-not-selected";
|
||||
if (/composer|command-send|command bar|disabledReason|button_disabled/u.test(message)) return "composer-disabled";
|
||||
if (/fresh session/u.test(message)) return "fresh-session-not-aligned";
|
||||
if (/login|auth/u.test(message)) return "auth-login-failed";
|
||||
if (/Timeout|timed out|timeout/u.test(message)) return "browser-timeout";
|
||||
@@ -1159,12 +1180,13 @@ async function waitForCommandReady(page, args, actions) {
|
||||
const ready = await page.waitForFunction(() => {
|
||||
const input = document.querySelector("#command-input");
|
||||
const send = document.querySelector("#command-send");
|
||||
return Boolean(input && send && !input.disabled);
|
||||
const warning = document.querySelector(".composer-warning")?.textContent?.trim() ?? "";
|
||||
return Boolean(input && send && !input.disabled && !warning);
|
||||
}, null, { timeout: timeoutMs }).catch(() => null);
|
||||
const after = await collectCommandState(page);
|
||||
const result = { action: "command-ready", ready: Boolean(ready), timeoutMs, before, after };
|
||||
actions.push(result);
|
||||
if (!ready) throw new Error(`command bar is not ready for submit: ${JSON.stringify(result)}`);
|
||||
if (!ready) throw new Error(`command composer is not ready for submit: ${JSON.stringify(result)}`);
|
||||
return after;
|
||||
}
|
||||
|
||||
@@ -1179,6 +1201,9 @@ async function collectCommandState(page) {
|
||||
inputLength: input && typeof input.value === "string" ? input.value.length : null,
|
||||
sendVisible: Boolean(send),
|
||||
sendDisabled: send ? Boolean(send.disabled) : null,
|
||||
sendAction: send?.getAttribute("data-action") ?? null,
|
||||
disabledReason: document.querySelector(".composer-warning")?.textContent?.trim() ?? null,
|
||||
modeText: document.querySelector(".composer-mode")?.textContent?.trim() ?? null,
|
||||
sendText: send?.textContent?.trim() ?? null,
|
||||
formTitle: form?.getAttribute("title") ?? null,
|
||||
messageCount: document.querySelectorAll(".message-card").length,
|
||||
|
||||
@@ -142,13 +142,87 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
|
||||
if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } });
|
||||
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
|
||||
if (path === "/v1/hwpod/specs") return json(response, 200, { specs: [] });
|
||||
if (path === "/v1/hwpod-node-ops") return json(response, 200, { ok: true, status: "ready", events: [], groups: [] });
|
||||
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
|
||||
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
|
||||
if (path === "/v1/web-performance/summary") return json(response, 200, webPerformanceSummaryPayload());
|
||||
if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } });
|
||||
|
||||
return staticFile(response, path);
|
||||
}
|
||||
|
||||
function hwpodSpecsPayload(): JsonRecord {
|
||||
return {
|
||||
ok: true,
|
||||
status: "completed",
|
||||
count: 1,
|
||||
availableCount: 0,
|
||||
nodeOpsContractVersion: "hwpod-node-ops-v1",
|
||||
specs: [
|
||||
{
|
||||
name: "d601-f103-v2",
|
||||
hwpodId: "d601-f103-v2",
|
||||
nodeId: "node-d601-f103-v2",
|
||||
workspacePath: "F:\\Work\\D601-HWLAB",
|
||||
source: { caseId: "d601-f103-v2-compile" },
|
||||
availability: {
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
checkedAt: "2026-06-18T04:30:00.000Z",
|
||||
results: [
|
||||
{ opId: "op_01_workspace_ls", op: "workspace.ls", ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "no outbound WebSocket hwpod-node is connected" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function hwpodNodeOpsPayload(): JsonRecord {
|
||||
return {
|
||||
ok: true,
|
||||
status: "ready",
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
route: "/v1/hwpod-node-ops",
|
||||
supportedOps: ["node.health", "workspace.ls", "debug.build", "debug.flash"],
|
||||
websocket: { connectedCount: 0, pendingCount: 1, nodes: [] },
|
||||
events: [
|
||||
{ ts: "2026-06-18T04:30:00.000Z", status: "blocked", blocker: { code: "hwpod_node_unavailable", summary: "no outbound WebSocket hwpod-node is connected" } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function webPerformanceSummaryPayload(): JsonRecord {
|
||||
const rows = [
|
||||
performanceRow({ kind: "workbench-journey", metric: "session-switch-full-load", route: "/workbench/sessions/:sessionId", count: 21, p50: 0.18, p75: 0.31, p95: 1.12, tone: "warn", backend: "agentrun", transport: "sse", targetState: "completed" }),
|
||||
performanceRow({ kind: "workbench-event-phase", metric: "trace-event-projected", route: "/workbench", count: 28, p50: 0.08, p75: 0.14, p95: 0.42, tone: "ok", eventType: "tool_result", phase: "render", source: "rum" }),
|
||||
performanceRow({ kind: "workbench-backend-event", metric: "backend-event-visible", route: "/v1/workbench/events", count: 15, p50: 0.2, p75: 0.34, p95: 1.38, tone: "warn", eventType: "agentrun.stdout", backend: "agentrun", cache: "miss" }),
|
||||
performanceRow({ kind: "web-vital", metric: "LCP", route: "/performance", count: 9, p50: 1.2, p75: 1.8, p95: 2.9, tone: "ok" }),
|
||||
performanceRow({ kind: "api", metric: "fetchJson", route: "/v1/web-performance/summary", method: "GET", statusClass: "2xx", outcome: "ok", count: 17, p50: 0.09, p75: 0.15, p95: 0.33, tone: "ok" }),
|
||||
performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy" })
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
status: "ready",
|
||||
source: "fake-server",
|
||||
observedAt: "2026-06-18T04:30:00.000Z",
|
||||
namespace: "hwlab-v03",
|
||||
gitopsTarget: "D601/v03",
|
||||
summary: { status: "ready", sampleCount: 93, sampleSeries: 12, problemCount: 2, lowSampleThreshold: 5, workbenchJourneySeries: 1, workbenchEventPhaseSeries: 1, workbenchBackendEventVisibleSeries: 1 },
|
||||
rows,
|
||||
problems: rows.filter((row) => row.tone === "warn"),
|
||||
webVitals: rows.filter((row) => row.kind === "web-vital"),
|
||||
apiRoutes: rows.filter((row) => row.kind === "api"),
|
||||
longTasks: rows.filter((row) => row.kind === "long-task"),
|
||||
workbenchJourneys: rows.filter((row) => row.kind === "workbench-journey"),
|
||||
workbenchEventPhases: rows.filter((row) => row.kind === "workbench-event-phase"),
|
||||
workbenchBackendEvents: rows.filter((row) => row.kind === "workbench-backend-event")
|
||||
};
|
||||
}
|
||||
|
||||
function performanceRow(row: JsonRecord): JsonRecord {
|
||||
return { method: "GET", statusClass: "2xx", outcome: "ok", unit: "seconds", sampleState: "ok", ...row };
|
||||
}
|
||||
|
||||
function createScenarioState(scenarioId: string): ScenarioState {
|
||||
const base = structuredClone(capture.scenario);
|
||||
const id = scenarioId || "baseline";
|
||||
|
||||
@@ -1780,8 +1780,11 @@
|
||||
}
|
||||
|
||||
.data-table-wrap {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@@ -2305,10 +2308,91 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hwpod-groups-section,
|
||||
.hwpod-raw-panel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.hwpod-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hwpod-node-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 10px;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.hwpod-node-row strong,
|
||||
.hwpod-node-row small,
|
||||
.hwpod-node-row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hwpod-node-row small,
|
||||
.hwpod-node-row code {
|
||||
grid-column: 1 / -1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.hwpod-op-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hwpod-op-list code {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hwpod-blocker {
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.performance-wide-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.performance-page .table-page-layout {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.performance-page .data-table-wrap {
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.performance-page .data-table {
|
||||
width: max-content;
|
||||
min-width: 780px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.performance-page .data-table th,
|
||||
.performance-page .data-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.performance-page .performance-dimensions {
|
||||
min-width: 220px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.performance-dimensions {
|
||||
display: block;
|
||||
max-width: 360px;
|
||||
|
||||
@@ -1,20 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import { useHwpodStore } from "@/stores/hwpod";
|
||||
|
||||
const hwpod = useHwpodStore();
|
||||
const showRawJson = ref(false);
|
||||
|
||||
const nodeOps = computed(() => asRecord(hwpod.nodeOps));
|
||||
const specs = computed(() => asRecord(hwpod.specs));
|
||||
const websocket = computed(() => asRecord(nodeOps.value.websocket));
|
||||
const supportedOps = computed(() => stringArray(nodeOps.value.supportedOps ?? nodeOps.value.ops));
|
||||
const nodeRows = computed(() => normalizeNodeRows(nodeOps.value, specs.value));
|
||||
const blockerSummary = computed(() => collectBlockers([nodeOps.value, specs.value, websocket.value, ...nodeRows.value]).join(" / "));
|
||||
const rawPayload = computed(() => JSON.stringify({ nodeOps: nodeOps.value, specs: specs.value }, null, 2));
|
||||
|
||||
onMounted(() => void hwpod.refresh());
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(asRecord).filter((item) => Object.keys(item).length > 0) : [];
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function firstText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function countText(value: unknown): string {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? String(number) : "0";
|
||||
}
|
||||
|
||||
function normalizeNodeRows(ops: Record<string, unknown>, specPayload: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const wsNodes: Record<string, unknown>[] = asRecordArray(asRecord(ops.websocket).nodes);
|
||||
const directNodes: Record<string, unknown>[] = asRecordArray(ops.nodes);
|
||||
const groupNodes: Record<string, unknown>[] = asRecordArray(ops.groups).flatMap((group) => asRecordArray(group.nodes).map((node): Record<string, unknown> => ({ ...node, group: firstText(group.name, group.id) })));
|
||||
const specNodes: Record<string, unknown>[] = asRecordArray(specPayload.specs).map((spec): Record<string, unknown> => ({
|
||||
...spec,
|
||||
name: firstText(spec.name, spec.hwpodId),
|
||||
nodeId: firstText(spec.nodeId, spec.hwpodNodeId, spec.hwpodId),
|
||||
status: firstText(asRecord(spec.availability).status, spec.status)
|
||||
}));
|
||||
const seen = new Set<string>();
|
||||
return [...wsNodes, ...directNodes, ...groupNodes, ...specNodes].filter((node) => {
|
||||
const key = firstText(node.nodeId, node.id, node.name, node.hwpodId);
|
||||
if (key === "-" || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function nodeStatus(node: Record<string, unknown>): string {
|
||||
return firstText(node.status, asRecord(node.availability).status, node.ready === true ? "ready" : null, node.connected === true ? "connected" : null);
|
||||
}
|
||||
|
||||
function collectBlockers(values: unknown[]): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (value: unknown): void => {
|
||||
if (out.length >= 6 || value === null || value === undefined) return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") return;
|
||||
const record = value as Record<string, unknown>;
|
||||
const blocker = asRecord(record.blocker);
|
||||
const summary = firstText(record.blockedReason, record.reason, record.error, blocker.summary, blocker.code);
|
||||
if (summary !== "-" && !out.includes(summary)) out.push(summary);
|
||||
visit(record.results);
|
||||
visit(record.availability);
|
||||
visit(record.events);
|
||||
};
|
||||
values.forEach(visit);
|
||||
return out;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack">
|
||||
<PageHeader eyebrow="Admin" title="HWPOD 分组" description="HWPOD groups/resource pool 的管理骨架。" />
|
||||
<section class="route-stack hwpod-groups-page">
|
||||
<PageHeader eyebrow="Admin" title="HWPOD 分组" description="HWPOD node-ops 合约、连接状态与节点可用性。" />
|
||||
<LoadingState v-if="hwpod.loading" />
|
||||
<EmptyState v-else-if="hwpod.error" title="HWPOD 加载失败" :description="hwpod.error" />
|
||||
<EmptyState v-else title="HWPOD group 管理扩展位" description="等待后端 group API;当前保留 node-ops/specs 可见性。" />
|
||||
<pre class="json-panel">{{ JSON.stringify(hwpod.nodeOps || hwpod.specs || { status: 'empty' }, null, 2) }}</pre>
|
||||
<template v-else>
|
||||
<section class="system-summary-grid" aria-label="HWPOD node-ops summary">
|
||||
<article class="metric-card"><span>Contract</span><strong>{{ firstText(nodeOps.contractVersion, specs.nodeOpsContractVersion, 'hwpod-node-ops-v1') }}</strong><small>{{ firstText(nodeOps.status, 'unknown') }}</small></article>
|
||||
<article class="metric-card"><span>Route</span><strong>{{ firstText(nodeOps.route, '/v1/hwpod-node-ops') }}</strong><small>node-ops endpoint</small></article>
|
||||
<article class="metric-card"><span>WebSocket</span><strong>{{ countText(websocket.connectedCount) }} / {{ countText(websocket.pendingCount) }}</strong><small>connected / pending</small></article>
|
||||
<article class="metric-card"><span>Ops</span><strong>{{ supportedOps.length }}</strong><small>supported operations</small></article>
|
||||
</section>
|
||||
|
||||
<section class="data-panel hwpod-groups-section" aria-label="HWPOD nodes">
|
||||
<header class="panel-header table-page-header">
|
||||
<div>
|
||||
<h2>节点列表</h2>
|
||||
<small>{{ nodeRows.length }} nodes / {{ countText(specs.availableCount) }} available specs</small>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="nodeRows.length" class="hwpod-node-grid">
|
||||
<article v-for="node in nodeRows" :key="firstText(node.nodeId, node.id, node.name, node.hwpodId)" class="hwpod-node-row">
|
||||
<strong>{{ firstText(node.name, node.hwpodId, node.nodeId, node.id) }}</strong>
|
||||
<span class="status-pill" :data-status="nodeStatus(node)">{{ nodeStatus(node) }}</span>
|
||||
<small>{{ firstText(node.nodeId, node.id) }}</small>
|
||||
<code>{{ firstText(node.workspacePath, node.workspace, node.group) }}</code>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无在线 HWPOD node" :description="blockerSummary || 'node-ops 当前没有返回节点;连接数和 pending 状态见上方摘要。'" />
|
||||
<p v-if="blockerSummary" class="muted-box hwpod-blocker">{{ blockerSummary }}</p>
|
||||
</section>
|
||||
|
||||
<section class="data-panel hwpod-groups-section" aria-label="HWPOD supported operations">
|
||||
<header class="panel-header table-page-header">
|
||||
<div>
|
||||
<h2>支持操作</h2>
|
||||
<small>{{ supportedOps.length }} ops exposed by node-ops</small>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="supportedOps.length" class="hwpod-op-list">
|
||||
<code v-for="op in supportedOps" :key="op">{{ op }}</code>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无 supportedOps" description="后端尚未返回支持操作列表。" />
|
||||
</section>
|
||||
|
||||
<section class="data-panel hwpod-raw-panel" aria-label="HWPOD raw payload">
|
||||
<button data-testid="hwpod-raw-json-toggle" class="table-action" type="button" :aria-expanded="showRawJson" @click="showRawJson = !showRawJson">
|
||||
{{ showRawJson ? '隐藏原始 JSON' : '查看原始 JSON' }}
|
||||
</button>
|
||||
<pre v-if="showRawJson" data-testid="hwpod-raw-json" class="json-panel">{{ rawPayload }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("admin HWPOD groups renders node-ops as structured UI before raw JSON is requested", async ({ page }) => {
|
||||
await page.goto("/admin/hwpod-groups");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "HWPOD 分组" })).toBeVisible();
|
||||
await expect(page.getByText("hwpod-node-ops-v1")).toBeVisible();
|
||||
await expect(page.getByText("/v1/hwpod-node-ops")).toBeVisible();
|
||||
await expect(page.getByText("node.health")).toBeVisible();
|
||||
await expect(page.getByText("node-d601-f103-v2")).toBeVisible();
|
||||
|
||||
await expect(page.locator("[data-testid='hwpod-raw-json']")).toHaveCount(0);
|
||||
await expect(page.locator("body")).not.toContainText('"contractVersion"');
|
||||
|
||||
await page.getByTestId("hwpod-raw-json-toggle").click();
|
||||
await expect(page.getByTestId("hwpod-raw-json")).toContainText('"contractVersion"');
|
||||
});
|
||||
|
||||
test("performance tables keep wide columns reachable inside local mobile scrollers", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByText("Problem rows")).toBeVisible();
|
||||
|
||||
const scrollers = await page.locator(".performance-page .data-table-wrap").evaluateAll((items) => items.map((item) => ({
|
||||
clientWidth: Math.round(item.clientWidth),
|
||||
scrollWidth: Math.round(item.scrollWidth),
|
||||
overflowX: getComputedStyle(item).overflowX
|
||||
})));
|
||||
expect(scrollers.length).toBeGreaterThanOrEqual(4);
|
||||
expect(scrollers.every((item) => item.clientWidth <= 390)).toBeTruthy();
|
||||
expect(scrollers.some((item) => item.scrollWidth > item.clientWidth + 120)).toBeTruthy();
|
||||
expect(scrollers.every((item) => item.overflowX === "auto" || item.overflowX === "scroll")).toBeTruthy();
|
||||
|
||||
const documentScrollWidth = await page.evaluate(() => Math.round(document.documentElement.scrollWidth));
|
||||
expect(documentScrollWidth).toBeLessThanOrEqual(390);
|
||||
});
|
||||
Reference in New Issue
Block a user