fix: resolve MDTODO review blockers

This commit is contained in:
root
2026-07-14 20:33:31 +02:00
parent a054e3d941
commit 009083468a
19 changed files with 259 additions and 195 deletions
@@ -1,18 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { AdminAccessMatrixResponse, AdminAccessUsersResponse } from "../src/types/index.ts";
import type { AdminAccessMatrixResponse, AdminAccessOpenFga, AdminAccessSummaryResponse, AdminAccessUser, AdminAccessUsersResponse } from "../src/types/index.ts";
import { normalizeAccessDetail, normalizeAccessSummary, normalizeAccessUsers } from "../src/stores/admin-access-view.ts";
import { mergeProviderRow, normalizeProviderConfig, normalizeProviderProfiles, normalizeProviderValidation } from "../src/stores/provider-profiles-view.ts";
test("R4 Access helpers map runtime rows and OpenFGA summary", () => {
const summary = normalizeAccessSummary({ contractVersion: "admin-access-v1", counts: { users: 3, tuples: 5, toolTuples: 4 }, openfga: { ready: true, status: "ready", storeName: "hwlab-v03" }, supported: { toolIds: ["hwpod", "github_pr"] } });
const actor: AdminAccessUser = { id: "usr_admin", username: "admin", displayName: "Admin", role: "admin", status: "active" };
const user: AdminAccessUser = { id: "usr_1", username: "alice", displayName: "Alice", role: "user", status: "active" };
const openfga: AdminAccessOpenFga = { contractVersion: "hwlab-openfga-authorization-v1", mode: "enforce", configured: true, ready: true, status: "ready", apiUrlConfigured: true, storeName: "hwlab-v03", storeId: "sto…1234", modelId: "mod…1234", degradedReason: null, valuesRedacted: true };
const tools = { hwpod: true, unidesk_ssh: false, trans_cmd: false, github_pr: false };
test("R4 Access helpers map the fixed admin-access-v1 DTO", () => {
const payload: AdminAccessSummaryResponse = { contractVersion: "admin-access-v1", actor, counts: { users: 3, tuples: 5, toolTuples: 4 }, openfga, supported: { toolIds: ["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"] } };
const summary = normalizeAccessSummary(payload);
assert.equal(summary.users, 3);
assert.equal(summary.toolTuples, 4);
assert.deepEqual(summary.toolIds, ["hwpod", "github_pr"]);
assert.deepEqual(summary.toolIds, ["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"]);
assert.equal(summary.openfga.ready, true);
const usersPayload = { users: [{ user: { id: "usr_1", username: "alice", role: "member", status: "active" }, tools: { hwpod: true, github_pr: false }, tupleCount: 1 }] } as unknown as AdminAccessUsersResponse;
const usersPayload: AdminAccessUsersResponse = { contractVersion: "admin-access-v1", actor, openfga, users: [{ user, tools, tupleCount: 1 }], count: 1 };
const users = normalizeAccessUsers(usersPayload);
assert.equal(users.length, 1);
assert.equal(users[0]?.id, "usr_1");
@@ -21,7 +28,7 @@ test("R4 Access helpers map runtime rows and OpenFGA summary", () => {
});
test("R4 Access detail maps tool object into toggles", () => {
const detailPayload = { user: { id: "usr_1", username: "alice" }, tools: { hwpod: true, trans_cmd: false }, tuples: [{ userId: "usr_1", relation: "can_use", object: "tool:hwpod" }], openfga: { valuesRedacted: true } } as unknown as AdminAccessMatrixResponse;
const detailPayload: AdminAccessMatrixResponse = { contractVersion: "admin-access-v1", actor, user, tools, tuples: [{ userId: "usr_1", relation: "can_use", object: "tool:hwpod", createdByAdminId: actor.id, createdAt: "2026-07-14T00:00:00.000Z" }], openfga };
const detail = normalizeAccessDetail(detailPayload);
assert.equal(detail.user?.id, "usr_1");
assert.ok(detail.tools.some((tool) => tool.id === "hwpod" && tool.allowed));
@@ -29,6 +36,14 @@ test("R4 Access detail maps tool object into toggles", () => {
assert.equal(detail.tuples.length, 1);
});
test("ContentViewer keeps wrap available and hides maximize when fullscreen is disabled", () => {
const source = readFileSync(new URL("../src/components/common/ContentViewer.vue", import.meta.url), "utf8");
assert.match(source, /<button\s+class="icon-button"[\s\S]*?<WrapText/);
assert.doesNotMatch(source, /<button\s+v-if="fullscreenEnabled"[\s\S]{0,240}<WrapText/);
assert.match(source, /<button\s+v-if="fullscreenEnabled"[\s\S]{0,240}<Maximize2/);
assert.match(source, /:open="fullscreenEnabled && fullscreen"/);
});
test("R4 Provider helpers accept Sub2API-style item envelopes", () => {
const rows = normalizeProviderProfiles({ data: { items: [{ profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v02", name: "agentrun-v02-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc123", configHashSuffix: "def456", bridge: { route: "Moon Bridge -> DeepSeek" }, valuesPrinted: false }] } });
assert.equal(rows.length, 1);
+2 -2
View File
@@ -1,5 +1,5 @@
import { fetchJson } from "./client";
import type { AccessUserStatus, AdminAuditResponse, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, AdminUsageRow, ApiResult, UsageLedgerRow, UsageSummary } from "@/types";
import type { AdminAuditResponse, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, AdminUsageRow, ApiResult, BillingUserStatus, UsageLedgerRow, UsageSummary } from "@/types";
export const usageAPI = {
summary: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }),
@@ -20,7 +20,7 @@ export const usageAPI = {
createAdminUser: (body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson("/v1/admin/billing/users", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create admin user" }),
updateAdminUser: (userId: string, body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "update admin user" }),
adjustAdminCredits: (userId: string, body: Record<string, unknown>): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/credits/adjust`, { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "adjust admin credits" }),
updateAdminUserStatus: (userId: string, status: AccessUserStatus): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }),
updateAdminUserStatus: (userId: string, status: BillingUserStatus): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }),
performance: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" })
};
@@ -16,7 +16,7 @@ import {
accessUserLabel,
type AccessUserRow,
} from "@/stores/admin-access-view";
import type { AccessUserRole, AccessUserStatus } from "@/types";
import type { AccessToolId, AccessUserRole, AccessUserStatus } from "@/types";
const access = useAccessStore();
const route = useRoute();
@@ -141,7 +141,7 @@ function onRoleChange(role: AccessUserRole): void {
function onStatusChange(status: AccessUserStatus): void {
void access.updateSelectedUser({ status });
}
function onToolChange(toolId: string, allowed: boolean): void {
function onToolChange(toolId: AccessToolId, allowed: boolean): void {
void access.setTool(toolId, allowed);
}
function valueText(value: unknown): string {
@@ -234,7 +234,7 @@ function valueText(value: unknown): string {
overflow: auto;
}
.permission-matrix-head,
.permission-matrix > button {
.permission-matrix > [role="row"] {
display: grid;
grid-template-columns: minmax(180px, 1.6fr) repeat(
var(--permission-tool-count),
@@ -250,27 +250,33 @@ function valueText(value: unknown): string {
background: #eef3f4;
}
.permission-matrix-head span,
.permission-matrix > button > span {
.permission-matrix > [role="row"] > [role="gridcell"] {
padding: 9px;
border-right: 1px solid #d8e1e4;
border-bottom: 1px solid #d8e1e4;
text-align: center;
}
.permission-matrix-head span:first-child,
.permission-matrix > button > span:first-child {
.permission-matrix > [role="row"] > [role="gridcell"]:first-child {
text-align: left;
}
.permission-matrix > button {
.permission-matrix > [role="row"]:not(.permission-matrix-head) {
width: 100%;
border: 0;
background: white;
color: inherit;
cursor: pointer;
}
.permission-matrix > button:hover,
.permission-matrix > button[data-selected="true"] {
.permission-matrix > [role="row"]:not(.permission-matrix-head):hover,
.permission-matrix > [role="row"][data-selected="true"] {
background: #e8f6f5;
}
.permission-matrix > [role="row"] > [role="gridcell"]:first-child button {
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.permission-matrix strong,
.permission-matrix small {
display: block;
@@ -43,7 +43,13 @@ function valueText(value: unknown): string {
<span role="columnheader">用户</span>
<span v-for="tool in tools" :key="tool.id" role="columnheader">{{ tool.label }}</span>
</div>
<div v-for="row in rows" :key="row.id" role="row" :data-selected="row.id === selectedId">
<div
v-for="row in rows"
:key="row.id"
role="row"
:aria-selected="row.id === selectedId"
:data-selected="row.id === selectedId"
>
<span role="gridcell"><button type="button" @click="emit('select', row)"><strong>{{ accessUserLabel(row) }}</strong><small>{{ row.role }} · {{ row.status }}</small></button></span>
<span v-for="tool in tools" :key="tool.id" role="gridcell" :data-allowed="row.tools[tool.id] ? 'true' : 'false'">{{ row.tools[tool.id] ? "允许" : "—" }}</span>
</div>
@@ -17,7 +17,7 @@ const emit = defineEmits<{
close: [];
role: [value: AccessUserRole];
status: [value: AccessUserStatus];
tool: [toolId: string, allowed: boolean];
tool: [toolId: AccessToolRow["id"], allowed: boolean];
}>();
</script>
@@ -29,13 +29,13 @@ const emit = defineEmits<{
<label>
<span>Role</span>
<select :value="user.role" :disabled="saving" @change="emit('role', ($event.target as HTMLSelectElement).value as AccessUserRole)">
<option value="admin">admin</option><option value="member">member</option><option value="viewer">viewer</option>
<option value="admin">admin</option><option value="user">user</option>
</select>
</label>
<label>
<span>Status</span>
<select :value="user.status" :disabled="saving" @change="emit('status', ($event.target as HTMLSelectElement).value as AccessUserStatus)">
<option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option>
<option value="active">active</option><option value="disabled">disabled</option>
</select>
</label>
</div>
@@ -9,12 +9,14 @@ const props = withDefaults(defineProps<{
title: string;
description?: string;
wide?: boolean;
fullscreen?: boolean;
closeOnBackdrop?: boolean;
busy?: boolean;
initialFocus?: string;
}>(), {
description: "",
wide: false,
fullscreen: false,
closeOnBackdrop: true,
busy: false,
initialFocus: ""
@@ -24,5 +26,5 @@ defineEmits<{ close: [] }>();
</script>
<template>
<OverlaySurface :open="open" :title="title" :description="description" :wide="wide" :close-on-backdrop="closeOnBackdrop" :busy="busy" :initial-focus="initialFocus" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
<OverlaySurface :open="open" :title="title" :description="description" :wide="wide" :fullscreen="fullscreen" :close-on-backdrop="closeOnBackdrop" :busy="busy" :initial-focus="initialFocus" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
</template>
@@ -21,11 +21,13 @@ const props = withDefaults(defineProps<{
title?: string;
filename?: string;
lineNumbers?: boolean;
fullscreenEnabled?: boolean;
}>(), {
mode: "text",
title: "内容",
filename: "hwlab-content.txt",
lineNumbers: true
lineNumbers: true,
fullscreenEnabled: true
});
const query = ref("");
@@ -36,6 +38,15 @@ const text = computed(() => normalizeContent(props.content, props.mode));
const lines = computed(() => text.value.split("\n"));
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase());
const markdownHtml = computed(() => DOMPurify.sanitize(marked.parse(text.value, { async: false }) as string));
const markdownMatches = computed(() => {
if (props.mode !== "markdown" || !normalizedQuery.value) return [];
return lines.value.flatMap((line, index) =>
line.toLocaleLowerCase().includes(normalizedQuery.value)
? [{ lineNumber: index + 1, text: line }]
: [],
);
});
const visibleMarkdownMatches = computed(() => markdownMatches.value.slice(0, 30));
async function copy(): Promise<void> {
await navigator.clipboard.writeText(text.value);
@@ -58,6 +69,24 @@ function lineMatches(line: string): boolean {
return Boolean(normalizedQuery.value) && line.toLocaleLowerCase().includes(normalizedQuery.value);
}
function matchParts(line: string): Array<{ text: string; match: boolean }> {
const needle = normalizedQuery.value;
if (!needle) return [{ text: line, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const normalizedLine = line.toLocaleLowerCase();
let offset = 0;
let matchIndex = normalizedLine.indexOf(needle);
while (matchIndex >= 0) {
if (matchIndex > offset) parts.push({ text: line.slice(offset, matchIndex), match: false });
const end = matchIndex + needle.length;
parts.push({ text: line.slice(matchIndex, end), match: true });
offset = end;
matchIndex = normalizedLine.indexOf(needle, offset);
}
if (offset < line.length) parts.push({ text: line.slice(offset), match: false });
return parts.length ? parts : [{ text: line, match: false }];
}
function normalizeContent(content: unknown, mode: string): string {
if (mode === "json" || mode === "trace") {
if (typeof content === "string") {
@@ -118,6 +147,7 @@ function normalizeContent(content: unknown, mode: string): string {
<Download :size="16" aria-hidden="true" />
</button>
<button
v-if="fullscreenEnabled"
class="icon-button"
type="button"
title="全屏查看"
@@ -129,6 +159,15 @@ function normalizeContent(content: unknown, mode: string): string {
<span class="sr-only" aria-live="polite">{{ copied ? "内容已复制" : "" }}</span>
</header>
<div class="content-viewer-body">
<aside v-if="mode === 'markdown' && normalizedQuery" class="content-viewer-markdown-search" role="status" aria-live="polite">
<strong>{{ markdownMatches.length ? `找到 ${markdownMatches.length} 处匹配` : "未找到匹配内容" }}</strong>
<ol v-if="visibleMarkdownMatches.length">
<li v-for="match in visibleMarkdownMatches" :key="match.lineNumber">
<span class="content-viewer-match-line">{{ match.lineNumber }}</span>
<span><template v-for="(part, index) in matchParts(match.text)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></span>
</li>
</ol>
</aside>
<article v-if="mode === 'markdown'" class="content-viewer-markdown" v-html="markdownHtml" />
<pre v-else class="content-viewer-pre" :data-wrap="wrap ? 'true' : 'false'"><code><span
v-for="(line, index) in lines"
@@ -141,7 +180,7 @@ function normalizeContent(content: unknown, mode: string): string {
</section>
<OverlaySurface
:open="fullscreen"
:open="fullscreenEnabled && fullscreen"
:title="`${title} · ${mode}`"
description="全屏内容查看器"
:close-on-backdrop="false"
@@ -198,6 +237,15 @@ function normalizeContent(content: unknown, mode: string): string {
<span class="sr-only" aria-live="polite">{{ copied ? "内容已复制" : "" }}</span>
</header>
<div class="content-viewer-body">
<aside v-if="mode === 'markdown' && normalizedQuery" class="content-viewer-markdown-search" role="status" aria-live="polite">
<strong>{{ markdownMatches.length ? `找到 ${markdownMatches.length} 处匹配` : "未找到匹配内容" }}</strong>
<ol v-if="visibleMarkdownMatches.length">
<li v-for="match in visibleMarkdownMatches" :key="match.lineNumber">
<span class="content-viewer-match-line">{{ match.lineNumber }}</span>
<span><template v-for="(part, index) in matchParts(match.text)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></span>
</li>
</ol>
</aside>
<article
v-if="mode === 'markdown'"
class="content-viewer-markdown"
@@ -214,3 +262,11 @@ function normalizeContent(content: unknown, mode: string): string {
</section>
</OverlaySurface>
</template>
<style scoped>
.content-viewer-markdown-search { display: grid; gap: 8px; margin: 10px; padding: 10px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-subtle); color: var(--console-graphite-700); font-size: 12px; }
.content-viewer-markdown-search ol { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
.content-viewer-markdown-search li { display: grid; min-width: 0; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; font-family: var(--console-font-mono); overflow-wrap: anywhere; }
.content-viewer-match-line { color: var(--console-graphite-500); text-align: right; }
.content-viewer-markdown-search mark { border-radius: 2px; background: var(--console-amber-100); color: var(--console-amber-700); }
</style>
@@ -48,6 +48,7 @@ function close(): void {
v-if="open"
class="console-overlay-backdrop"
:class="{ 'console-drawer-backdrop': kind === 'drawer' }"
:data-fullscreen="kind === 'dialog' && fullscreen ? 'true' : undefined"
role="presentation"
@mousedown.self="closeOnBackdrop ? close() : undefined"
>
@@ -11,7 +11,7 @@ import HwpodDeviceCard from "./HwpodDeviceCard.vue";
defineProps<{ devices: HwpodDeviceTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
const emit = defineEmits<{ select: [device: HwpodDeviceTopologyItem]; sort: [key: string] }>();
const columns: DataGridColumn[] = [{ key: "name", label: "HWPOD", sortable: true, width: "190px" }, { key: "nodeName", label: "Node", width: "170px" }, { key: "status", label: "状态", sortable: true, width: "100px" }, { key: "elements", label: "目标 / 工作区", width: "230px" }, { key: "debugIo", label: "调试 / IO", width: "210px" }, { key: "capabilities", label: "能力", width: "140px" }, { key: "blocker", label: "主要 blocker", width: "220px" }, { key: "operation", label: "用户操作", width: "160px" }];
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配" }[status] || status || "unknown");
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配", "capability-mismatch": "能力不匹配" }[status] || status || "unknown");
</script>
<template>
@@ -55,14 +55,3 @@ const statusLabel = (status: string): string => ({ online: "在线", offline: "
</DataGrid>
</section>
</template>
<style scoped>
.resource-stage { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
.grid-stack { display: grid; min-width: 0; gap: 3px; }
.grid-stack strong, .grid-stack code, .grid-stack small { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.grid-stack code, .grid-stack small { color: var(--console-graphite-600); font-size: 11px; }
.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; }
.status-chip[data-status="busy"] { color: #8b4c0a; background: #fff4df; border-color: #e6bd80; }
.status-chip[data-status="offline"], .status-chip[data-status="workspace-blocked"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
.status-chip[data-status="capability-mismatch"], .status-chip[data-status="mismatch"] { color: #745d09; background: #fff9d9; border-color: #e4d26d; }
</style>
@@ -11,7 +11,7 @@ import HwpodReadinessRail from "./HwpodReadinessRail.vue";
defineProps<{ nodes: HwpodNodeTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
const emit = defineEmits<{ select: [node: HwpodNodeTopologyItem]; sort: [key: string] }>();
const columns: DataGridColumn[] = [{ key: "name", label: "Node", sortable: true, width: "210px" }, { key: "status", label: "连接", sortable: true, width: "100px" }, { key: "deviceCount", label: "HWPOD", align: "right", width: "80px" }, { key: "inFlight", label: "In-flight", width: "100px" }, { key: "capabilities", label: "能力", width: "130px" }, { key: "mounted", label: "挂载 HWPOD", width: "220px" }, { key: "readiness", label: "接入阶段", width: "180px" }, { key: "lastSeen", label: "最后心跳", width: "150px" }, { key: "blocker", label: "Blocker", width: "220px" }];
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配" }[status] || status || "unknown");
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配", "capability-mismatch": "能力不匹配" }[status] || status || "unknown");
function formatTime(value: string | null): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
</script>
@@ -95,14 +95,6 @@ function formatTime(value: string | null): string { if (!value) return "-"; cons
</template>
<style scoped>
.resource-stage { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
.grid-stack { display: grid; min-width: 0; gap: 3px; }
.grid-stack strong, .grid-stack code, .grid-stack small { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.grid-stack code, .grid-stack small { color: var(--console-graphite-600); font-size: 11px; }
.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; }
.status-chip[data-status="busy"] { color: #8b4c0a; background: #fff4df; border-color: #e6bd80; }
.status-chip[data-status="offline"], .status-chip[data-status="workspace-blocked"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
.status-chip[data-status="capability-mismatch"], .status-chip[data-status="mismatch"] { color: #745d09; background: #fff9d9; border-color: #e4d26d; }
.node-card { min-width: 0; padding: 18px; border: 1px solid #c1cbc7; border-top: 3px solid #3a766a; border-radius: 8px; background: #fbfaf6; box-shadow: 0 10px 26px rgb(19 42 36 / 7%); transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
.node-card[data-status="offline"] { border-top-color: #8d453b; }
.node-card[data-selected="true"] { border-color: #1f7469; border-top-color: #145f57; box-shadow: 0 0 0 3px rgb(31 116 105 / 18%), 0 15px 32px rgb(20 55 48 / 13%); transform: translateY(-1px); }
@@ -88,6 +88,7 @@ const emit = defineEmits<{
title="报告预览"
:filename="report?.relativePath || 'report.md'"
:line-numbers="false"
:fullscreen-enabled="false"
/>
<template #empty>
<div class="report-empty">报告内容尚未加载</div>
@@ -115,17 +116,18 @@ const emit = defineEmits<{
:open="showFullscreen && Boolean(report)"
:title="activeLink?.label || report?.relativePath || '任务报告'"
description="Markdown 报告只读预览"
wide
fullscreen
@close="emit('closeFullscreen')"
>
<ContentViewer
class="report-markdown fullscreen"
class="report-markdown"
data-testid="mdtodo-report-fullscreen-dialog"
:content="report?.content || ''"
mode="markdown"
:title="activeLink?.label || report?.relativePath || '任务报告'"
:filename="report?.relativePath || 'report.md'"
:line-numbers="false"
:fullscreen-enabled="false"
/>
<template #footer>
<button class="btn" type="button" @click="emit('closeFullscreen')">
@@ -215,7 +217,4 @@ const emit = defineEmits<{
font: 700 9px ui-monospace, monospace;
text-transform: uppercase;
}
.fullscreen {
max-height: calc(100dvh - 230px);
}
</style>
@@ -3,7 +3,7 @@
<script setup lang="ts">
import { ChevronRight, Search, UnfoldVertical } from "lucide-vue-next";
import { computed, nextTick, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { MdtodoTaskPage, MdtodoTaskRecord } from "@/api";
import RefreshBoundary from "@/components/common/RefreshBoundary.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
@@ -35,21 +35,63 @@ const emit = defineEmits<{
}>();
const rows = ref<HTMLElement[]>([]);
const tree = ref<HTMLElement | null>(null);
const viewportTaskRefs = ref<Set<string>>(new Set());
const firstViewportTaskRef = ref<string | null>(null);
let viewportObserver: ResizeObserver | null = null;
const items = computed(() =>
visibleTasks(props.tasks, props.collapsedTaskRefs, props.selectedTaskRef),
);
const tabStopTaskRef = computed(() =>
props.selectedTaskRef && viewportTaskRefs.value.has(props.selectedTaskRef)
? props.selectedTaskRef
: firstViewportTaskRef.value || items.value[0]?.taskRef || null,
);
function updateViewportTaskRefs(): void {
if (!tree.value) return;
const viewport = tree.value.getBoundingClientRect();
const refs = new Set<string>();
let first: string | null = null;
rows.value.forEach((row, index) => {
const task = items.value[index];
if (!row || !task) return;
const bounds = row.getBoundingClientRect();
if (bounds.bottom <= viewport.top || bounds.top >= viewport.bottom) return;
refs.add(task.taskRef);
first ||= task.taskRef;
});
viewportTaskRefs.value = refs;
firstViewportTaskRef.value = first;
}
onMounted(() => {
void nextTick(updateViewportTaskRefs);
if (tree.value && typeof ResizeObserver !== "undefined") {
viewportObserver = new ResizeObserver(updateViewportTaskRefs);
viewportObserver.observe(tree.value);
} else if (typeof window !== "undefined") {
window.addEventListener("resize", updateViewportTaskRefs, { passive: true });
}
});
onBeforeUnmount(() => {
viewportObserver?.disconnect();
if (typeof window !== "undefined") window.removeEventListener("resize", updateViewportTaskRefs);
});
watch(items, async () => {
rows.value = [];
await nextTick();
updateViewportTaskRefs();
});
function depth(task: MdtodoTaskRecord): number {
return task.depth ?? taskParentRefs(task, props.tasks).length;
}
function children(task: MdtodoTaskRecord): MdtodoTaskRecord[] {
return props.tasks.filter(
(item) =>
item.parentTaskRef === task.taskRef ||
(!item.parentTaskRef &&
taskParentRefs(item, props.tasks).at(-1) === task.taskRef),
);
return props.tasks.filter((item) => item.parentTaskRef === task.taskRef);
}
function updateCollapse(task: MdtodoTaskRecord, collapse: boolean): void {
@@ -147,10 +189,12 @@ function keydown(
compact
>
<ol
ref="tree"
class="task-tree"
role="tree"
aria-label="MDTODO 任务树"
data-testid="mdtodo-task-tree"
@scroll.passive="updateViewportTaskRefs"
>
<li
v-for="(task, index) in items"
@@ -163,7 +207,7 @@ function keydown(
class="task-row"
type="button"
role="treeitem"
:tabindex="task.taskRef === selectedTaskRef ? 0 : -1"
:tabindex="task.taskRef === tabStopTaskRef ? 0 : -1"
:aria-current="task.taskRef === selectedTaskRef ? 'true' : undefined"
:aria-selected="task.taskRef === selectedTaskRef"
:aria-expanded="children(task).length ? !collapsedTaskRefs.has(task.taskRef) : undefined"
@@ -18,11 +18,6 @@ describe("MDTODO task tree model", () => {
expect(taskParentRefs(tasks[2]!, tasks)).toEqual(["root", "child"]);
});
it("falls back to complete Rxx segmentation", () => {
const fallback = tasks.map((task) => ({ ...task, parentTaskRef: null }));
expect(taskParentRefs(fallback[2]!, fallback)).toEqual(["root", "child"]);
});
it("keeps only the selected deep-link path visible through a collapsed parent", () => {
expect(
visibleTasks(tasks, new Set(["root"]), "deep").map(
@@ -21,17 +21,7 @@ export function taskParentRefs(
parents.unshift(parentRef);
parentRef = byRef.get(parentRef)?.parentTaskRef || null;
}
if (parents.length) return parents;
const taskId = task.taskId || task.rxxId || "";
const parts = taskId.split(".");
const ids = parts
.slice(1)
.map((_, index) => parts.slice(0, index + 1).join("."));
return ids
.map(
(id) => tasks.find((item) => (item.taskId || item.rxxId) === id)?.taskRef,
)
.filter((value): value is string => Boolean(value));
return parents;
}
export function visibleTasks(
@@ -1,4 +1,4 @@
import type { AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessSummaryResponse, AdminAccessUsersResponse, AuthUser } from "@/types";
import type { AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessOpenFga, AdminAccessSummaryResponse, AdminAccessTuple, AdminAccessUserSummary, AdminAccessUsersResponse } from "@/types";
export interface AccessUserRow {
id: string;
@@ -8,8 +8,7 @@ export interface AccessUserRow {
status: AccessUserStatus;
tupleCount: number;
toolCount: number;
tools: Record<string, boolean>;
source: Record<string, unknown>;
tools: Record<AccessToolId, boolean>;
}
export interface AccessToolRow {
@@ -19,13 +18,13 @@ export interface AccessToolRow {
}
export interface AccessOpenFgaView {
mode: string;
mode: AdminAccessOpenFga["mode"];
storeName: string;
storeId: string;
modelId: string;
valuesRedacted: boolean;
ready: boolean;
status: string;
status: AdminAccessOpenFga["status"];
}
export interface AccessTupleView {
@@ -33,7 +32,8 @@ export interface AccessTupleView {
userId: string;
relation: string;
object: string;
condition: string;
createdByAdminId: string;
createdAt: string;
}
export interface AccessSummaryView {
@@ -42,10 +42,10 @@ export interface AccessSummaryView {
tuples: number;
toolTuples: number;
openfga: AccessOpenFgaView;
toolIds: string[];
toolIds: AccessToolId[];
}
const TOOL_LABELS: Record<string, string> = {
const TOOL_LABELS: Record<AccessToolId, string> = {
hwpod: "HWPOD CLI",
unidesk_ssh: "UniDesk SSH",
trans_cmd: "trans passthrough",
@@ -53,133 +53,77 @@ const TOOL_LABELS: Record<string, string> = {
};
export function normalizeAccessSummary(payload: AdminAccessSummaryResponse | null): AccessSummaryView {
const record = objectRecord(payload);
const summary = objectRecord(record?.summary) ?? record ?? {};
const counts = objectRecord(summary?.counts);
const supported = objectRecord(summary?.supported);
const openfga = normalizeOpenFga(summary?.openfga);
if (!payload) return emptyAccessSummary();
return {
contractVersion: text(summary?.contractVersion) || "admin-access-v1",
users: numberValue(counts?.users),
tuples: numberValue(counts?.tuples),
toolTuples: numberValue(counts?.toolTuples),
openfga,
toolIds: stringArray(supported?.toolIds)
contractVersion: payload.contractVersion,
users: payload.counts.users,
tuples: payload.counts.tuples,
toolTuples: payload.counts.toolTuples,
openfga: normalizeOpenFga(payload.openfga),
toolIds: [...payload.supported.toolIds]
};
}
export function normalizeAccessUsers(payload: AdminAccessUsersResponse | null): AccessUserRow[] {
const record = objectRecord(payload);
const rows = firstArray(record?.users, record?.items, objectRecord(record?.data)?.users, objectRecord(record?.data)?.items);
return rows.map((item, index) => normalizeUserRow(item, index)).filter((row) => row.id);
return payload?.users.map(normalizeUserRow) ?? [];
}
export function normalizeAccessDetail(payload: AdminAccessMatrixResponse | null): { user: AccessUserRow | null; tools: AccessToolRow[]; tuples: AccessTupleView[]; openfga: AccessOpenFgaView } {
const record = objectRecord(payload);
const user = record ? normalizeUserRow(record, 0) : null;
const tools = normalizeTools(record?.tools, Object.keys(user?.tools ?? {}));
if (!payload) return { user: null, tools: [], tuples: [], openfga: emptyOpenFga() };
const user = normalizeUserRow({ user: payload.user, tools: payload.tools, tupleCount: payload.tuples.length });
return {
user: user?.id ? user : null,
tools,
tuples: firstArray(record?.tuples).map(normalizeTuple),
openfga: normalizeOpenFga(record?.openfga)
user,
tools: normalizeTools(payload.tools),
tuples: payload.tuples.map(normalizeTuple),
openfga: normalizeOpenFga(payload.openfga)
};
}
function normalizeOpenFga(value: unknown): AccessOpenFgaView {
const record = objectRecord(value) ?? {};
function normalizeOpenFga(value: AdminAccessOpenFga): AccessOpenFgaView {
return {
mode: text(record.mode),
storeName: text(record.storeName),
storeId: text(record.storeId),
modelId: text(record.modelId),
valuesRedacted: record.valuesRedacted !== false,
ready: booleanValue(record.ready),
status: text(record.status)
mode: value.mode,
storeName: value.storeName,
storeId: value.storeId,
modelId: value.modelId,
valuesRedacted: value.valuesRedacted,
ready: value.ready,
status: value.status
};
}
function normalizeTuple(value: Record<string, unknown>, index: number): AccessTupleView {
const userId = text(value.userId ?? value.user);
const relation = text(value.relation);
const object = text(value.object);
function normalizeTuple(value: AdminAccessTuple, index: number): AccessTupleView {
return {
id: text(value.id) || `${userId}:${relation}:${object}:${index}`,
userId,
relation,
object,
condition: text(value.condition)
id: `${value.userId}:${value.relation}:${value.object}:${index}`,
userId: value.userId,
relation: value.relation,
object: value.object,
createdByAdminId: value.createdByAdminId,
createdAt: value.createdAt
};
}
export function normalizeTools(value: unknown, fallbackIds: string[] = []): AccessToolRow[] {
if (Array.isArray(value)) {
return value.map((item) => {
const record = objectRecord(item) ?? {};
const id = text(record.id ?? record.toolId ?? record.name);
return { id, label: text(record.label) || TOOL_LABELS[id] || id, allowed: booleanValue(record.allowed ?? record.canUse ?? record.enabled) };
}).filter((row) => row.id);
}
const record = objectRecord(value) ?? {};
const ids = Array.from(new Set([...Object.keys(record), ...fallbackIds, ...Object.keys(TOOL_LABELS)])).filter(Boolean);
return ids.map((id) => ({ id, label: TOOL_LABELS[id] || id, allowed: booleanValue(record[id]) }));
export function normalizeTools(value: Record<AccessToolId, boolean>): AccessToolRow[] {
return (Object.keys(TOOL_LABELS) as AccessToolId[]).map((id) => ({ id, label: TOOL_LABELS[id], allowed: value[id] }));
}
export function accessUserLabel(user: Pick<AccessUserRow, "displayName" | "username" | "id"> | AuthUser | null | undefined): string {
const record = objectRecord(user);
return text(record?.displayName ?? record?.name ?? record?.username ?? record?.id) || "unknown";
export function accessUserLabel(user: Pick<AccessUserRow, "displayName" | "username" | "id"> | null | undefined): string {
return user?.displayName.trim() || user?.username.trim() || user?.id.trim() || "unknown";
}
function normalizeUserRow(value: unknown, index: number): AccessUserRow {
const record = objectRecord(value) ?? {};
const nestedUser = objectRecord(record.user);
const user = nestedUser ?? record;
const toolsObject = normalizeToolObject(record.tools ?? user.tools);
const id = text(user.id ?? user.userId ?? record.userId ?? user.username ?? record.username) || `user-${index}`;
function normalizeUserRow(value: AdminAccessUserSummary): AccessUserRow {
const tools = { ...value.tools };
return {
id,
username: text(user.username),
displayName: text(user.displayName ?? user.name),
role: text(user.role) || "member",
status: text(user.status) || "active",
tupleCount: numberValue(record.tupleCount ?? user.tupleCount),
toolCount: Object.values(toolsObject).filter(Boolean).length,
tools: toolsObject,
source: record
...value.user,
tupleCount: value.tupleCount,
toolCount: Object.values(tools).filter(Boolean).length,
tools
};
}
function normalizeToolObject(value: unknown): Record<string, boolean> {
const record = objectRecord(value) ?? {};
const out: Record<string, boolean> = {};
for (const [key, allowed] of Object.entries(record)) out[key] = booleanValue(allowed);
return out;
function emptyOpenFga(): AccessOpenFgaView {
return { mode: "off", storeName: "", storeId: "", modelId: "", valuesRedacted: true, ready: false, status: "disabled" };
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function firstArray(...values: unknown[]): Record<string, unknown>[] {
for (const value of values) if (Array.isArray(value)) return value.filter((item): item is Record<string, unknown> => Boolean(objectRecord(item)));
return [];
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : [];
}
function text(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function numberValue(value: unknown): number {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
}
function booleanValue(value: unknown): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "allowed", "enabled"].includes(value.toLowerCase());
return Boolean(value);
function emptyAccessSummary(): AccessSummaryView {
return { contractVersion: "admin-access-v1", users: 0, tuples: 0, toolTuples: 0, openfga: emptyOpenFga(), toolIds: [] };
}
+10 -2
View File
@@ -1,7 +1,7 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
import type { AuthAccess, AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
import type { AccessUserRole, AccessUserStatus, AuthAccess, AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
import { asRecord, nonEmptyString } from "@/utils";
export const useAuthStore = defineStore("auth", () => {
@@ -108,7 +108,15 @@ function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unk
function userFromUnknown(value: unknown): AuthUser | undefined {
const record = asRecord(value);
if (!record) return undefined;
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined };
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: accessRoleFromUnknown(record.role), status: accessStatusFromUnknown(record.status) };
}
function accessRoleFromUnknown(value: unknown): AccessUserRole | undefined {
return value === "admin" || value === "user" ? value : undefined;
}
function accessStatusFromUnknown(value: unknown): AccessUserStatus | undefined {
return value === "active" || value === "disabled" ? value : undefined;
}
function capabilitiesFromUnknown(value: unknown): AuthCapabilities | undefined {
@@ -536,9 +536,14 @@
}
.console-overlay-surface[data-fullscreen="true"] {
width: calc(100vw - 24px);
height: calc(100dvh - 24px);
width: 100vw;
height: 100dvh;
max-height: none;
border: 0;
border-radius: 0;
}
.console-overlay-backdrop[data-fullscreen="true"] {
padding: 0;
}
.console-overlay-header,
+13 -8
View File
@@ -6,9 +6,10 @@ export type AuthState = "checking" | "login" | "authenticated";
export type ProviderProfile = string;
export type ChatRole = "user" | "agent" | "system";
export type ChatStatus = "pending" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled";
export type AccessUserRole = "admin" | "member" | "viewer" | string;
export type AccessUserStatus = "active" | "disabled" | "pending" | string;
export type AccessToolId = "code-agent" | "hwpod" | "provider-profiles" | "access-admin" | string;
export type AccessUserRole = "admin" | "user";
export type AccessUserStatus = "active" | "disabled";
export type BillingUserStatus = AccessUserStatus | "pending";
export type AccessToolId = "hwpod" | "unidesk_ssh" | "trans_cmd" | "github_pr";
export interface AuthUser {
id?: string;
@@ -891,10 +892,14 @@ export interface SkillUploadResponse { accepted?: boolean; skill?: SkillRecord;
export interface SkillTreeResponse { skill?: SkillRecord; tree?: SkillTreeEntry[]; count?: number; [key: string]: unknown }
export interface SkillFileResponse { skill?: SkillRecord; file?: SkillFilePayload; content?: string; [key: string]: unknown }
export interface AdminAccessSummaryResponse { summary?: Record<string, unknown>; [key: string]: unknown }
export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown }
export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown }
export interface AdminAccessUser { id: string; username: string; displayName: string; role: AccessUserRole; status: AccessUserStatus }
export interface AdminAccessOpenFga { contractVersion: string; mode: "off" | "enforce"; configured: boolean; ready: boolean; status: "ready" | "disabled" | "degraded"; apiUrlConfigured: boolean; storeName: string; storeId: string; modelId: string; degradedReason: string | null; valuesRedacted: true }
export interface AdminAccessTuple { userId: string; relation: string; object: string; createdByAdminId: string; createdAt: string }
export interface AdminAccessUserSummary { user: AdminAccessUser; tupleCount: number; tools: Record<AccessToolId, boolean> }
export interface AdminAccessSummaryResponse { contractVersion: "admin-access-v1"; actor: AdminAccessUser; openfga: AdminAccessOpenFga; counts: { users: number; tuples: number; toolTuples: number }; supported: { toolIds: AccessToolId[] } }
export interface AdminAccessUsersResponse { contractVersion: "admin-access-v1"; actor: AdminAccessUser; openfga: AdminAccessOpenFga; users: AdminAccessUserSummary[]; count: number }
export interface AdminAccessMatrixResponse { contractVersion: "admin-access-v1"; actor: AdminAccessUser; user: AdminAccessUser; openfga: AdminAccessOpenFga; tools: Record<AccessToolId, boolean>; tuples: AdminAccessTuple[] }
export interface AdminAccessMutationResponse { ok?: boolean; changed?: boolean; updated?: boolean; user?: AdminAccessUser; access?: AdminAccessMatrixResponse }
export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; displaySecret?: string | null }
export interface UsageServiceSummary { serviceId: string; resourceType?: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string }
export interface UsageLedgerRow { id: string; userId?: string; email?: string; username?: string; kind?: string; reason?: string; deltaCredits?: number; balanceBefore?: number; balanceAfter?: number; source?: string; status?: string; idempotencyKey?: string; operatorUserId?: string; operatorUsername?: string; metadata?: Record<string, unknown>; createdAt?: string }
@@ -1171,7 +1176,7 @@ export interface DashboardSummaryResponse {
export interface AdminBillingMutationResponse {
ok?: boolean;
userId?: string;
status?: AccessUserStatus;
status?: BillingUserStatus;
user?: AuthUser;
detail?: AdminBillingUserDetail;
balance?: number;
@@ -303,7 +303,14 @@ function text(value: unknown): string {
.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 { height: clamp(420px, calc(100dvh - 300px), 760px); }
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
.resource-stage { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
.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; }