Merge pull request #1169 from pikasTech/fix/v03-r4-admin-crud-1154
fix: 补齐 v0.3 Vue Admin CRUD 与 Provider 委托
This commit is contained in:
+3
-3
@@ -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* 同源 API;OpenFGA 不在浏览器直连。" />
|
||||
<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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user