fix(web): 收口 HWPOD Node 接入体验
This commit is contained in:
@@ -58,6 +58,9 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat
|
||||
assert.equal(nodeA.diagnostic.message.includes("[REDACTED]"), true);
|
||||
assert.equal(nodeA.diagnostic.valuesRedacted, true);
|
||||
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").status, "ready");
|
||||
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").nextAction, null);
|
||||
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "hwpod-ready").nextAction.entrypoint, "hwpod-topology");
|
||||
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").readiness.find((stage: any) => stage.id === "workspace-ready").blocker.code, "hwpod_node_spec_missing");
|
||||
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").status, "unconfigured");
|
||||
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-c").status, "offline");
|
||||
assert.equal(nodePayload.invalidSpecs[0].blocker.code, "invalid_hwpod_spec");
|
||||
|
||||
@@ -9,6 +9,15 @@ export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1";
|
||||
const DEFAULT_PAGE_LIMIT = 50;
|
||||
const MAX_PAGE_LIMIT = 100;
|
||||
|
||||
export const HWPOD_NODE_READINESS_DEFINITIONS = [
|
||||
{ id: "downloaded", label: "已下载", authority: "user-device", nextAction: { label: "从节点接入页下载并校验已发布 Python 单文件", entrypoint: "cloud-web-hwpod-onboarding" } },
|
||||
{ id: "installed", label: "已安装", authority: "python-node-runtime", nextAction: { label: "按 owning YAML 选择的交互用户与原生 Python 启动节点", entrypoint: "yaml-managed-hwpod-node" } },
|
||||
{ id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime", nextAction: { label: "在目标交互登录会话确认窗口或托盘可见", entrypoint: "hwlab-node-ui" } },
|
||||
{ id: "registered", label: "已注册", authority: "websocket-registry", nextAction: { label: "保持节点运行并通过受控节点状态入口检查注册", entrypoint: "hwpod-node-status" } },
|
||||
{ id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe", nextAction: { label: "通过显式 readiness 探测检查 owning workspace", entrypoint: "hwpod-readiness-probe" } },
|
||||
{ id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology", nextAction: { label: "检查挂载 HWPOD 的 blocker、能力与占用状态", entrypoint: "hwpod-topology" } }
|
||||
] as const;
|
||||
|
||||
export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any = {}, options: any = {}) {
|
||||
const observedAt = text(options.observedAt) || new Date().toISOString();
|
||||
const onlineNodes = normalizeOnlineNodes(registrySnapshot);
|
||||
@@ -267,17 +276,18 @@ function nodeReadiness(input: any) {
|
||||
const localObserved = Boolean(input.onlineNode);
|
||||
const stageBlocker = input.blockers[0] ?? null;
|
||||
return [
|
||||
readinessStage("downloaded", "已下载", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)),
|
||||
readinessStage("installed", "已安装", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)),
|
||||
readinessStage("desktop-visible", "桌面可见", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", "python-node-runtime", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)),
|
||||
readinessStage("registered", "已注册", input.online ? "ready" : "blocked", "websocket-registry", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)),
|
||||
readinessStage("workspace-ready", "工作区就绪", input.workspaceReady ? "ready" : "blocked", "hwpod-spec-probe", input.workspaceReady ? null : stageBlocker),
|
||||
readinessStage("hwpod-ready", "HWPOD 就绪", input.hwpodReady && !input.busy ? "ready" : "blocked", "hwpod-topology", input.hwpodReady && !input.busy ? null : stageBlocker)
|
||||
readinessStage("downloaded", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)),
|
||||
readinessStage("installed", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)),
|
||||
readinessStage("desktop-visible", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)),
|
||||
readinessStage("registered", input.online ? "ready" : "blocked", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)),
|
||||
readinessStage("workspace-ready", input.workspaceReady ? "ready" : "blocked", input.workspaceReady ? null : stageBlocker ?? blocker("hwpod_workspace_not_ready", "工作区尚未通过显式 readiness 探测。", true)),
|
||||
readinessStage("hwpod-ready", input.hwpodReady && !input.busy ? "ready" : "blocked", input.hwpodReady && !input.busy ? null : stageBlocker ?? blocker("hwpod_not_ready", "尚未观测到可用且空闲的挂载 HWPOD。", true))
|
||||
];
|
||||
}
|
||||
|
||||
function readinessStage(id: string, label: string, status: string, authority: string, stageBlocker: any) {
|
||||
return { id, label, status, authority, blocker: stageBlocker };
|
||||
function readinessStage(id: string, status: string, stageBlocker: any) {
|
||||
const definition = HWPOD_NODE_READINESS_DEFINITIONS.find((stage) => stage.id === id)!;
|
||||
return { ...definition, status, blocker: stageBlocker, nextAction: status === "ready" ? null : definition.nextAction };
|
||||
}
|
||||
|
||||
function invalidSpecSummary(spec: any) {
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
hwpodSpecWorkspaceProbePlan
|
||||
} from "./hwpod-spec-discovery.ts";
|
||||
import {
|
||||
buildHwpodTopologyReadModel
|
||||
buildHwpodTopologyReadModel,
|
||||
HWPOD_NODE_READINESS_DEFINITIONS
|
||||
} from "./hwpod-topology-read-model.ts";
|
||||
import {
|
||||
getHeader,
|
||||
@@ -231,14 +232,7 @@ function hwlabNodeReleaseNotes(env) {
|
||||
}
|
||||
|
||||
function hwlabNodeReadinessStages() {
|
||||
return [
|
||||
{ id: "downloaded", label: "已下载", authority: "user-device" },
|
||||
{ id: "installed", label: "已安装", authority: "python-node-runtime" },
|
||||
{ id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime" },
|
||||
{ id: "registered", label: "已注册", authority: "websocket-registry" },
|
||||
{ id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe" },
|
||||
{ id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology" }
|
||||
];
|
||||
return HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } }));
|
||||
}
|
||||
|
||||
function hwlabNodeDownloadUrl(env) {
|
||||
|
||||
@@ -61,7 +61,7 @@ const formatSize = (value: number): string =>
|
||||
v-if="metadata?.artifact.downloadUrl"
|
||||
class="download-button"
|
||||
:href="metadata.artifact.downloadUrl"
|
||||
download="hwlab-node.py"
|
||||
:download="metadata.artifact.fileName"
|
||||
>下载 Python 单文件</a>
|
||||
<p v-else class="download-blocked">发布 artifact 当前不可用。</p>
|
||||
|
||||
@@ -92,29 +92,21 @@ const formatSize = (value: number): string =>
|
||||
<ol class="install-steps">
|
||||
<li>
|
||||
<strong>下载并固定文件</strong>
|
||||
<p>将单文件保存到用户可写目录,不要从浏览器临时目录长期运行。</p>
|
||||
<p>将单文件保存到 owning YAML 允许的用户可写目录,不要从浏览器临时目录长期运行。</p>
|
||||
<details>
|
||||
<summary>查看文件建议</summary>
|
||||
<code>mkdir C:\HWLAB</code>
|
||||
<code>move .\{{ metadata?.artifact.fileName || "hwlab-node.py" }} C:\HWLAB\hwlab-node.py</code>
|
||||
<summary>查看配置边界</summary>
|
||||
<span>实际运行目录、交互用户和工作区根由 owning YAML 与受控节点配置拥有,网页不猜测目标路径。</span>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<strong>校验 SHA-256</strong>
|
||||
<p>将本机结果与页面上方发布 SHA 完整比对,不一致时停止运行并重新下载。</p>
|
||||
<details>
|
||||
<summary>查看 PowerShell 命令</summary>
|
||||
<code>Get-FileHash C:\HWLAB\hwlab-node.py -Algorithm SHA256</code>
|
||||
</details>
|
||||
<details><summary>查看校验要求</summary><span>使用 Windows 本机文件哈希工具校验实际下载文件,文件名以发布元数据为准。</span></details>
|
||||
</li>
|
||||
<li>
|
||||
<strong>在交互用户会话启动</strong>
|
||||
<p>使用本机 Python 3 启动图形节点,保持窗口或托盘程序运行。</p>
|
||||
<details>
|
||||
<summary>查看启动命令</summary>
|
||||
<code>py -3 C:\HWLAB\hwlab-node.py</code>
|
||||
<code>python C:\HWLAB\hwlab-node.py</code>
|
||||
</details>
|
||||
<p>使用 owning YAML 选择的 Windows 交互用户与原生 Python 启动图形节点,保持窗口或托盘程序运行。</p>
|
||||
<details><summary>查看启动边界</summary><span>不得使用 WSL Python、SSH 辅助解释器、Bun 运行器或非交互 Windows 服务冒充完成态。</span></details>
|
||||
</li>
|
||||
<li>
|
||||
<strong>选择 Node 并观察 readiness</strong>
|
||||
|
||||
@@ -17,6 +17,7 @@ defineProps<{ stages: HwpodNodeReadinessStage[] }>();
|
||||
<strong>{{ stage.label }}</strong>
|
||||
<small>{{ stage.authority }}</small>
|
||||
<p v-if="stage.blocker">{{ stage.blocker.summary }}</p>
|
||||
<p v-if="stage.nextAction" class="stage-action"><strong>下一步</strong>{{ stage.nextAction.label }}<code>{{ stage.nextAction.entrypoint }}</code></p>
|
||||
</div>
|
||||
<span class="stage-status">{{ stage.status === 'ready' ? '已就绪' : stage.status === 'blocked' ? '阻塞' : '待观测' }}</span>
|
||||
</li>
|
||||
@@ -33,6 +34,9 @@ li[data-status="blocked"] .stage-index { border-color: #b46b4b; background: #fff
|
||||
.readiness-rail strong { display: block; color: #1d302c; font-size: .86rem; }
|
||||
.readiness-rail small { color: #71807b; font: .67rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.readiness-rail p { margin: 4px 0 0; color: #79503b; font-size: .75rem; line-height: 1.4; }
|
||||
.stage-action { display: grid; gap: 2px; color: #31564d !important; }
|
||||
.stage-action strong { font-size: .7rem; }
|
||||
.stage-action code { color: #687872; font-size: .66rem; overflow-wrap: anywhere; }
|
||||
.stage-status { margin-top: 4px; color: #5e6f69; font-size: .7rem; font-weight: 800; }
|
||||
li[data-status="ready"] .stage-status { color: #2c7567; }
|
||||
li[data-status="blocked"] .stage-status { color: #9b4f2d; }
|
||||
|
||||
@@ -26,6 +26,7 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
const consoleLoading = ref(false);
|
||||
const consoleError = ref<string | null>(null);
|
||||
const consoleStale = ref(false);
|
||||
let consoleRequestId = 0;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -38,12 +39,14 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
}
|
||||
|
||||
async function refreshConsole(resource: "device" | "node" | "onboarding", query: TopologyQuery = {}): Promise<void> {
|
||||
const requestId = ++consoleRequestId;
|
||||
consoleLoading.value = true;
|
||||
consoleError.value = null;
|
||||
consoleStale.value = false;
|
||||
try {
|
||||
if (resource === "device") {
|
||||
const result = await hwpodAPI.devices(query);
|
||||
if (requestId !== consoleRequestId) return;
|
||||
if (result.ok && result.data) deviceTopology.value = result.data;
|
||||
else {
|
||||
consoleError.value = result.error ?? "HWPOD 设备拓扑不可用";
|
||||
@@ -51,6 +54,7 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
}
|
||||
} else if (resource === "node") {
|
||||
const result = await hwpodAPI.nodes(query);
|
||||
if (requestId !== consoleRequestId) return;
|
||||
if (result.ok && result.data) nodeTopology.value = result.data;
|
||||
else {
|
||||
consoleError.value = result.error ?? "HWPOD Node 拓扑不可用";
|
||||
@@ -58,6 +62,7 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
}
|
||||
} else {
|
||||
const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]);
|
||||
if (requestId !== consoleRequestId) return;
|
||||
if (nodesResult.ok && nodesResult.data) nodeTopology.value = nodesResult.data;
|
||||
if (updateResult.ok && updateResult.data) nodeUpdate.value = updateResult.data;
|
||||
if (!nodesResult.ok || !updateResult.ok) {
|
||||
@@ -66,10 +71,11 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
if (requestId !== consoleRequestId) return;
|
||||
consoleError.value = cause instanceof Error ? cause.message : "HWPOD 控制台刷新失败";
|
||||
consoleStale.value = resource === "device" ? Boolean(deviceTopology.value) : resource === "node" ? Boolean(nodeTopology.value) : Boolean(nodeTopology.value || nodeUpdate.value);
|
||||
} finally {
|
||||
consoleLoading.value = false;
|
||||
if (requestId === consoleRequestId) consoleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +454,7 @@ export interface HwpodNodeReadinessStage {
|
||||
status: HwpodReadinessStatus;
|
||||
authority: string;
|
||||
blocker: HwpodBlocker | null;
|
||||
nextAction: { label: string; entrypoint: string } | null;
|
||||
}
|
||||
|
||||
export interface HwpodNodeTopologyItem {
|
||||
@@ -511,7 +512,7 @@ export interface HwlabNodeUpdateMetadata {
|
||||
releaseNotes: string[];
|
||||
releaseNotesUrl: string | null;
|
||||
installConditions: Array<{ id: string; label: string; required: boolean }>;
|
||||
readinessStages: Array<{ id: string; label: string; authority: string }>;
|
||||
readinessStages: Array<{ id: string; label: string; authority: string; nextAction: { label: string; entrypoint: string } }>;
|
||||
observedAt: string;
|
||||
}
|
||||
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
|
||||
|
||||
@@ -34,7 +34,7 @@ const tab = computed<ConsoleTab>(() => {
|
||||
return value === "nodes" || value === "onboarding" ? value : "devices";
|
||||
});
|
||||
const pathEntityId = computed(() => tab.value === "devices" ? text(route.params.hwpodId) : text(route.params.nodeId));
|
||||
const effectiveQuery = computed(() => viewState.q.value || pathEntityId.value);
|
||||
const effectiveQuery = computed(() => pathEntityId.value || viewState.q.value);
|
||||
const effectiveCursor = computed(() => pathEntityId.value ? "" : viewState.cursor.value);
|
||||
const view = viewState.view;
|
||||
const density = viewState.density;
|
||||
@@ -51,7 +51,7 @@ const onboardingStages = computed<HwpodNodeReadinessStage[]>(() => selectedNode.
|
||||
const artifact = computed(() => hwpod.nodeUpdate?.artifact ?? null);
|
||||
const sortField = computed(() => viewState.sort.value.replace(/^-/, "") || "name");
|
||||
const sortDirection = computed<"asc" | "desc">(() => viewState.sort.value.startsWith("-") ? "desc" : "asc");
|
||||
const hasContent = computed(() => tab.value === "devices" ? devices.value.length > 0 : tab.value === "nodes" ? nodes.value.length > 0 : Boolean(hwpod.nodeUpdate));
|
||||
const hasContent = computed(() => tab.value === "devices" ? devices.value.length > 0 : tab.value === "nodes" ? nodes.value.length > 0 : Boolean(hwpod.nodeUpdate) || nodes.value.length > 0);
|
||||
const boundaryState = computed(() => {
|
||||
if (hwpod.consoleError) return hwpod.consoleStale && hasContent.value ? "partial" as const : "error" as const;
|
||||
if (hwpod.consoleLoading) return hasContent.value ? "refreshing" as const : "initial-loading" as const;
|
||||
@@ -99,6 +99,10 @@ function goTab(next: ConsoleTab): void {
|
||||
const query = collectionQuery();
|
||||
delete query.cursor;
|
||||
delete query.filter;
|
||||
if (next === "onboarding") {
|
||||
delete query.q;
|
||||
delete query.sort;
|
||||
}
|
||||
void router.push({ path: `/hwpods/${next}`, query });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user