Merge pull request #1169 from pikasTech/fix/v03-r4-admin-crud-1154

fix: 补齐 v0.3 Vue Admin CRUD 与 Provider 委托
This commit is contained in:
Lyon
2026-06-14 03:39:16 +08:00
committed by GitHub
15 changed files with 1262 additions and 38 deletions
+3 -3
View File
@@ -410,9 +410,9 @@ lanes:
HWLAB_KEYCLOAK_CLIENT_ID: hwlab-cloud-web
HWLAB_KEYCLOAK_CLIENT_SECRET: secretRef:hwlab-cloud-web-client/client-secret
HWLAB_CODE_AGENT_ADAPTER: agentrun-v01
AGENTRUN_MGR_URL: http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080
HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v01
HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v01
AGENTRUN_MGR_URL: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080
HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v02
HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v02
HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git
- serviceId: hwlab-user-billing
env:
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AdminAccessMatrixResponse, 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"] } });
assert.equal(summary.users, 3);
assert.equal(summary.toolTuples, 4);
assert.deepEqual(summary.toolIds, ["hwpod", "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 users = normalizeAccessUsers(usersPayload);
assert.equal(users.length, 1);
assert.equal(users[0]?.id, "usr_1");
assert.equal(users[0]?.toolCount, 1);
assert.equal(users[0]?.tools.hwpod, true);
});
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 detail = normalizeAccessDetail(detailPayload);
assert.equal(detail.user?.id, "usr_1");
assert.ok(detail.tools.some((tool) => tool.id === "hwpod" && tool.allowed));
assert.ok(detail.tools.some((tool) => tool.id === "trans_cmd" && !tool.allowed));
assert.equal(detail.tuples.length, 1);
});
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);
assert.equal(rows[0]?.profile, "deepseek");
assert.equal(rows[0]?.secretRefText, "agentrun-v02/agentrun-v02-provider-deepseek [auth.json,config.toml]");
assert.equal(rows[0]?.credentialHashSuffix, "abc123");
assert.equal(rows[0]?.valuesPrinted, false);
});
test("R4 Provider config and validation summaries stay redacted", () => {
const config = normalizeProviderConfig({ data: { profile: "deepseek", configToml: "model = \"deepseek-chat\"", configHashSuffix: "cfg123", valuesPrinted: false } }, "deepseek");
assert.equal(config.profile, "deepseek");
assert.match(config.configToml, /deepseek-chat/);
assert.equal(config.valuesPrinted, false);
const validation = normalizeProviderValidation({ data: { validationId: "val_1", profile: "deepseek", status: "completed", runId: "run_1", commandId: "cmd_1", result: { terminalStatus: "completed", agentRun: { jobName: "agentrun-v02-runner" } }, valuesPrinted: false } }, "deepseek");
assert.equal(validation.validationId, "val_1");
assert.equal(validation.jobName, "agentrun-v02-runner");
assert.equal(validation.valuesPrinted, false);
});
test("R4 Provider merge keeps updated profile and ordering", () => {
const rows = normalizeProviderProfiles({ items: [{ profile: "custom" }, { profile: "deepseek", configured: false }] });
const updated = normalizeProviderProfiles({ items: [{ profile: "deepseek", configured: true, credentialHashSuffix: "abc" }] })[0] ?? null;
const merged = mergeProviderRow(rows, updated);
assert.equal(merged[0]?.profile, "deepseek");
assert.equal(merged[0]?.configured, true);
assert.equal(merged.length, 2);
});
@@ -4,6 +4,7 @@ import type { ApiResult, ProviderProfile, ProviderProfileValidation, ProviderPro
export const providerProfilesAPI = {
list: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }),
catalog: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profile catalog" }),
remove: (profile: ProviderProfile): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}`, { method: "DELETE", timeoutMs: 30000, timeoutName: "provider profile remove" }),
setKey: (profile: ProviderProfile, apiKey: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`, { method: "PUT", body: JSON.stringify({ apiKey }), timeoutMs: 30000, timeoutName: "provider profile key update" }),
config: (profile: ProviderProfile): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { timeoutMs: 12000, timeoutName: "provider profile config" }),
setConfig: (profile: ProviderProfile, configToml: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { method: "PUT", body: JSON.stringify({ configToml }), timeoutMs: 30000, timeoutName: "provider profile config update" }),
@@ -1,27 +1,176 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { computed, onMounted } from "vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useAccessStore } from "@/stores/access";
import { accessUserLabel, type AccessUserRow } from "@/stores/admin-access-view";
import type { AccessUserRole, AccessUserStatus } from "@/types";
const access = useAccessStore();
const columns: DataTableColumn[] = [
{ key: "user", label: "用户" },
{ key: "role", label: "角色" },
{ key: "status", label: "状态" },
{ key: "tools", label: "工具" },
{ key: "tuples", label: "Tuples", align: "right" },
{ key: "actions", label: "操作", align: "right" }
];
const openfga = computed(() => access.summaryView.openfga);
onMounted(() => void access.refresh());
function selectRow(row: AccessUserRow): void {
void access.selectUser(row.id);
}
function onRoleChange(event: Event): void {
const role = (event.target as HTMLSelectElement | null)?.value as AccessUserRole | undefined;
if (role) void access.updateSelectedUser({ role });
}
function onStatusChange(event: Event): void {
const status = (event.target as HTMLSelectElement | null)?.value as AccessUserStatus | undefined;
if (status) void access.updateSelectedUser({ status });
}
function onToolChange(toolId: string, event: Event): void {
const checked = (event.target as HTMLInputElement | null)?.checked === true;
void access.setTool(toolId, checked);
}
function rowKey(row: AccessUserRow): string {
return row.id;
}
function valueText(value: unknown): string {
if (typeof value === "boolean") return value ? "yes" : "no";
if (typeof value === "number") return String(value);
if (typeof value === "string" && value.trim()) return value;
return "-";
}
</script>
<template>
<section class="data-panel">
<LoadingState v-if="access.loading" />
<EmptyState v-else-if="access.error" title="Access 加载失败" :description="access.error" />
<EmptyState v-else-if="!access.users?.users?.length" title="Access 骨架已就绪" description="页面调用 /v1/admin/access* 同源 APIOpenFGA 不在浏览器直连。" />
<table v-else class="data-table">
<thead><tr><th>用户</th><th>角色</th><th>状态</th></tr></thead>
<tbody>
<tr v-for="user in access.users.users" :key="user.id || user.username">
<td>{{ user.displayName || user.username || user.id }}</td>
<td>{{ user.role }}</td>
<td>{{ user.status }}</td>
</tr>
</tbody>
</table>
<section class="route-stack access-admin-page">
<LoadingState v-if="access.loading && !access.userRows.length" />
<EmptyState v-else-if="access.error" title="Access 加载失败" :description="access.error" action-label="重试" @action="access.refresh" />
<template v-else>
<div class="overview-grid access-summary-grid">
<article class="metric-card">
<span>Users</span>
<strong>{{ access.summaryView.users }}</strong>
</article>
<article class="metric-card">
<span>Tuples</span>
<strong>{{ access.summaryView.tuples }}</strong>
</article>
<article class="metric-card">
<span>Tool grants</span>
<strong>{{ access.summaryView.toolTuples }}</strong>
</article>
</div>
<section id="access-openfga-panel" class="data-panel admin-crud-panel openfga-panel">
<header class="panel-header">
<h2>OpenFGA</h2>
<span class="status-pill" :data-status="valueText(openfga.status || (openfga.ready ? 'active' : 'disabled'))">{{ valueText(openfga.status || (openfga.ready ? 'ready' : 'not ready')) }}</span>
</header>
<dl class="field-grid">
<div><dt>mode</dt><dd>{{ valueText(openfga.mode) }}</dd></div>
<div><dt>store</dt><dd>{{ valueText(openfga.storeName || openfga.storeId) }}</dd></div>
<div><dt>model</dt><dd>{{ valueText(openfga.modelId) }}</dd></div>
<div><dt>values</dt><dd>{{ openfga.valuesRedacted === false ? "visible" : "redacted" }}</dd></div>
</dl>
</section>
<TablePageLayout title="Access users" subtitle="Cloud API 同源 admin access matrix">
<template #actions>
<button id="access-refresh" class="table-action" type="button" :disabled="access.loading" @click="access.refresh">刷新</button>
</template>
<DataTable :columns="columns" :rows="access.userRows" :row-key="rowKey" empty-text="暂无用户记录">
<template #cell-user="{ row }">
<button class="link-button" type="button" :data-selected="row.id === access.selectedUserId" @click="selectRow(row as AccessUserRow)">
<strong>{{ accessUserLabel(row as AccessUserRow) }}</strong>
<small>{{ (row as AccessUserRow).username || (row as AccessUserRow).id }}</small>
</button>
</template>
<template #cell-role="{ row }">
<span class="status-pill" :data-status="(row as AccessUserRow).role">{{ (row as AccessUserRow).role }}</span>
</template>
<template #cell-status="{ row }">
<span class="status-pill" :data-status="(row as AccessUserRow).status">{{ (row as AccessUserRow).status }}</span>
</template>
<template #cell-tools="{ row }">
{{ (row as AccessUserRow).toolCount }} / {{ Object.keys((row as AccessUserRow).tools).length }}
</template>
<template #cell-tuples="{ row }">
{{ (row as AccessUserRow).tupleCount }}
</template>
<template #cell-actions="{ row }">
<button class="table-action" type="button" @click="selectRow(row as AccessUserRow)">详情</button>
</template>
</DataTable>
</TablePageLayout>
<section id="access-user-detail" class="data-panel admin-crud-panel access-detail-panel">
<header class="panel-header">
<div>
<h2>Selected user</h2>
<small>{{ access.selectedUser ? access.selectedUser.id : "未选择" }}</small>
</div>
<span v-if="access.detailLoading" class="status-pill">loading</span>
</header>
<p v-if="access.mutationError" class="form-error">{{ access.mutationError }}</p>
<EmptyState v-if="!access.selectedUser" title="请选择用户" description="从 Access users 表格选择一个用户以编辑 role/status 和 tool matrix。" />
<template v-else>
<div class="access-user-head">
<strong>{{ accessUserLabel(access.selectedUser) }}</strong>
<small>{{ access.selectedUser.username || access.selectedUser.id }}</small>
</div>
<div class="form-grid compact-form-grid">
<label>
<span>Role</span>
<select id="access-role-select" :value="access.selectedUser.role" :disabled="access.savingKey !== null" @change="onRoleChange">
<option value="admin">admin</option>
<option value="member">member</option>
<option value="viewer">viewer</option>
</select>
</label>
<label>
<span>Status</span>
<select id="access-status-select" :value="access.selectedUser.status" :disabled="access.savingKey !== null" @change="onStatusChange">
<option value="active">active</option>
<option value="disabled">disabled</option>
<option value="pending">pending</option>
</select>
</label>
</div>
<div class="tool-toggle-grid">
<label v-for="tool in access.toolRows" :key="tool.id" class="tool-toggle-row" data-access-tool-toggle>
<span>
<strong>{{ tool.label }}</strong>
<small>{{ tool.id }}</small>
</span>
<input type="checkbox" :checked="tool.allowed" :disabled="access.savingKey !== null" @change="onToolChange(tool.id, $event)">
</label>
</div>
<div class="tuple-list">
<h3>Tuples</h3>
<p v-if="!access.detailView.tuples.length" class="muted-box">暂无 tuple</p>
<ul v-else>
<li v-for="tuple in access.detailView.tuples" :key="String(tuple.object || tuple.relation || tuple.userId)">
<code>{{ valueText(tuple.userId) }}</code>
<span>{{ valueText(tuple.relation) }}</span>
<code>{{ valueText(tuple.object) }}</code>
</li>
</ul>
</div>
</template>
</section>
</template>
</section>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from "vue";
const props = withDefaults(defineProps<{ open: boolean; title: string; description?: string; wide?: boolean; closeOnBackdrop?: boolean }>(), {
description: "",
wide: false,
closeOnBackdrop: true
});
const emit = defineEmits<{ close: [] }>();
function close(): void {
emit("close");
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === "Escape" && props.open) close();
}
watch(() => props.open, (open) => {
document.body.classList.toggle("dialog-open", open);
});
onMounted(() => window.addEventListener("keydown", onKeydown));
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
document.body.classList.remove("dialog-open");
});
</script>
<template>
<Teleport to="body">
<div v-if="open" class="workbench-dialog-backdrop" role="presentation" @click.self="closeOnBackdrop ? close() : undefined">
<section class="workbench-dialog" :class="{ wide }" role="dialog" aria-modal="true" :aria-label="title">
<header>
<div class="dialog-title-stack">
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<button type="button" class="dialog-close" aria-label="关闭" @click="close">x</button>
</header>
<slot />
<footer v-if="$slots.footer" class="dialog-footer">
<slot name="footer" />
</footer>
</section>
</div>
</Teleport>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import BaseDialog from "./BaseDialog.vue";
defineProps<{ open: boolean; title: string; message: string; confirmLabel?: string; pending?: boolean }>();
const emit = defineEmits<{ close: []; confirm: [] }>();
</script>
<template>
<BaseDialog :open="open" :title="title" :description="message" @close="emit('close')">
<template #footer>
<button class="table-action" type="button" :disabled="pending" @click="emit('close')">取消</button>
<button class="table-action danger" type="button" :disabled="pending" @click="emit('confirm')">{{ pending ? "处理中" : confirmLabel || "确认" }}</button>
</template>
</BaseDialog>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts" generic="T extends Record<string, unknown>">
export interface DataTableColumn {
key: string;
label: string;
align?: "left" | "right";
}
defineProps<{ columns: DataTableColumn[]; rows: T[]; rowKey: (row: T) => string; emptyText?: string }>();
</script>
<template>
<div class="data-table-wrap">
<table v-if="rows.length" class="data-table crud-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key" :data-align="column.align || 'left'">{{ column.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="rowKey(row)">
<td v-for="column in columns" :key="column.key" :data-align="column.align || 'left'" :data-label="column.label">
<slot :name="`cell-${column.key}`" :row="row">
{{ row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
<p v-else class="muted-box">{{ emptyText || "暂无记录。" }}</p>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ page: number; pageSize: number; total: number }>(), {
page: 1,
pageSize: 10,
total: 0
});
const emit = defineEmits<{ "update:page": [page: number] }>();
function next(delta: number): void {
const pageCount = Math.max(1, Math.ceil(props.total / props.pageSize));
const page = Math.min(pageCount, Math.max(1, props.page + delta));
emit("update:page", page);
}
</script>
<template>
<nav class="table-pagination" aria-label="分页">
<span> {{ page }} / {{ Math.max(1, Math.ceil(total / pageSize)) }} {{ total }} </span>
<div>
<button class="table-action" type="button" :disabled="page <= 1" @click="next(-1)">上一页</button>
<button class="table-action" type="button" :disabled="page >= Math.max(1, Math.ceil(total / pageSize))" @click="next(1)">下一页</button>
</div>
</nav>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{ title: string; subtitle?: string }>();
</script>
<template>
<section class="table-page-layout">
<header class="panel-header table-page-header">
<div>
<h2>{{ title }}</h2>
<small v-if="subtitle">{{ subtitle }}</small>
</div>
<div v-if="$slots.actions" class="table-page-actions">
<slot name="actions" />
</div>
</header>
<div v-if="$slots.filters" class="table-page-filters">
<slot name="filters" />
</div>
<slot />
<footer v-if="$slots.pagination" class="table-page-pagination">
<slot name="pagination" />
</footer>
</section>
</template>
@@ -0,0 +1,26 @@
import { ref } from "vue";
export function useForm() {
const loading = ref(false);
const error = ref<string | null>(null);
const notice = ref<string | null>(null);
async function submit(action: () => Promise<void>, successMessage?: string): Promise<boolean> {
if (loading.value) return false;
loading.value = true;
error.value = null;
notice.value = null;
try {
await action();
notice.value = successMessage ?? null;
return true;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
return false;
} finally {
loading.value = false;
}
}
return { loading, error, notice, submit };
}
+61 -3
View File
@@ -1,13 +1,25 @@
import { ref } from "vue";
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { accessAPI } from "@/api";
import type { AdminAccessSummaryResponse, AdminAccessUsersResponse } from "@/types";
import { normalizeAccessDetail, normalizeAccessSummary, normalizeAccessUsers, type AccessToolRow, type AccessUserRow } from "./admin-access-view";
import type { AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessSummaryResponse, AdminAccessUsersResponse } from "@/types";
export const useAccessStore = defineStore("access", () => {
const summary = ref<AdminAccessSummaryResponse | null>(null);
const users = ref<AdminAccessUsersResponse | null>(null);
const detail = ref<AdminAccessMatrixResponse | null>(null);
const selectedUserId = ref<string | null>(null);
const loading = ref(false);
const detailLoading = ref(false);
const savingKey = ref<string | null>(null);
const error = ref<string | null>(null);
const mutationError = ref<string | null>(null);
const summaryView = computed(() => normalizeAccessSummary(summary.value));
const userRows = computed<AccessUserRow[]>(() => normalizeAccessUsers(users.value));
const detailView = computed(() => normalizeAccessDetail(detail.value));
const toolRows = computed<AccessToolRow[]>(() => detailView.value.tools);
const selectedUser = computed(() => detailView.value.user ?? userRows.value.find((row) => row.id === selectedUserId.value) ?? null);
async function refresh(): Promise<void> {
loading.value = true;
@@ -17,7 +29,53 @@ export const useAccessStore = defineStore("access", () => {
users.value = usersResult.data;
error.value = summaryResult.ok && usersResult.ok ? null : summaryResult.error ?? usersResult.error ?? "Access API unavailable";
loading.value = false;
const firstUserId = userRows.value[0]?.id ?? null;
if (!selectedUserId.value && firstUserId) await selectUser(firstUserId);
}
return { summary, users, loading, error, refresh };
async function selectUser(userId: string): Promise<void> {
if (!userId) return;
selectedUserId.value = userId;
detailLoading.value = true;
mutationError.value = null;
const response = await accessAPI.user(userId);
if (response.ok) {
detail.value = response.data;
} else {
mutationError.value = response.error ?? `HTTP ${response.status}`;
}
detailLoading.value = false;
}
async function updateSelectedUser(payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<void> {
if (!selectedUserId.value) return;
savingKey.value = `user:${selectedUserId.value}`;
mutationError.value = null;
const response = await accessAPI.updateUser(selectedUserId.value, payload);
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
savingKey.value = null;
return;
}
await refresh();
await selectUser(selectedUserId.value);
savingKey.value = null;
}
async function setTool(toolId: AccessToolId, allowed: boolean): Promise<void> {
if (!selectedUserId.value) return;
savingKey.value = `tool:${toolId}`;
mutationError.value = null;
const response = await accessAPI.setTool(selectedUserId.value, toolId, allowed);
if (!response.ok) {
mutationError.value = response.error ?? `HTTP ${response.status}`;
savingKey.value = null;
return;
}
await refresh();
await selectUser(selectedUserId.value);
savingKey.value = null;
}
return { summary, users, detail, selectedUserId, summaryView, userRows, detailView, toolRows, selectedUser, loading, detailLoading, savingKey, error, mutationError, refresh, selectUser, updateSelectedUser, setTool };
});
@@ -0,0 +1,141 @@
import type { AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessSummaryResponse, AdminAccessUsersResponse, AuthUser } from "@/types";
export interface AccessUserRow extends Record<string, unknown> {
id: string;
username: string;
displayName: string;
role: AccessUserRole;
status: AccessUserStatus;
tupleCount: number;
toolCount: number;
tools: Record<string, boolean>;
source: Record<string, unknown>;
}
export interface AccessToolRow extends Record<string, unknown> {
id: AccessToolId;
label: string;
allowed: boolean;
}
export interface AccessSummaryView {
contractVersion: string;
users: number;
tuples: number;
toolTuples: number;
openfga: Record<string, unknown>;
toolIds: string[];
}
const TOOL_LABELS: Record<string, string> = {
hwpod: "HWPOD CLI",
unidesk_ssh: "UniDesk SSH",
trans_cmd: "trans passthrough",
github_pr: "GitHub PR"
};
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 = objectRecord(summary?.openfga) ?? {};
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)
};
}
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);
}
export function normalizeAccessDetail(payload: AdminAccessMatrixResponse | null): { user: AccessUserRow | null; tools: AccessToolRow[]; tuples: Record<string, unknown>[]; openfga: Record<string, unknown> } {
const record = objectRecord(payload);
const user = record ? normalizeUserRow(record, 0) : null;
const tools = normalizeTools(record?.tools, Object.keys(user?.tools ?? {}));
return {
user: user?.id ? user : null,
tools,
tuples: firstArray(record?.tuples),
openfga: objectRecord(record?.openfga) ?? {}
};
}
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 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";
}
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}`;
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
};
}
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 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);
}
@@ -0,0 +1,152 @@
import type { ProviderProfile, ProviderProfileValidation, ProviderProfilesResponse } from "@/types";
export interface ProviderProfileRow extends Record<string, unknown> {
profile: ProviderProfile;
configured: boolean;
credentialSource: string;
credentialHashSuffix: string;
configHashSuffix: string;
resourceVersion: string;
updatedAt: string;
secretRefText: string;
bridgeText: string;
validationStatus: string;
failureKind: string;
valuesPrinted: boolean;
source: Record<string, unknown>;
}
export interface ProviderConfigView {
profile: string;
configToml: string;
configHashSuffix: string;
valuesPrinted: boolean;
}
export interface ProviderValidationView {
validationId: string;
profile: string;
status: string;
runId: string;
commandId: string;
jobName: string;
traceId: string;
failureKind: string;
terminalStatus: string;
valuesPrinted: boolean;
}
export function normalizeProviderProfiles(payload: ProviderProfilesResponse | null): ProviderProfileRow[] {
const record = objectRecord(payload);
const rows = firstArray(record?.items, record?.profiles, objectRecord(record?.data)?.items, objectRecord(record?.data)?.profiles);
return rows.map(normalizeProviderProfile).filter((row) => row.profile);
}
export function normalizeProviderProfilePayload(payload: ProviderProfilesResponse | null): ProviderProfileRow | null {
const record = objectRecord(payload);
const candidate = objectRecord(record?.data) ?? objectRecord(record?.profile) ?? record;
if (!candidate) return null;
const row = normalizeProviderProfile(candidate);
return row.profile ? row : null;
}
export function normalizeProviderConfig(payload: ProviderProfilesResponse | null, fallbackProfile = ""): ProviderConfigView {
const record = objectRecord(payload);
const data = objectRecord(record?.data) ?? record ?? {};
return {
profile: text(data.profile ?? record?.profile) || fallbackProfile,
configToml: text(data.configToml ?? data.toml),
configHashSuffix: text(data.configHashSuffix ?? data.configHash),
valuesPrinted: data.valuesPrinted === true || record?.valuesPrinted === true
};
}
export function normalizeProviderValidation(payload: (ProviderProfilesResponse & ProviderProfileValidation) | null, fallbackProfile = ""): ProviderValidationView {
const record = objectRecord(payload);
const data = objectRecord(record?.data) ?? record ?? {};
const result = objectRecord(data.result);
const agentRun = objectRecord(data.agentRun ?? result?.agentRun);
return {
validationId: text(data.validationId ?? record?.validationId),
profile: text(data.profile ?? record?.profile) || fallbackProfile,
status: text(data.status ?? record?.status) || "unknown",
runId: text(data.runId ?? agentRun?.runId),
commandId: text(data.commandId ?? agentRun?.commandId),
jobName: text(data.jobName ?? agentRun?.jobName),
traceId: text(data.traceId ?? agentRun?.traceId),
failureKind: text(data.failureKind ?? result?.failureKind ?? objectRecord(data.error)?.code),
terminalStatus: text(data.terminalStatus ?? result?.terminalStatus),
valuesPrinted: data.valuesPrinted === true || record?.valuesPrinted === true
};
}
export function validationComplete(status: string): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(status.toLowerCase());
}
export function mergeProviderRow(rows: ProviderProfileRow[], updated: ProviderProfileRow | null): ProviderProfileRow[] {
if (!updated) return rows;
const next = rows.filter((row) => row.profile !== updated.profile);
next.push(updated);
return orderProviderProfiles(next);
}
export function orderProviderProfiles(rows: ProviderProfileRow[]): ProviderProfileRow[] {
const preferred = ["deepseek", "dsflash-go", "codex-api", "minimax-m3"];
return [...rows].sort((a, b) => {
const ai = preferred.indexOf(a.profile);
const bi = preferred.indexOf(b.profile);
if (ai !== -1 || bi !== -1) return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
return a.profile.localeCompare(b.profile);
});
}
function normalizeProviderProfile(value: unknown): ProviderProfileRow {
const record = objectRecord(value) ?? {};
const secretRef = objectRecord(record.secretRef);
const bridge = objectRecord(record.bridge);
const validation = objectRecord(record.validation ?? record.lastValidation);
const profile = text(record.profile ?? record.name ?? record.id ?? record.providerProfile);
return {
profile,
configured: booleanValue(record.configured ?? record.ready),
credentialSource: text(record.credentialSource),
credentialHashSuffix: text(record.credentialHashSuffix ?? record.keyHashSuffix ?? record.apiKeyHashSuffix),
configHashSuffix: text(record.configHashSuffix ?? record.configHash),
resourceVersion: text(record.resourceVersion ?? secretRef?.resourceVersion),
updatedAt: text(record.updatedAt ?? secretRef?.updatedAt),
secretRefText: secretRefText(secretRef),
bridgeText: text(bridge?.route ?? bridge?.serviceUrl ?? record.backendKind ?? record.backend),
validationStatus: text(validation?.status ?? record.validationStatus),
failureKind: text(validation?.failureKind ?? record.failureKind),
valuesPrinted: record.valuesPrinted === true,
source: record
};
}
function secretRefText(secretRef: Record<string, unknown> | null): string {
if (!secretRef) return "-";
const namespace = text(secretRef.namespace);
const name = text(secretRef.name);
const keys = Array.isArray(secretRef.keys) ? secretRef.keys.map((item) => text(item)).filter(Boolean).join(",") : "";
return [namespace, name].filter(Boolean).join("/") + (keys ? ` [${keys}]` : "");
}
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 text(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function booleanValue(value: unknown): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "ready", "configured"].includes(value.toLowerCase());
return Boolean(value);
}
@@ -282,6 +282,28 @@
font-weight: 800;
}
.dialog-title-stack {
display: grid;
min-width: 0;
gap: 3px;
}
.dialog-title-stack h2,
.dialog-title-stack p {
margin: 0;
}
.dialog-title-stack p {
color: #64748b;
font-size: 13px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.probe-list,
.live-build-list,
.hwpod-errors ul {
@@ -1030,6 +1052,48 @@
overflow: auto;
}
.admin-crud-panel,
.table-page-layout {
display: grid;
min-width: 0;
gap: 12px;
padding: 14px;
}
.table-page-layout {
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.table-page-header small,
.panel-header small {
color: #64748b;
font-size: 12px;
}
.table-page-actions,
.table-actions-inline,
.table-pagination,
.table-pagination div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.table-pagination {
justify-content: space-between;
color: #64748b;
font-size: 13px;
}
.data-table-wrap {
min-width: 0;
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
@@ -1046,6 +1110,40 @@
text-align: left;
}
.data-table th[data-align="right"],
.data-table td[data-align="right"] {
text-align: right;
}
.crud-table td {
vertical-align: top;
}
.crud-table small,
.provider-admin-page small,
.access-admin-page small {
display: block;
margin-top: 3px;
color: #64748b;
font-size: 12px;
}
.link-button {
display: grid;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: #0f172a;
cursor: pointer;
padding: 0;
text-align: left;
}
.link-button[data-selected="true"] strong {
color: #0f766e;
}
.usage-table th,
.usage-table td {
white-space: nowrap;
@@ -1096,6 +1194,11 @@
color: #0c4a6e;
}
.table-action.danger:hover:not(:disabled) {
border-color: #ef4444;
color: #991b1b;
}
.table-action:disabled {
cursor: wait;
opacity: 0.55;
@@ -1125,6 +1228,154 @@
color: #991b1b;
}
.status-pill[data-status="admin"],
.status-pill[data-status="completed"],
.status-pill[data-status="ready"] {
border-color: #bbf7d0;
background: #dcfce7;
color: #166534;
}
.status-pill[data-status="failed"],
.status-pill[data-status="blocked"],
.status-pill[data-status="error"] {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
}
.field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.field-grid div {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 10px;
}
.field-grid dt,
.field-grid dd {
min-width: 0;
margin: 0;
}
.field-grid dt {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.field-grid dd {
overflow-wrap: anywhere;
color: #0f172a;
font-size: 13px;
font-weight: 750;
}
.form-grid,
.admin-form {
display: grid;
gap: 12px;
}
.compact-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid label,
.admin-form label {
display: grid;
gap: 6px;
color: #475569;
font-size: 13px;
font-weight: 700;
}
.form-grid select,
.admin-form input,
.admin-form textarea {
min-width: 0;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
color: #0f172a;
font: inherit;
padding: 9px 10px;
}
.admin-form textarea {
resize: vertical;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.form-notice {
margin: 0;
color: #0f766e;
font-size: 12px;
}
.access-user-head,
.tool-toggle-grid,
.tuple-list,
.provider-validation-panel {
display: grid;
gap: 10px;
}
.tool-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 10px;
}
.tool-toggle-row input {
width: 18px;
height: 18px;
}
.tuple-list h3 {
margin: 0;
font-size: 14px;
}
.tuple-list ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.tuple-list li {
display: grid;
grid-template-columns: minmax(0, 1.1fr) auto minmax(0, 1.3fr);
gap: 8px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
}
.tuple-list code,
.provider-admin-page code {
overflow-wrap: anywhere;
color: #0f766e;
font-size: 12px;
}
.toast-host {
position: fixed;
right: 16px;
@@ -1165,6 +1416,11 @@
.usage-panels {
grid-template-columns: 1fr;
}
.field-grid,
.compact-form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
@@ -1,29 +1,266 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { providerProfilesAPI } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import type { ProviderProfilesResponse } from "@/types";
import Pagination from "@/components/common/Pagination.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
import { useTableLoader } from "@/composables/useTableLoader";
import { mergeProviderRow, normalizeProviderConfig, normalizeProviderProfilePayload, normalizeProviderProfiles, normalizeProviderValidation, orderProviderProfiles, validationComplete, type ProviderConfigView, type ProviderProfileRow, type ProviderValidationView } from "@/stores/provider-profiles-view";
import type { ProviderProfile } from "@/types";
const payload = ref<ProviderProfilesResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
onMounted(async () => {
loading.value = true;
const columns: DataTableColumn[] = [
{ key: "profile", label: "Profile" },
{ key: "configured", label: "状态" },
{ key: "secret", label: "SecretRef" },
{ key: "hash", label: "Fingerprint" },
{ key: "bridge", label: "Bridge" },
{ key: "validation", label: "Validation" },
{ key: "actions", label: "操作", align: "right" }
];
const table = useTableLoader<ProviderProfileRow>(loadProfiles);
const selectedProfile = ref<ProviderProfileRow | null>(null);
const credentialDialogOpen = ref(false);
const configDialogOpen = ref(false);
const removeDialogOpen = ref(false);
const apiKeyInput = ref("");
const configText = ref("");
const configInfo = ref<ProviderConfigView | null>(null);
const validation = ref<ProviderValidationView | null>(null);
const validationError = ref<string | null>(null);
const validatingProfile = ref<string | null>(null);
const credentialForm = useForm();
const configForm = useForm();
const removeForm = useForm();
const page = ref(1);
const pageSize = 10;
const rows = computed(() => orderProviderProfiles(table.rows.value));
const pageRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize));
onMounted(() => void table.reload());
async function loadProfiles(): Promise<ProviderProfileRow[]> {
const response = await providerProfilesAPI.list();
payload.value = response.data;
error.value = response.ok ? null : response.error;
loading.value = false;
});
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
return normalizeProviderProfiles(response.data);
}
function rowKey(row: ProviderProfileRow): string {
return row.profile;
}
function openCredential(row: ProviderProfileRow): void {
selectedProfile.value = row;
apiKeyInput.value = "";
credentialDialogOpen.value = true;
}
function openRemove(row: ProviderProfileRow): void {
selectedProfile.value = row;
removeDialogOpen.value = true;
removeForm.error.value = null;
}
async function saveCredential(): Promise<void> {
const row = selectedProfile.value;
if (!row || !apiKeyInput.value.trim()) return;
const ok = await credentialForm.submit(async () => {
const response = await providerProfilesAPI.setKey(row.profile, apiKeyInput.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
table.rows.value = mergeProviderRow(table.rows.value, normalizeProviderProfilePayload(response.data));
}, "Credential updated");
if (ok) {
apiKeyInput.value = "";
credentialDialogOpen.value = false;
}
}
async function openConfig(row: ProviderProfileRow): Promise<void> {
selectedProfile.value = row;
configDialogOpen.value = true;
configForm.error.value = null;
configInfo.value = null;
configText.value = "";
const response = await providerProfilesAPI.config(row.profile);
if (!response.ok) {
configForm.error.value = response.error ?? `HTTP ${response.status}`;
return;
}
const config = normalizeProviderConfig(response.data, row.profile);
configInfo.value = config;
configText.value = config.configToml;
}
async function saveConfig(): Promise<void> {
const row = selectedProfile.value;
if (!row) return;
const ok = await configForm.submit(async () => {
const response = await providerProfilesAPI.setConfig(row.profile, configText.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
table.rows.value = mergeProviderRow(table.rows.value, normalizeProviderProfilePayload(response.data));
}, "Config updated");
if (ok) configDialogOpen.value = false;
}
async function removeProfile(): Promise<void> {
const row = selectedProfile.value;
if (!row) return;
const ok = await removeForm.submit(async () => {
const response = await providerProfilesAPI.remove(row.profile);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
table.rows.value = table.rows.value.filter((item) => item.profile !== row.profile);
}, "Profile removed");
if (ok) {
removeDialogOpen.value = false;
selectedProfile.value = null;
}
}
async function validateProfile(row: ProviderProfileRow): Promise<void> {
validatingProfile.value = row.profile;
validationError.value = null;
validation.value = null;
const submit = await providerProfilesAPI.validate(row.profile);
if (!submit.ok) {
validationError.value = submit.error ?? `HTTP ${submit.status}`;
validatingProfile.value = null;
return;
}
let current = normalizeProviderValidation(submit.data, row.profile);
validation.value = current;
const validationId = current.validationId;
for (let attempt = 0; validationId && attempt < 30 && !validationComplete(current.status); attempt += 1) {
await delay(2000);
const poll = await providerProfilesAPI.validation(row.profile, validationId);
if (!poll.ok) {
validationError.value = poll.error ?? `HTTP ${poll.status}`;
break;
}
current = normalizeProviderValidation(poll.data, row.profile);
validation.value = current;
}
validatingProfile.value = null;
void table.reload();
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function displayDate(value: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
</script>
<template>
<section class="route-stack">
<PageHeader eyebrow="Admin" title="Provider Profiles" description="Provider API key/config 只通过 Cloud API 委托,不暴露 Secret。" />
<LoadingState v-if="loading" />
<EmptyState v-else-if="error" title="Provider Profiles 加载失败" :description="error" />
<EmptyState v-else-if="!payload?.profiles?.length" title="Provider Profiles 骨架已就绪" description="等待 /v1/admin/provider-profiles 返回真实配置状态。" />
<pre v-else class="json-panel">{{ JSON.stringify(payload, null, 2) }}</pre>
<section class="route-stack provider-admin-page">
<PageHeader eyebrow="Admin" title="Provider Profiles" description="Provider credential/config Cloud API 鉴权后委托 AgentRun;页面只显示 SecretRef、fingerprint 和 validation 摘要。" />
<LoadingState v-if="table.loading.value && !rows.length" />
<section v-else-if="table.error.value" class="data-panel admin-crud-panel">
<EmptyState title="Provider Profiles 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<p class="muted-box">当前错误会保留为委托链路状态不再用 raw JSON dump 作为页面主体</p>
</section>
<TablePageLayout v-else title="Profiles" subtitle="AgentRun provider profile registry">
<template #actions>
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
</template>
<DataTable :columns="columns" :rows="pageRows" :row-key="rowKey" empty-text="暂无 provider profile">
<template #cell-profile="{ row }">
<strong>{{ (row as ProviderProfileRow).profile }}</strong>
<small>{{ displayDate((row as ProviderProfileRow).updatedAt) }}</small>
</template>
<template #cell-configured="{ row }">
<span class="status-pill" :data-status="(row as ProviderProfileRow).configured ? 'active' : 'disabled'">{{ (row as ProviderProfileRow).configured ? "configured" : "missing" }}</span>
</template>
<template #cell-secret="{ row }">
<code>{{ (row as ProviderProfileRow).secretRefText }}</code>
<small>{{ (row as ProviderProfileRow).resourceVersion || "rv -" }}</small>
</template>
<template #cell-hash="{ row }">
<span>key {{ (row as ProviderProfileRow).credentialHashSuffix || "-" }}</span>
<small>config {{ (row as ProviderProfileRow).configHashSuffix || "-" }}</small>
</template>
<template #cell-bridge="{ row }">
{{ (row as ProviderProfileRow).bridgeText || "-" }}
</template>
<template #cell-validation="{ row }">
<span class="status-pill" :data-status="(row as ProviderProfileRow).validationStatus || 'pending'">{{ (row as ProviderProfileRow).validationStatus || "not run" }}</span>
<small v-if="(row as ProviderProfileRow).failureKind">{{ (row as ProviderProfileRow).failureKind }}</small>
</template>
<template #cell-actions="{ row }">
<div class="table-actions-inline">
<button class="table-action" type="button" @click="openCredential(row as ProviderProfileRow)">Key</button>
<button class="table-action" type="button" @click="openConfig(row as ProviderProfileRow)">Config</button>
<button class="table-action" type="button" :disabled="validatingProfile !== null" @click="validateProfile(row as ProviderProfileRow)">{{ validatingProfile === (row as ProviderProfileRow).profile ? "验证中" : "Validate" }}</button>
<button class="table-action danger" type="button" @click="openRemove(row as ProviderProfileRow)">Remove</button>
</div>
</template>
</DataTable>
<template #pagination>
<Pagination v-model:page="page" :page-size="pageSize" :total="rows.length" />
</template>
</TablePageLayout>
<section id="provider-validation-summary" class="data-panel admin-crud-panel provider-validation-panel">
<header class="panel-header">
<h2>Validation</h2>
<span class="status-pill" :data-status="validation?.status || (validationError ? 'failed' : 'pending')">{{ validation?.status || (validationError ? "failed" : "idle") }}</span>
</header>
<p v-if="validationError" class="form-error">{{ validationError }}</p>
<dl v-if="validation" class="field-grid">
<div><dt>validationId</dt><dd>{{ validation.validationId || "-" }}</dd></div>
<div><dt>runId</dt><dd>{{ validation.runId || "-" }}</dd></div>
<div><dt>commandId</dt><dd>{{ validation.commandId || "-" }}</dd></div>
<div><dt>jobName</dt><dd>{{ validation.jobName || "-" }}</dd></div>
<div><dt>traceId</dt><dd>{{ validation.traceId || "-" }}</dd></div>
<div><dt>failureKind</dt><dd>{{ validation.failureKind || validation.terminalStatus || "-" }}</dd></div>
</dl>
<p v-else-if="!validationError" class="muted-box">尚未触发本页 validate workflow</p>
</section>
<BaseDialog :open="credentialDialogOpen" title="Provider Credential" :description="selectedProfile?.profile || ''" @close="credentialDialogOpen = false">
<form class="admin-form" @submit.prevent="saveCredential">
<label>
<span>API key</span>
<input id="provider-api-key-input" v-model="apiKeyInput" type="password" autocomplete="off" placeholder="输入后只用于本次提交">
</label>
<p v-if="credentialForm.error.value" class="form-error">{{ credentialForm.error.value }}</p>
<p v-if="credentialForm.notice.value" class="form-notice">{{ credentialForm.notice.value }}</p>
</form>
<template #footer>
<button class="table-action" type="button" :disabled="credentialForm.loading.value" @click="credentialDialogOpen = false">取消</button>
<button class="table-action" type="button" :disabled="credentialForm.loading.value || !apiKeyInput.trim()" @click="saveCredential">{{ credentialForm.loading.value ? "保存中" : "保存" }}</button>
</template>
</BaseDialog>
<BaseDialog :open="configDialogOpen" title="Provider config.toml" :description="selectedProfile?.profile || ''" wide @close="configDialogOpen = false">
<form class="admin-form" @submit.prevent="saveConfig">
<label>
<span>config.toml</span>
<textarea id="provider-config-toml" v-model="configText" rows="14" spellcheck="false" />
</label>
<p v-if="configInfo" class="muted-box">config fingerprint: {{ configInfo.configHashSuffix || "-" }} / valuesPrinted={{ configInfo.valuesPrinted ? "true" : "false" }}</p>
<p v-if="configForm.error.value" class="form-error">{{ configForm.error.value }}</p>
<p v-if="configForm.notice.value" class="form-notice">{{ configForm.notice.value }}</p>
</form>
<template #footer>
<button class="table-action" type="button" :disabled="configForm.loading.value" @click="configDialogOpen = false">取消</button>
<button class="table-action" type="button" :disabled="configForm.loading.value" @click="saveConfig">{{ configForm.loading.value ? "保存中" : "保存" }}</button>
</template>
</BaseDialog>
<ConfirmDialog :open="removeDialogOpen" title="Remove provider profile" :message="`删除 ${selectedProfile?.profile || 'profile'} 的管理配置;内建能力仍由后端决定是否保留。`" confirm-label="删除" :pending="removeForm.loading.value" @close="removeDialogOpen = false" @confirm="removeProfile" />
<p v-if="removeForm.error.value" class="form-error admin-action-error">{{ removeForm.error.value }}</p>
</section>
</template>