From b6026f9759c33d4362d51e9619ff9006e07ddb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Thu, 12 Nov 2026 17:33:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=9C=BA=E6=99=AF=E5=8A=A9=E6=89=8B=E4=B8=8E?= =?UTF-8?q?GPU=E5=81=A5=E5=BA=B7=E8=AF=8A=E6=96=AD=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/e2e/editions.spec.ts | 39 +- console/src/api/index.ts | 4 + console/src/api/schema.d.ts | 312 ++++++++++ console/src/views/CommonWorkbench.vue | 43 +- console/src/views/Diagnostics.vue | 16 +- console/src/views/GPUs.vue | 15 +- docs/README.md | 2 + docs/cloud-assistant-wizard-20261112.md | 26 + docs/cloud-feature-progress-20261110.md | 2 + ...loud-gpu-health-exclusive-logs-20261112.md | 18 + docs/current-status.md | 2 + .../cloud-assistant-wizard-20261112/README.md | 7 + .../browser-verification.json | 15 + .../deployment.json | 12 + .../live-workbench-assistant.png | Bin 0 -> 110194 bytes .../state-verification.json | 13 + docs/openapi/platform.v1.json | 573 +++++++++++++++++- docs/roadmap.md | 2 + platform/internal/server/diagnostics.go | 53 ++ platform/internal/server/exclusive_gpu.go | 123 ++++ .../internal/server/exclusive_gpu_test.go | 17 + platform/internal/server/gpu.go | 21 +- platform/internal/server/observability.go | 2 +- platform/internal/server/server.go | 2 + platform/internal/server/server_test.go | 5 + platform/internal/server/terminal.go | 9 + platform/internal/store/schema.sql | 9 + platform/pkg/proxmox/gpu.go | 1 + tools/generate-platform-openapi.mjs | 12 +- 29 files changed, 1333 insertions(+), 22 deletions(-) create mode 100644 docs/cloud-assistant-wizard-20261112.md create mode 100644 docs/cloud-gpu-health-exclusive-logs-20261112.md create mode 100644 docs/evidence/cloud-assistant-wizard-20261112/README.md create mode 100644 docs/evidence/cloud-assistant-wizard-20261112/browser-verification.json create mode 100644 docs/evidence/cloud-assistant-wizard-20261112/deployment.json create mode 100644 docs/evidence/cloud-assistant-wizard-20261112/live-workbench-assistant.png create mode 100644 docs/evidence/cloud-assistant-wizard-20261112/state-verification.json diff --git a/console/e2e/editions.spec.ts b/console/e2e/editions.spec.ts index 0a5f6b8..ab58e47 100644 --- a/console/e2e/editions.spec.ts +++ b/console/e2e/editions.spec.ts @@ -1,9 +1,9 @@ import { test, expect, type Page } from '@playwright/test' -async function fixture(page: Page, options: { role?: string; empty?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) { +async function fixture(page: Page, options: { role?: string; empty?: boolean; ambiguous?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) { const writes: { path: string; body: Record }[] = [], reads: string[] = [] let users = options.empty ? [] : [{ id: 'u1', username: 'zhang', displayName: '张三', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, { id: 'u2', username: 'li', displayName: '李四', enabled: false, terminalId: null, desktopIds: [] as string[] }] - const desktops = options.empty ? [] : ['d1', 'd2'].map((id, i) => ({ id, name: i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() })) + const desktops = options.empty ? [] : ['d1', 'd2', ...(options.ambiguous ? ['d3'] : [])].map((id, i) => ({ id, name: options.ambiguous && i === 2 ? '备用桌面' : i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() })) const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }] const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved }) @@ -129,3 +129,38 @@ test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, inf await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true }) }) + +test('通用工作台固定场景助手生成方案,确认后复用真实授权接口', async ({ page }) => { + const calls = await fixture(page); await page.goto('/workbench') + await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面') + await page.getByRole('button', { name: '生成方案', exact: true }).click() + await expect(page.getByText('可执行方案', { exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: '分配已有桌面', exact: true })).toBeVisible() + await expect(page.locator('.assistant-plan').getByText('备用桌面', { exact: false }).first()).toBeVisible() + await page.getByRole('button', { name: '执行方案', exact: true }).click() + await page.getByRole('button', { name: '确认提交', exact: true }).click() + await expect(page.getByRole('button', { name: '已执行', exact: true })).toBeVisible() + expect(calls.writes.filter(w => w.path === '/users/batch-bindings')).toHaveLength(1) + expect(calls.writes.find(w => w.path === '/users/batch-bindings')?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'grant' }) +}) + +test('通用工作台助手对未知或重复授权给出阻止方案,不发写请求', async ({ page }) => { + const calls = await fixture(page); await page.goto('/workbench') + await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给不存在的人分配一台桌面') + await page.getByRole('button', { name: '生成方案', exact: true }).click() + await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible() + await expect(page.getByText('没有匹配到员工', { exact: false })).toBeVisible() + await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配办公桌面') + await page.getByRole('button', { name: '生成方案', exact: true }).click() + await expect(page.getByText('授权已存在', { exact: false })).toBeVisible() + expect(calls.writes).toHaveLength(0) +}) + +test('通用工作台助手不会在多个同名桌面之间自动选择', async ({ page }) => { + const calls = await fixture(page, { ambiguous: true }); await page.goto('/workbench') + await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面') + await page.getByRole('button', { name: '生成方案', exact: true }).click() + await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible() + await expect(page.getByText('匹配到多个桌面', { exact: false })).toBeVisible() + expect(calls.writes).toHaveLength(0) +}) diff --git a/console/src/api/index.ts b/console/src/api/index.ts index ebcda57..dc3577c 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -52,6 +52,8 @@ export type User = components['schemas']['User'] export type Resource = components['schemas']['Resource'] export type GPUInventory = components['schemas']['GPUInventory'] export type GPUItem = components['schemas']['GPUItem'] +export type GPUExclusiveStatus = components['schemas']['GPUExclusiveStatus'] +export type TerminalLogArchivePage = components['schemas']['TerminalLogArchivePage'] export type Task = components['schemas']['Task'] export type Audit = components['schemas']['Audit'] export type AuditQuery = NonNullable @@ -92,6 +94,8 @@ export const api = { exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })), collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })), gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), + gpuExclusiveStatus: (node: string) => result(client.GET('/resources/gpu-exclusive', { params: { query: { node } } })), + terminalLogArchives: (filters: NonNullable = {}) => result(client.GET('/terminal-logs', { params: { query: filters } })), pveConnection: () => result(client.GET('/settings/pve')), checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })), savePveConnection: (body: PveConnectionInput) => result(client.PUT('/settings/pve', { body })), diff --git a/console/src/api/schema.d.ts b/console/src/api/schema.d.ts index adabad4..024aa63 100644 --- a/console/src/api/schema.d.ts +++ b/console/src/api/schema.d.ts @@ -692,6 +692,22 @@ export interface paths { patch?: never; trace?: never; }; + "/resources/gpu-exclusive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["gpuExclusiveStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/vms": { parameters: { query?: never; @@ -1238,6 +1254,22 @@ export interface paths { patch?: never; trace?: never; }; + "/terminal-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["terminalLogArchives"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -1588,6 +1620,19 @@ export interface components { mediated: boolean; pendingRemoval: boolean; }; + GPUHealth: { + status: string; + source: string; + /** Format: date-time */ + observedAt: string; + temperatureC: number | null; + utilizationPercent: number | null; + memoryUsedBytes: number | null; + memoryTotalBytes: number | null; + driver: string; + /** @enum {string} */ + driverBinding: "bound" | "unknown"; + }; GPUItem: { id: string; address: string; @@ -1599,6 +1644,7 @@ export interface components { iommuGroup: number | null; /** @enum {string} */ state: "unreferenced" | "unknown" | "referenced" | "multiple"; + health: components["schemas"]["GPUHealth"]; references: components["schemas"]["GPUReference"][]; }; GPUIssue: { @@ -1615,6 +1661,14 @@ export interface components { scannedVmCount: number; items: components["schemas"]["GPUItem"][]; issues: components["schemas"]["GPUIssue"][]; + health: { + status: string; + source: string; + temperature: string; + utilization: string; + memory: string; + driverBinding: string; + }; }; Task: { id: string; @@ -2344,6 +2398,53 @@ export interface components { source: string; notes: string[]; }; + TerminalLogArchive: { + id: string; + taskId: string; + terminalId: string; + terminalName: string; + /** Format: date-time */ + capturedAt: string; + bootId: string; + agentVersion: string; + kioskState: string; + logText: string; + }; + TerminalLogArchivePage: { + items: components["schemas"]["TerminalLogArchive"][]; + page: number; + pageSize: number; + total: number; + /** Format: date-time */ + observedAt: string; + retentionDays: number; + }; + GPUExclusiveMember: { + desktopId: string; + name: string; + vmid: number; + powerState: string; + powerObserved: boolean; + activeSessions: number; + activeTasks: number; + conflicts: string[]; + eligible: boolean; + }; + GPUExclusiveStatus: { + enabled: boolean; + node: string; + vmids: number[]; + /** Format: date-time */ + observedAt: string; + lockAvailable: boolean; + groupComplete: boolean; + members: components["schemas"]["GPUExclusiveMember"][]; + conflicts: string[]; + allocationReady: boolean; + releaseReady: boolean; + releasePolicy: string; + activeTasks: components["schemas"]["Task"][]; + }; DesktopList: { items: components["schemas"]["Desktop"][]; page: number; @@ -7833,6 +7934,109 @@ export interface operations { }; }; }; + gpuExclusiveStatus: { + parameters: { + query: { + node: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUExclusiveStatus"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; discoverVMs: { parameters: { query?: { @@ -11882,6 +12086,114 @@ export interface operations { }; }; }; + terminalLogArchives: { + parameters: { + query?: { + terminalId?: string; + keyword?: string; + from?: string; + to?: string; + page?: number; + pageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TerminalLogArchivePage"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; audits: { parameters: { query?: { diff --git a/console/src/views/CommonWorkbench.vue b/console/src/views/CommonWorkbench.vue index 4001611..2f11c70 100644 --- a/console/src/views/CommonWorkbench.vue +++ b/console/src/views/CommonWorkbench.vue @@ -1,16 +1,53 @@ + diff --git a/console/src/views/Diagnostics.vue b/console/src/views/Diagnostics.vue index 0525262..ff6208f 100644 --- a/console/src/views/Diagnostics.vue +++ b/console/src/views/Diagnostics.vue @@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { ElMessage } from 'element-plus' -import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task } from '../api' +import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task, type TerminalLogArchivePage } from '../api' import { auditActionLabels } from '../api/audit-labels' import { usePlatform } from '../stores/platform' import { useAuth } from '../stores/auth' @@ -13,7 +13,7 @@ const end = new Date() const form = reactive({ kind: 'terminal' as 'terminal' | 'desktop', id: '', source: '' as NonNullable, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null }) const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' } const lifecycle: Record = { 'session-reserved': '开始连接', 'session-connected': '连接已建立', 'session-ended': '连接结束', 'alert-opened': '告警触发', 'alert-resolved': '告警恢复' } -const report = ref(null), applied = ref(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false) +const report = ref(null), archives = ref(null), archiveKeyword = ref(''), archiveLoading = ref(false), applied = ref(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false) const collection = ref(null), collectionTarget = ref('') const currentCollection = computed(() => data.tasks.find(t => t.id === collection.value?.id) || collection.value) const objects = computed(() => form.kind === 'terminal' ? data.terminals.map(t => ({ id: t.id, label: `${t.name} / ${t.sn}` })) : data.desktops.map(d => ({ id: d.id, label: `${d.name} / VM ${d.vmid}` }))) @@ -34,10 +34,17 @@ async function load(reset = false) { if (!applied.value) return const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 } loading.value = true; error.value = ''; report.value = null - try { const result = await api.diagnostics(filters); if (version === generation) report.value = result } + try { const result = await api.diagnostics(filters); if (version === generation) { report.value = result; archives.value = result.subject.kind === 'terminal' ? await api.terminalLogArchives({ terminalId: result.subject.id, from: filters.from, to: filters.to, page: 1, pageSize: 20 }) : null } } catch (e) { if (version === generation) error.value = errorMessage(e) } finally { if (version === generation) loading.value = false } } +async function searchArchives() { + if (!report.value || report.value.subject.kind !== 'terminal') return + archiveLoading.value = true + try { archives.value = await api.terminalLogArchives({ terminalId: report.value.subject.id, keyword: archiveKeyword.value || undefined, from: applied.value?.from, to: applied.value?.to, page: 1, pageSize: 20 }) } + catch (e) { ElMessage.error(errorMessage(e)) } + finally { archiveLoading.value = false } +} async function download() { if (!report.value || !applied.value || exporting.value || loading.value) return const filters = { ...applied.value }, version = exportGeneration @@ -85,10 +92,11 @@ watch(() => [route.query.targetType, route.query.targetId], applyRoute)

{{ report.subject.name }}

{{ report.subject.id }} · {{ report.subject.node }} / VM {{ report.subject.vmid }}

当前快照 {{ time(report.observedAt) }} · 最近心跳 {{ time(report.subject.lastSeen) }}

收集此设备日志

{{ pending ? '该设备有未完成或结果未知的命令,请先到任务中心核对。' : report.subject.state !== 'online' ? '设备当前不在线,恢复在线后再采集新日志。' : '采集需要设备回执;已存在的日志可在下方查看。' }}

{{ label }}{{ report.counts[key] }}

关联事件

{{ time(report.from) }} — {{ time(report.to) }} · {{ report.source ? sources[report.source as keyof typeof sources] : '全部来源' }} · 共 {{ report.total }} 条

按事件时间倒序
{{ sources[event.source] }}{{ lifecycle[event.action] || auditActionLabels[event.action] || event.action }}

{{ event.message }}

{{ event.targetName }} · {{ event.actor }}

{{ details(event).logTail || '设备回传的日志片段为空' }}
{{ metadata(event) }}
+

持续日志归档

终端回传的每次日志快照独立归档,保留 {{ archives?.retentionDays || 30 }} 天,可按关键词检索

{{ time(item.capturedAt) }}{{ item.kioskState || '未知状态' }}Agent {{ item.agentVersion || '未报告' }} · boot {{ item.bootId || '未报告' }}
{{ item.logText || '日志为空' }}

任务 {{ item.taskId }}

查询范围说明

{{ note }}

diff --git a/console/src/views/GPUs.vue b/console/src/views/GPUs.vue index b57cde3..c1f9d52 100644 --- a/console/src/views/GPUs.vue +++ b/console/src/views/GPUs.vue @@ -1,11 +1,11 @@