fix: resolve MDTODO review blockers
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user