ccf10af5bd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
349 lines
24 KiB
Vue
349 lines
24 KiB
Vue
<!--
|
|
SPEC: PJ2026-010405 云端控制台; PJ2026-010103 HWPOD服务。
|
|
Implementation reference: draft-2026-07-13-p0-cloud-console.
|
|
-->
|
|
<script setup lang="ts">
|
|
import { Grid2X2, List, Rows3, Rows4 } from "lucide-vue-next";
|
|
import { computed, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
|
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
|
import HwpodDevicesWorkspace from "@/components/hwpod/HwpodDevicesWorkspace.vue";
|
|
import HwpodNodesWorkspace from "@/components/hwpod/HwpodNodesWorkspace.vue";
|
|
import HwpodOnboardingWorkspace from "@/components/hwpod/HwpodOnboardingWorkspace.vue";
|
|
import HwpodOperationsWorkspace from "@/components/hwpod/HwpodOperationsWorkspace.vue";
|
|
import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue";
|
|
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
|
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
|
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
|
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
|
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
|
import { useHwpodStore } from "@/stores/hwpod";
|
|
import type { HwpodDeviceTopologyItem, HwpodNodeReadinessStage } from "@/types";
|
|
|
|
type ConsoleTab = "devices" | "nodes" | "onboarding";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const hwpod = useHwpodStore();
|
|
const viewState = useConsoleViewState({ view: "cards", density: "comfortable", sort: "name" });
|
|
const searchText = ref(viewState.q.value);
|
|
const copied = ref(false);
|
|
const workspaceMode = computed(() => tab.value === "devices" && route.query.workspace === "1" && Boolean(text(route.params.hwpodId)));
|
|
|
|
const tab = computed<ConsoleTab>(() => {
|
|
const value = route.path.split("/")[2] ?? "";
|
|
return value === "nodes" || value === "onboarding" ? value : "devices";
|
|
});
|
|
const pathEntityId = computed(() => tab.value === "devices" ? text(route.params.hwpodId) : text(route.params.nodeId));
|
|
const effectiveQuery = computed(() => pathEntityId.value || viewState.q.value);
|
|
const effectiveCursor = computed(() => pathEntityId.value ? "" : viewState.cursor.value);
|
|
const view = viewState.view;
|
|
const density = viewState.density;
|
|
const devices = computed(() => hwpod.deviceTopology?.items ?? []);
|
|
const nodes = computed(() => hwpod.nodeTopology?.items ?? []);
|
|
const summary = computed(() => tab.value === "devices" ? hwpod.deviceTopology?.summary : hwpod.nodeTopology?.summary);
|
|
const invalidSpecs = computed(() => (tab.value === "devices" ? hwpod.deviceTopology : hwpod.nodeTopology)?.invalidSpecs ?? []);
|
|
const firstInvalidSpec = computed(() => invalidSpecs.value[0] ?? null);
|
|
const selectedDevice = computed(() => devices.value.find((device) => device.hwpodId === text(route.params.hwpodId)) ?? null);
|
|
const selectedNodeByPath = computed(() => nodes.value.find((node) => node.nodeId === text(route.params.nodeId)) ?? null);
|
|
const selectedNode = computed(() => selectedNodeByPath.value);
|
|
const selectedNodeDetail = computed(() => tab.value === "nodes" ? selectedNodeByPath.value : null);
|
|
const onboardingStages = computed<HwpodNodeReadinessStage[]>(() => selectedNode.value?.readiness ?? (hwpod.nodeUpdate?.readinessStages ?? []).map((stage) => ({ ...stage, status: "unknown", blocker: null })));
|
|
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) || 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;
|
|
if (invalidSpecs.value.length > 0) return "partial" as const;
|
|
if (!hasContent.value) return "empty" as const;
|
|
return "ready" as const;
|
|
});
|
|
const statusItems = computed(() => [
|
|
{ id: "owner", label: "owner", value: "cloud-api-hwpod-domain", tone: "info" as const },
|
|
{ id: "nodes", label: "nodes online", value: `${summary.value?.onlineNodeCount ?? 0}/${summary.value?.nodeCount ?? 0}`, tone: (summary.value?.offlineNodeCount ?? 0) > 0 ? "warn" as const : "ok" as const },
|
|
{ id: "hwpods", label: "HWPOD available", value: `${summary.value?.availableDeviceCount ?? 0}/${summary.value?.deviceCount ?? 0}`, tone: (summary.value?.busyDeviceCount ?? 0) > 0 ? "warn" as const : "neutral" as const },
|
|
{ id: "invalid", label: "invalid specs", value: summary.value?.invalidSpecCount ?? 0, tone: (summary.value?.invalidSpecCount ?? 0) > 0 ? "error" as const : "neutral" as const }
|
|
]);
|
|
const statusFilterOptions = computed(() => tab.value === "nodes"
|
|
? [
|
|
{ value: "online", label: "在线" },
|
|
{ value: "offline", label: "离线" }
|
|
]
|
|
: [
|
|
{ value: "online", label: "在线" },
|
|
{ value: "offline", label: "离线" }
|
|
]);
|
|
const collectionPage = computed(() => tab.value === "devices" ? hwpod.deviceTopology?.page : tab.value === "nodes" ? hwpod.nodeTopology?.page : null);
|
|
|
|
watch(viewState.q, (value) => { searchText.value = value; });
|
|
watch([tab, effectiveQuery, viewState.filter, viewState.sort, effectiveCursor], ([currentTab, q, filter, sort, cursor]) => {
|
|
const resource = currentTab === "onboarding" ? "onboarding" : currentTab === "nodes" ? "node" : "device";
|
|
void hwpod.refreshConsole(resource, {
|
|
q,
|
|
status: filter,
|
|
sort: (sort.replace(/^-/, "") || "name") as "name" | "status" | "lastSeenAt" | "deviceCount",
|
|
direction: sort.startsWith("-") ? "desc" : "asc",
|
|
cursor
|
|
});
|
|
}, { immediate: true });
|
|
|
|
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 });
|
|
}
|
|
|
|
function setView(next: "cards" | "list"): void {
|
|
void viewState.update({ view: next });
|
|
}
|
|
|
|
function setDensity(next: "compact" | "comfortable"): void {
|
|
void viewState.update({ density: next });
|
|
}
|
|
|
|
function applySearch(): void {
|
|
void viewState.update({ q: searchText.value.trim() });
|
|
}
|
|
|
|
function setStatusFilter(event: Event): void {
|
|
void viewState.update({ filter: (event.target as HTMLSelectElement).value });
|
|
}
|
|
|
|
function toggleSort(key: string): void {
|
|
const next = sortField.value === key && sortDirection.value === "asc" ? `-${key}` : key;
|
|
void viewState.update({ sort: next });
|
|
}
|
|
|
|
function selectDevice(id: string): void {
|
|
void router.replace({ name: "HwpodDevices", params: { hwpodId: id }, query: collectionQuery() });
|
|
}
|
|
|
|
function closeInspector(): void {
|
|
void router.replace({ name: "HwpodDevices", query: collectionQuery() });
|
|
}
|
|
|
|
function openWorkspace(): void {
|
|
if (!selectedDevice.value) return;
|
|
void router.replace({ name: "HwpodDevices", params: { hwpodId: selectedDevice.value.hwpodId }, query: { workspace: "1", path: "." } });
|
|
}
|
|
|
|
function closeWorkspace(): void {
|
|
void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) } });
|
|
}
|
|
|
|
function navigateWorkspace(path: string): void {
|
|
void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) }, query: { workspace: "1", path } });
|
|
}
|
|
|
|
function selectDeviceItem(device: HwpodDeviceTopologyItem): void {
|
|
selectDevice(device.hwpodId);
|
|
}
|
|
|
|
function selectNodeItem(node: { nodeId: string }): void {
|
|
void router.replace({ name: "HwpodNodes", params: { nodeId: node.nodeId }, query: collectionQuery() });
|
|
}
|
|
|
|
function closeNodeInspector(): void {
|
|
void router.replace({ name: "HwpodNodes", query: collectionQuery() });
|
|
}
|
|
|
|
function setCursor(cursor: string | null): void {
|
|
void viewState.update({ cursor: cursor || "" });
|
|
}
|
|
|
|
function refreshCurrent(): void {
|
|
const resource = tab.value === "onboarding" ? "onboarding" : tab.value === "nodes" ? "node" : "device";
|
|
void hwpod.refreshConsole(resource, { q: effectiveQuery.value, status: viewState.filter.value, sort: sortField.value as "name" | "status" | "lastSeenAt" | "deviceCount", direction: sortDirection.value, cursor: effectiveCursor.value });
|
|
}
|
|
|
|
function probeCurrent(): void {
|
|
const resource = tab.value === "nodes" ? "node" : "device";
|
|
void hwpod.refreshConsole(resource, { q: effectiveQuery.value, status: viewState.filter.value, sort: sortField.value as "name" | "status" | "lastSeenAt" | "deviceCount", direction: sortDirection.value, cursor: effectiveCursor.value, probe: true });
|
|
}
|
|
|
|
function chooseNodeId(nodeId: string): void {
|
|
void router.replace({ name: "HwpodOnboarding", params: nodeId ? { nodeId } : {}, query: collectionQuery() });
|
|
}
|
|
|
|
function collectionQuery() {
|
|
const query = { ...route.query };
|
|
for (const key of Object.keys(query)) if (!["view", "density", "q", "filter", "sort", "cursor"].includes(key)) delete query[key];
|
|
return query;
|
|
}
|
|
|
|
async function copySha(): Promise<void> {
|
|
const sha = artifact.value?.sha256;
|
|
if (!sha) return;
|
|
await navigator.clipboard.writeText(sha);
|
|
copied.value = true;
|
|
window.setTimeout(() => { copied.value = false; }, 1600);
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
return ({ online: "在线", offline: "离线" }[status] ?? status);
|
|
}
|
|
|
|
function formatTime(value: string | null): string {
|
|
if (!value) return "未观测";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
function text(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="hwpod-console" data-testid="hwpod-console">
|
|
<PageCommandBar eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
|
|
<template #actions>
|
|
<nav class="console-tabs" aria-label="HWPOD 子页面">
|
|
<button v-for="item in ([['devices', '设备资源'], ['nodes', 'HWPOD Node'], ['onboarding', '节点接入']] as const)" :key="item[0]" type="button" :data-active="tab === item[0]" :aria-current="tab === item[0] ? 'page' : undefined" @click="goTab(item[0])">
|
|
{{ item[1] }}
|
|
</button>
|
|
</nav>
|
|
</template>
|
|
<template v-if="tab !== 'onboarding'" #filters>
|
|
<form class="console-search" role="search" @submit.prevent="applySearch">
|
|
<label for="hwpod-search">搜索</label>
|
|
<input id="hwpod-search" v-model="searchText" type="search" placeholder="nodeId / hwpodId / 状态" />
|
|
<button type="submit">查询</button>
|
|
</form>
|
|
<label class="status-filter">状态
|
|
<select :value="viewState.filter.value" @change="setStatusFilter">
|
|
<option value="">全部</option>
|
|
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
|
</select>
|
|
</label>
|
|
<button class="probe-button" type="button" title="显式执行 workspace readiness 探测;普通页面读取不会 dispatch operation。" @click="probeCurrent">重新探测</button>
|
|
<ViewModeSwitch :model-value="view" :options="[{ value: 'cards', label: tab === 'devices' ? '电路板卡片' : '卡片', icon: Grid2X2 }, { value: 'list', label: '列表', icon: List }]" label="显示模式" @update:model-value="setView($event as 'cards' | 'list')" />
|
|
<ViewModeSwitch :model-value="density" :options="[{ value: 'comfortable', label: '舒适密度', icon: Rows4 }, { value: 'compact', label: '紧凑密度', icon: Rows3 }]" label="显示密度" @update:model-value="setDensity($event as 'compact' | 'comfortable')" />
|
|
</template>
|
|
</PageCommandBar>
|
|
|
|
<StatusStrip :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
|
|
|
<AsyncBoundary
|
|
:state="boundaryState"
|
|
:has-content="hasContent"
|
|
:error="hwpod.consoleError || 'HWPOD typed topology 当前不可用。'"
|
|
empty-title="没有 HWPOD 资源"
|
|
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
|
|
@retry="refreshCurrent"
|
|
>
|
|
<aside v-if="hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
|
|
<strong>显示最近成功快照</strong>
|
|
<span>{{ hwpod.consoleError }}</span>
|
|
</aside>
|
|
<aside v-if="firstInvalidSpec" class="spec-warning" role="status">
|
|
<strong>{{ invalidSpecs.length }} 份 invalid spec 未进入可用资源</strong>
|
|
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
|
|
</aside>
|
|
|
|
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
|
|
<HwpodOperationsWorkspace v-if="workspaceMode" :hwpod-id="text(route.params.hwpodId)" :initial-path="text(route.query.path) || '.'" @back="closeWorkspace" @navigate="navigateWorkspace" />
|
|
<HwpodDevicesWorkspace v-else-if="tab === 'devices'" :devices="devices" :selected-id="selectedDevice?.hwpodId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectDeviceItem" @sort="toggleSort" />
|
|
<HwpodNodesWorkspace v-else-if="tab === 'nodes'" :nodes="nodes" :selected-id="selectedNodeDetail?.nodeId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectNodeItem" @sort="toggleSort" />
|
|
<HwpodOnboardingWorkspace v-else :metadata="hwpod.nodeUpdate" :nodes="nodes" :selected-node-id="selectedNode?.nodeId || ''" :stages="onboardingStages" :copied="copied" @copy="copySha" @node="chooseNodeId" />
|
|
</BoundedWorkspace>
|
|
<nav v-if="collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
|
|
<span>本页 {{ collectionPage.returned }} / 共 {{ collectionPage.total }}</span>
|
|
<div>
|
|
<button type="button" :disabled="!collectionPage.previousCursor" @click="setCursor(collectionPage.previousCursor)">上一页</button>
|
|
<button type="button" :disabled="!collectionPage.nextCursor" @click="setCursor(collectionPage.nextCursor)">下一页</button>
|
|
</div>
|
|
</nav>
|
|
</AsyncBoundary>
|
|
|
|
<BaseDrawer :open="Boolean(selectedDevice) && !workspaceMode" :title="selectedDevice?.name || 'HWPOD 详情'" description="HWPOD API 返回的 typed DTO。" @close="closeInspector">
|
|
<template v-if="selectedDevice">
|
|
<dl class="resource-details">
|
|
<div><dt>身份</dt><dd>{{ selectedDevice.hwpodId }}</dd></div>
|
|
<div><dt>Node</dt><dd>{{ selectedDevice.nodeId }}</dd></div>
|
|
<div><dt>Spec authority</dt><dd>{{ selectedDevice.source.authority }}</dd></div>
|
|
<div><dt>最近 operation</dt><dd>{{ selectedDevice.operation?.status || '无' }}</dd></div>
|
|
<div><dt>目标板</dt><dd>{{ selectedDevice.elements.target.label }}</dd></div>
|
|
<div><dt>工作区</dt><dd>{{ selectedDevice.elements.workspace.label }}</dd></div>
|
|
<div><dt>调试探头</dt><dd>{{ selectedDevice.elements.debugProbe.label }}</dd></div>
|
|
<div><dt>IO 探头</dt><dd>{{ selectedDevice.elements.ioProbe.label }}</dd></div>
|
|
</dl>
|
|
<h3>缺失能力</h3>
|
|
<div class="capability-list"><code v-for="capability in selectedDevice.missingCapabilities" :key="capability">{{ capability }}</code><span v-if="!selectedDevice.missingCapabilities.length">无</span></div>
|
|
<h3>诊断摘要</h3>
|
|
<p>{{ selectedDevice.diagnostic?.message || selectedDevice.blockers[0]?.summary || '无当前诊断' }}</p>
|
|
<button class="workspace-command" type="button" @click="openWorkspace">打开工作区</button>
|
|
</template>
|
|
</BaseDrawer>
|
|
|
|
<BaseDrawer :open="Boolean(selectedNodeDetail)" :title="selectedNodeDetail?.name || 'HWPOD Node 详情'" description="Node 连接、挂载 HWPOD、并发与 readiness 的 typed DTO。" @close="closeNodeInspector">
|
|
<template v-if="selectedNodeDetail">
|
|
<dl class="resource-details">
|
|
<div><dt>Node ID</dt><dd>{{ selectedNodeDetail.nodeId }}</dd></div>
|
|
<div><dt>运行时</dt><dd>{{ selectedNodeDetail.runtimeKind || '未识别' }} · {{ selectedNodeDetail.version || '未知版本' }}</dd></div>
|
|
<div><dt>in-flight</dt><dd>{{ selectedNodeDetail.inFlight.count }}/{{ selectedNodeDetail.inFlight.max }}</dd></div>
|
|
<div><dt>最近用户 operation</dt><dd>{{ selectedNodeDetail.operation?.status || '无' }}</dd></div>
|
|
<div><dt>最近 readiness probe</dt><dd>{{ selectedNodeDetail.readinessProbe?.status || '未探测' }}</dd></div>
|
|
</dl>
|
|
<h3>挂载 HWPOD</h3>
|
|
<div class="capability-list"><code v-for="device in selectedNodeDetail.hwpods" :key="device.hwpodId">{{ device.hwpodId }} · {{ statusLabel(device.status) }}</code><span v-if="!selectedNodeDetail.hwpods.length">无</span></div>
|
|
<h3>接入 readiness</h3>
|
|
<HwpodReadinessRail :stages="selectedNodeDetail.readiness" />
|
|
</template>
|
|
</BaseDrawer>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.hwpod-console { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; gap: 12px; padding: 8px; color: var(--console-graphite-900); overflow: hidden; }
|
|
.hwpod-console :deep(.console-async-boundary) { display: flex; min-height: 0; flex-direction: column; overflow: hidden; }
|
|
.hwpod-console :deep(.page-command-filters) { flex-wrap: nowrap; align-items: center; gap: 6px; padding-bottom: 1px; overflow-x: auto; overscroll-behavior-inline: contain; }
|
|
.console-tabs { display: flex; gap: 2px; width: fit-content; max-width: 100%; padding: 3px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); overflow-x: auto; }
|
|
.console-tabs button { min-height: 32px; border: 0; border-radius: 5px; background: var(--console-surface-raised); color: var(--console-graphite-600); padding: 0 12px; font-size: 12px; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
|
.console-tabs button[data-active="true"] { color: #fff; background: var(--console-graphite-900); }
|
|
.console-search { display: flex; width: min(340px, 100%); min-width: min(340px, 100%); align-items: center; gap: 6px; }
|
|
.console-search label, .status-filter { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; font-weight: 760; letter-spacing: 0; text-transform: uppercase; }
|
|
.console-search input { height: 32px; min-width: 0; flex: 1; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 9px; font-size: 11px; }
|
|
.console-search button { height: 32px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 0 9px; font-size: 11px; font-weight: 750; cursor: pointer; }
|
|
.status-filter { display: flex; align-items: center; gap: 7px; }
|
|
.status-filter select { height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 8px; color: var(--console-graphite-800); font-size: 11px; }
|
|
.probe-button { height: 32px; border: 1px solid var(--console-amber-500); border-radius: var(--console-radius-sm); background: var(--console-amber-100); color: var(--console-amber-700); padding: 0 9px; font-size: 11px; font-weight: 750; cursor: pointer; white-space: nowrap; }
|
|
.hwpod-console :deep(.view-mode-switch) { height: 32px; flex: 0 0 auto; }
|
|
.hwpod-console :deep(.view-mode-switch button) { height: 28px; min-height: 28px; padding-block: 0; font-size: 11px; }
|
|
.spec-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #dfc47d; border-left: 4px solid #aa7a18; border-radius: 7px; background: #fff8df; color: #6b5317; font-size: .78rem; }
|
|
.stale-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #e4a8a2; border-left: 4px solid var(--console-red-700); border-radius: var(--console-radius-sm); background: var(--console-red-100); color: var(--console-red-700); font-size: .78rem; }
|
|
.hwpod-workspace { min-height: 0; flex: 1 1 auto; }
|
|
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
|
|
.hwpod-workspace :deep(.resource-stage) { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
|
|
.hwpod-workspace :deep(.grid-stack) { display: grid; min-width: 0; gap: 3px; }
|
|
.hwpod-workspace :deep(.grid-stack strong), .hwpod-workspace :deep(.grid-stack code), .hwpod-workspace :deep(.grid-stack small) { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.hwpod-workspace :deep(.grid-stack code), .hwpod-workspace :deep(.grid-stack small) { color: var(--console-graphite-600); font-size: 11px; }
|
|
.hwpod-workspace :deep(.status-chip) { display: inline-flex; padding: 4px 8px; border: 1px solid #aebeb9; border-radius: 999px; background: #eaf5f0; color: #2c6b5d; font-size: .7rem; font-weight: 800; white-space: nowrap; }
|
|
.hwpod-workspace :deep(.status-chip[data-status="busy"]) { color: #8b4c0a; background: #fff4df; border-color: #e6bd80; }
|
|
.hwpod-workspace :deep(.status-chip[data-status="offline"]), .hwpod-workspace :deep(.status-chip[data-status="workspace-blocked"]) { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
|
|
.hwpod-workspace :deep(.status-chip[data-status="capability-mismatch"]), .hwpod-workspace :deep(.status-chip[data-status="mismatch"]) { color: #745d09; background: #fff9d9; border-color: #e4d26d; }
|
|
.hwpod-workspace :deep(.device-collection .resource-collection-items[data-view="cards"]) { grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 680px)); justify-content: start; }
|
|
.hwpod-workspace :deep(.node-collection .resource-collection-items[data-view="cards"]) { grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 680px)); justify-content: start; }
|
|
.resource-details { display: grid; gap: 1px; margin: 0 0 18px; border: 1px solid #d2d9d6; background: #d2d9d6; }
|
|
.resource-details div { padding: 10px 11px; background: #f6f6f1; }
|
|
dt { color: #6d7a75; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
|
|
dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
|
.capability-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.capability-list code { padding: 5px 7px; border-radius: 5px; background: #fff3dd; color: #754813; }
|
|
.workspace-command { width: 100%; min-height: 36px; margin-top: 18px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); font-weight: 800; cursor: pointer; }
|
|
.collection-pagination { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; }
|
|
.collection-pagination div { display: flex; gap: 6px; }
|
|
.collection-pagination button { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-graphite-800); padding: 6px 10px; font-weight: 700; cursor: pointer; }
|
|
.collection-pagination button:disabled { opacity: .45; cursor: not-allowed; }
|
|
@media (max-width: 680px) { .hwpod-console { padding: 2px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-search { width: 100%; min-width: 100%; } .status-filter { flex: 0 0 auto; } .spec-warning, .stale-warning { align-items: flex-start; flex-direction: column; } .hwpod-workspace :deep(.bounded-workspace-main) { padding: 8px; } }
|
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
|
|
</style>
|