diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index fd14cb0e..ed591c84 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -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: diff --git a/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts b/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts new file mode 100644 index 00000000..bc98b7ff --- /dev/null +++ b/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts @@ -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); +}); diff --git a/web/hwlab-cloud-web/src/api/providerProfiles.ts b/web/hwlab-cloud-web/src/api/providerProfiles.ts index b0f5788c..a083c79c 100644 --- a/web/hwlab-cloud-web/src/api/providerProfiles.ts +++ b/web/hwlab-cloud-web/src/api/providerProfiles.ts @@ -4,6 +4,7 @@ import type { ApiResult, ProviderProfile, ProviderProfileValidation, ProviderPro export const providerProfilesAPI = { list: (): Promise> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }), catalog: (): Promise> => fetchJson("/v1/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profile catalog" }), + remove: (profile: ProviderProfile): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}`, { method: "DELETE", timeoutMs: 30000, timeoutName: "provider profile remove" }), setKey: (profile: ProviderProfile, apiKey: string): Promise> => 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> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { timeoutMs: 12000, timeoutName: "provider profile config" }), setConfig: (profile: ProviderProfile, configToml: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { method: "PUT", body: JSON.stringify({ configToml }), timeoutMs: 30000, timeoutName: "provider profile config update" }), diff --git a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue index fdcfbcf6..257bbd71 100644 --- a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue +++ b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue @@ -1,27 +1,176 @@ diff --git a/web/hwlab-cloud-web/src/components/common/BaseDialog.vue b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue new file mode 100644 index 00000000..ab5ef03f --- /dev/null +++ b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/common/ConfirmDialog.vue b/web/hwlab-cloud-web/src/components/common/ConfirmDialog.vue new file mode 100644 index 00000000..b819c770 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/common/ConfirmDialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/common/DataTable.vue b/web/hwlab-cloud-web/src/components/common/DataTable.vue new file mode 100644 index 00000000..5ca1dbaa --- /dev/null +++ b/web/hwlab-cloud-web/src/components/common/DataTable.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/common/Pagination.vue b/web/hwlab-cloud-web/src/components/common/Pagination.vue new file mode 100644 index 00000000..84a6e4a9 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/common/Pagination.vue @@ -0,0 +1,25 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/layout/TablePageLayout.vue b/web/hwlab-cloud-web/src/components/layout/TablePageLayout.vue new file mode 100644 index 00000000..ee8c5fd5 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/layout/TablePageLayout.vue @@ -0,0 +1,24 @@ + + + diff --git a/web/hwlab-cloud-web/src/composables/useForm.ts b/web/hwlab-cloud-web/src/composables/useForm.ts new file mode 100644 index 00000000..96039a4f --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/useForm.ts @@ -0,0 +1,26 @@ +import { ref } from "vue"; + +export function useForm() { + const loading = ref(false); + const error = ref(null); + const notice = ref(null); + + async function submit(action: () => Promise, successMessage?: string): Promise { + 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 }; +} diff --git a/web/hwlab-cloud-web/src/stores/access.ts b/web/hwlab-cloud-web/src/stores/access.ts index 6b71ba36..17935270 100644 --- a/web/hwlab-cloud-web/src/stores/access.ts +++ b/web/hwlab-cloud-web/src/stores/access.ts @@ -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(null); const users = ref(null); + const detail = ref(null); + const selectedUserId = ref(null); const loading = ref(false); + const detailLoading = ref(false); + const savingKey = ref(null); const error = ref(null); + const mutationError = ref(null); + + const summaryView = computed(() => normalizeAccessSummary(summary.value)); + const userRows = computed(() => normalizeAccessUsers(users.value)); + const detailView = computed(() => normalizeAccessDetail(detail.value)); + const toolRows = computed(() => detailView.value.tools); + const selectedUser = computed(() => detailView.value.user ?? userRows.value.find((row) => row.id === selectedUserId.value) ?? null); async function refresh(): Promise { 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 { + 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 { + 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 { + 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 }; }); diff --git a/web/hwlab-cloud-web/src/stores/admin-access-view.ts b/web/hwlab-cloud-web/src/stores/admin-access-view.ts new file mode 100644 index 00000000..8f6fc1f3 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/admin-access-view.ts @@ -0,0 +1,141 @@ +import type { AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessSummaryResponse, AdminAccessUsersResponse, AuthUser } from "@/types"; + +export interface AccessUserRow extends Record { + id: string; + username: string; + displayName: string; + role: AccessUserRole; + status: AccessUserStatus; + tupleCount: number; + toolCount: number; + tools: Record; + source: Record; +} + +export interface AccessToolRow extends Record { + id: AccessToolId; + label: string; + allowed: boolean; +} + +export interface AccessSummaryView { + contractVersion: string; + users: number; + tuples: number; + toolTuples: number; + openfga: Record; + toolIds: string[]; +} + +const TOOL_LABELS: Record = { + 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[]; openfga: Record } { + 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 | 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 { + const record = objectRecord(value) ?? {}; + const out: Record = {}; + for (const [key, allowed] of Object.entries(record)) out[key] = booleanValue(allowed); + return out; +} + +function objectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function firstArray(...values: unknown[]): Record[] { + for (const value of values) if (Array.isArray(value)) return value.filter((item): item is Record => 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); +} diff --git a/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts b/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts new file mode 100644 index 00000000..f33f0224 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts @@ -0,0 +1,152 @@ +import type { ProviderProfile, ProviderProfileValidation, ProviderProfilesResponse } from "@/types"; + +export interface ProviderProfileRow extends Record { + 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; +} + +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 | 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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function firstArray(...values: unknown[]): Record[] { + for (const value of values) if (Array.isArray(value)) return value.filter((item): item is Record => 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); +} diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 7f568fa1..42787a48 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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) { diff --git a/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue b/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue index 69373ba6..99b2b290 100644 --- a/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue +++ b/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue @@ -1,29 +1,266 @@