功能:补充固定场景助手与GPU健康诊断能力

This commit is contained in:
宋一鸣 2026-11-12 17:33:07 +08:00
parent 9f05aa20ea
commit b6026f9759
29 changed files with 1333 additions and 22 deletions

View File

@ -1,9 +1,9 @@
import { test, expect, type Page } from '@playwright/test' 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<string, unknown> }[] = [], reads: string[] = [] const writes: { path: string; body: Record<string, unknown> }[] = [], 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[] }] 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 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() } 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 }) 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.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 }) 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)
})

View File

@ -52,6 +52,8 @@ export type User = components['schemas']['User']
export type Resource = components['schemas']['Resource'] export type Resource = components['schemas']['Resource']
export type GPUInventory = components['schemas']['GPUInventory'] export type GPUInventory = components['schemas']['GPUInventory']
export type GPUItem = components['schemas']['GPUItem'] 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 Task = components['schemas']['Task']
export type Audit = components['schemas']['Audit'] export type Audit = components['schemas']['Audit']
export type AuditQuery = NonNullable<paths['/audit-logs']['get']['parameters']['query']> export type AuditQuery = NonNullable<paths['/audit-logs']['get']['parameters']['query']>
@ -92,6 +94,8 @@ export const api = {
exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })), 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' } })), 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 } } })), 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<paths['/terminal-logs']['get']['parameters']['query']> = {}) => result(client.GET('/terminal-logs', { params: { query: filters } })),
pveConnection: () => result(client.GET('/settings/pve')), pveConnection: () => result(client.GET('/settings/pve')),
checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })), checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })),
savePveConnection: (body: PveConnectionInput) => result(client.PUT('/settings/pve', { body })), savePveConnection: (body: PveConnectionInput) => result(client.PUT('/settings/pve', { body })),

View File

@ -692,6 +692,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/resources/vms": {
parameters: { parameters: {
query?: never; query?: never;
@ -1238,6 +1254,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
@ -1588,6 +1620,19 @@ export interface components {
mediated: boolean; mediated: boolean;
pendingRemoval: 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: { GPUItem: {
id: string; id: string;
address: string; address: string;
@ -1599,6 +1644,7 @@ export interface components {
iommuGroup: number | null; iommuGroup: number | null;
/** @enum {string} */ /** @enum {string} */
state: "unreferenced" | "unknown" | "referenced" | "multiple"; state: "unreferenced" | "unknown" | "referenced" | "multiple";
health: components["schemas"]["GPUHealth"];
references: components["schemas"]["GPUReference"][]; references: components["schemas"]["GPUReference"][];
}; };
GPUIssue: { GPUIssue: {
@ -1615,6 +1661,14 @@ export interface components {
scannedVmCount: number; scannedVmCount: number;
items: components["schemas"]["GPUItem"][]; items: components["schemas"]["GPUItem"][];
issues: components["schemas"]["GPUIssue"][]; issues: components["schemas"]["GPUIssue"][];
health: {
status: string;
source: string;
temperature: string;
utilization: string;
memory: string;
driverBinding: string;
};
}; };
Task: { Task: {
id: string; id: string;
@ -2344,6 +2398,53 @@ export interface components {
source: string; source: string;
notes: 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: { DesktopList: {
items: components["schemas"]["Desktop"][]; items: components["schemas"]["Desktop"][];
page: number; 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: { discoverVMs: {
parameters: { parameters: {
query?: { 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: { audits: {
parameters: { parameters: {
query?: { query?: {

View File

@ -1,16 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
const data = usePlatform(), auth = useAuth() import { api, errorMessage, type Desktop, type User } from '../api'
import { ElMessage } from 'element-plus'
import { useAction } from '../composables/useAction'
const data = usePlatform(), auth = useAuth(), { busy, confirm } = useAction()
const running = computed(() => data.desktops.filter(d => d.powerState === 'running').length) const running = computed(() => data.desktops.filter(d => d.powerState === 'running').length)
const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length) const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length)
const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length) const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length)
const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length) const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length)
const request = ref(''), plan = ref<{ kind: 'grant' | 'route' | 'blocked'; title: string; summary: string; user?: User; desktop?: Desktop; path?: string; reason?: string } | null>(null), executed = ref(false)
const unassignedDesktops = computed(() => data.desktops.filter(d => !data.users.some(u => u.desktopIds.includes(d.id))))
function generatePlan() {
executed.value = false
const text = request.value.trim()
if (!text) { plan.value = null; return }
if (/分配|授权|安排/.test(text)) {
const userMatches = data.users.filter(u => text.includes(u.displayName) || text.includes(u.username))
const desktopMatches = data.desktops.filter(d => text.includes(d.name) || text.includes(String(d.vmid)))
if (userMatches.length !== 1) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号;同名员工需要到“员工与桌面”页面选择准确对象。', reason: userMatches.length ? '匹配到多个员工' : '没有匹配到员工' }; return }
const user = userMatches[0]
if (!user) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号。', reason: '没有匹配到员工' }; return }
if (desktopMatches.length > 1) { plan.value = { kind: 'blocked', title: '还需要确认桌面', summary: '匹配到多个同名桌面,请写出 VMID,或到“员工与桌面”页面选择准确对象。', reason: '匹配到多个桌面' }; return }
const desktop = (desktopMatches.length === 1 ? desktopMatches[0] : unassignedDesktops.value[0])
if (!desktop) { plan.value = { kind: 'blocked', title: '暂时无法生成授权方案', summary: '没有找到可分配的已有桌面。请先由运维纳管桌面,或到高级版检查模板和桌面池。', reason: '没有可用桌面' }; return }
if (user.desktopIds.includes(desktop.id)) { plan.value = { kind: 'blocked', title: '员工已经有这台桌面', summary: `${user.displayName} 已拥有“${desktop.name}”的使用权限,不需要重复授权。`, user, desktop, reason: '授权已存在' }; return }
plan.value = { kind: 'grant', title: '分配已有桌面', summary: `为 ${user.displayName} 追加“${desktop.name}”的使用权限。不会新建 VM,也不会自动开机。`, user, desktop }
return
}
if (/打不开|无法连接|连不上|离线/.test(text)) { plan.value = { kind: 'route', title: '进入问题处理', summary: '先检查设备在线、员工关联、桌面授权、电源和连接服务状态。', path: '/help' }; return }
if (/异常|故障|告警|问题/.test(text)) { plan.value = { kind: 'route', title: '查看需要处理的记录', summary: '打开操作记录,查看失败任务、结果未确认项和最近操作。', path: '/records' }; return }
plan.value = { kind: 'blocked', title: '暂不支持这个场景', summary: '当前无模型接入,只支持“给某员工分配一台已有桌面”、问题处理和操作记录。请改用明确的员工姓名与桌面名称。', reason: '固定场景未匹配' }
}
async function executePlan() {
if (!plan.value || plan.value.kind !== 'grant' || !plan.value.user || !plan.value.desktop || !auth.canAdmin || busy.value) return
if (!await confirm('确认分配桌面', `将为 ${plan.value.user.displayName} 追加“${plan.value.desktop.name}”的使用权限。其他授权保持不变。`)) return
try {
await api.batchBindings({ userIds: [plan.value.user.id], desktopIds: [plan.value.desktop.id], action: 'grant' })
await data.refresh(); executed.value = true; ElMessage.success('方案已执行,授权已保存')
} catch (e) { ElMessage.error(errorMessage(e)) }
}
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div> <div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div>
<div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div> <div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div>
<section class="panel simple-section"><h2>你想完成什么?</h2><p class="subtext">选择一件事,按步骤完成。</p><div class="simple-shortcuts"><router-link to="/people"><strong>员工与桌面 →</strong><span>添加员工,分配或撤销已有桌面</span></router-link><router-link to="/devices"><strong>查看终端设备 →</strong><span>看在线状态,登记新盒子</span></router-link><router-link to="/help"><strong>桌面打不开 →</strong><span>检查设备、使用人和桌面连接条件</span></router-link></div></section> <section class="panel simple-section assistant-box"><h2>你想完成什么?</h2><p class="subtext">用一句话描述,先生成可核对的方案。当前使用固定场景向导,暂未接入模型。</p><div class="assistant-input"><el-input v-model="request" aria-label="描述需求" placeholder="例如:给张三分配一台办公桌面" clearable @keyup.enter="generatePlan" /><el-button type="primary" :disabled="!request.trim()" @click="generatePlan">生成方案</el-button></div><article v-if="plan" class="assistant-plan" :class="`is-${plan.kind}`"><div><el-tag :type="plan.kind === 'grant' ? 'success' : plan.kind === 'blocked' ? 'warning' : 'info'">{{ plan.kind === 'grant' ? '可执行方案' : plan.kind === 'route' ? '查询方案' : '需要补充信息' }}</el-tag><h3>{{ plan.title }}</h3><p>{{ plan.summary }}</p><p v-if="plan.reason" class="secondary">原因:{{ plan.reason }}</p></div><div class="assistant-plan-actions"><el-button v-if="plan.kind === 'grant'" type="primary" :loading="busy" :disabled="!auth.canAdmin || executed" @click="executePlan">{{ executed ? '已执行' : auth.canAdmin ? '执行方案' : '当前角色无权执行' }}</el-button><router-link v-else-if="plan.kind === 'route' && plan.path" :to="plan.path"><el-button>打开页面</el-button></router-link></div><dl v-if="plan.kind === 'grant' && plan.user && plan.desktop"><div><dt>员工</dt><dd>{{ plan.user.displayName }} · {{ plan.user.username }}</dd></div><div><dt>桌面</dt><dd>{{ plan.desktop.name }} · VM {{ plan.desktop.vmid }}</dd></div><div><dt>执行影响</dt><dd>只追加授权,不开机、不建机、不终止已有连接</dd></div></dl></article><div class="simple-shortcuts"><router-link to="/people"><strong>员工与桌面 →</strong><span>添加员工,分配或撤销已有桌面</span></router-link><router-link to="/devices"><strong>查看终端设备 →</strong><span>看在线状态,登记新盒子</span></router-link><router-link to="/help"><strong>桌面打不开 →</strong><span>检查设备、使用人和桌面连接条件</span></router-link></div></section>
<section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section> <section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section>
</template> </template>
<style scoped>
.assistant-box{border-color:var(--vdi-accent)}.assistant-input{display:flex;gap:10px;margin:18px 0}.assistant-input .el-input{min-width:0}.assistant-plan{margin:18px 0;padding:18px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2);display:grid;gap:16px}.assistant-plan.is-grant{border-color:var(--vdi-ok)}.assistant-plan.is-blocked{border-color:var(--vdi-warn)}.assistant-plan h3{font-size:16px;margin:12px 0 8px}.assistant-plan p{line-height:1.8;font-size:13px}.assistant-plan-actions{display:flex;justify-content:flex-end}.assistant-plan dl{display:grid;gap:8px;margin:0;padding-top:14px;border-top:1px solid var(--vdi-border);font-size:12px}.assistant-plan dl>div{display:flex;gap:18px}.assistant-plan dt{color:var(--vdi-muted);min-width:64px}.assistant-plan dd{margin:0}.assistant-box .simple-shortcuts{margin-top:18px}@media(max-width:600px){.assistant-input{align-items:stretch;flex-direction:column}.assistant-plan-actions{justify-content:stretch}.assistant-plan-actions .el-button,.assistant-plan-actions a{width:100%}.assistant-plan-actions .el-button{width:100%}.assistant-plan dl>div{display:block}.assistant-plan dd{margin-top:5px}}
</style>

View File

@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' 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 { auditActionLabels } from '../api/audit-labels'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth' 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<DiagnosticQuery['source']>, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null }) const form = reactive({ kind: 'terminal' as 'terminal' | 'desktop', id: '', source: '' as NonNullable<DiagnosticQuery['source']>, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null })
const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' } const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' }
const lifecycle: Record<string, string> = { 'session-reserved': '开始连接', 'session-connected': '连接已建立', 'session-ended': '连接结束', 'alert-opened': '告警触发', 'alert-resolved': '告警恢复' } const lifecycle: Record<string, string> = { 'session-reserved': '开始连接', 'session-connected': '连接已建立', 'session-ended': '连接结束', 'alert-opened': '告警触发', 'alert-resolved': '告警恢复' }
const report = ref<DiagnosticReport | null>(null), applied = ref<DiagnosticQuery | null>(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false) const report = ref<DiagnosticReport | null>(null), archives = ref<TerminalLogArchivePage | null>(null), archiveKeyword = ref(''), archiveLoading = ref(false), applied = ref<DiagnosticQuery | null>(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false)
const collection = ref<Task | null>(null), collectionTarget = ref('') const collection = ref<Task | null>(null), collectionTarget = ref('')
const currentCollection = computed(() => data.tasks.find(t => t.id === collection.value?.id) || collection.value) 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}` }))) 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 if (!applied.value) return
const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 } const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 }
loading.value = true; error.value = ''; report.value = null 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) } catch (e) { if (version === generation) error.value = errorMessage(e) }
finally { if (version === generation) loading.value = false } 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() { async function download() {
if (!report.value || !applied.value || exporting.value || loading.value) return if (!report.value || !applied.value || exporting.value || loading.value) return
const filters = { ...applied.value }, version = exportGeneration const filters = { ...applied.value }, version = exportGeneration
@ -85,10 +92,11 @@ watch(() => [route.query.targetType, route.query.targetId], applyRoute)
<section class="panel diagnosis-subject"><div><h2>{{ report.subject.name }}</h2><p class="mono secondary">{{ report.subject.id }}<span v-if="report.subject.node"> · {{ report.subject.node }} / VM {{ report.subject.vmid }}</span></p><p class="secondary">当前快照 {{ time(report.observedAt) }}<span v-if="report.subject.lastSeen"> · 最近心跳 {{ time(report.subject.lastSeen) }}</span></p></div><div class="diagnosis-subject-actions"><StatusTag :value="report.subject.state" /><el-button v-if="report.subject.kind === 'terminal' && auth.canOperate" :loading="collecting" :disabled="!canCollect" @click="collect">收集此设备日志</el-button></div><p v-if="report.subject.kind === 'terminal'" class="diagnosis-footnote">{{ pending ? '该设备有未完成或结果未知的命令,请先到任务中心核对。' : report.subject.state !== 'online' ? '设备当前不在线,恢复在线后再采集新日志。' : '采集需要设备回执;已存在的日志可在下方查看。' }}</p></section> <section class="panel diagnosis-subject"><div><h2>{{ report.subject.name }}</h2><p class="mono secondary">{{ report.subject.id }}<span v-if="report.subject.node"> · {{ report.subject.node }} / VM {{ report.subject.vmid }}</span></p><p class="secondary">当前快照 {{ time(report.observedAt) }}<span v-if="report.subject.lastSeen"> · 最近心跳 {{ time(report.subject.lastSeen) }}</span></p></div><div class="diagnosis-subject-actions"><StatusTag :value="report.subject.state" /><el-button v-if="report.subject.kind === 'terminal' && auth.canOperate" :loading="collecting" :disabled="!canCollect" @click="collect">收集此设备日志</el-button></div><p v-if="report.subject.kind === 'terminal'" class="diagnosis-footnote">{{ pending ? '该设备有未完成或结果未知的命令,请先到任务中心核对。' : report.subject.state !== 'online' ? '设备当前不在线,恢复在线后再采集新日志。' : '采集需要设备回执;已存在的日志可在下方查看。' }}</p></section>
<div class="diagnosis-counts"><div v-for="(label, key) in sources" :key="key" class="panel"><span>{{ label }}</span><strong>{{ report.counts[key] }}</strong></div></div> <div class="diagnosis-counts"><div v-for="(label, key) in sources" :key="key" class="panel"><span>{{ label }}</span><strong>{{ report.counts[key] }}</strong></div></div>
<section class="panel diagnosis-timeline"><div class="diagnosis-timeline-heading"><div><h2>关联事件</h2><p class="subtext">{{ time(report.from) }} — {{ time(report.to) }} · {{ report.source ? sources[report.source as keyof typeof sources] : '全部来源' }} · 共 {{ report.total }} 条</p></div><el-tag type="info">按事件时间倒序</el-tag></div><el-alert v-if="report.total > 2000" type="warning" title="事件超过 2000 条,请缩小时间范围或选择记录来源后导出" :closable="false" class="notice" /><el-empty v-if="!report.items.length" description="此范围内没有匹配记录;未采集或未上报不代表没有故障" :image-size="90" /><el-timeline v-else><el-timeline-item v-for="event in report.items" :key="event.id" :timestamp="time(event.time)" placement="top"><article class="diagnosis-event"><div class="diagnosis-event-heading"><el-tag size="small" type="info">{{ sources[event.source] }}</el-tag><strong>{{ lifecycle[event.action] || auditActionLabels[event.action] || event.action }}</strong><StatusTag :value="event.status" /></div><p>{{ event.message }}</p><p class="secondary">{{ event.targetName }}<span v-if="event.actor"> · {{ event.actor }}</span></p><el-collapse><el-collapse-item title="查看关联标识与详情" :name="event.id"><pre v-if="typeof details(event).logTail === 'string'" class="diagnosis-log">{{ details(event).logTail || '设备回传的日志片段为空' }}</pre><pre>{{ metadata(event) }}</pre></el-collapse-item></el-collapse></article></el-timeline-item></el-timeline><el-pagination v-model:current-page="page" :disabled="loading" :total="report.total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" /></section> <section class="panel diagnosis-timeline"><div class="diagnosis-timeline-heading"><div><h2>关联事件</h2><p class="subtext">{{ time(report.from) }} — {{ time(report.to) }} · {{ report.source ? sources[report.source as keyof typeof sources] : '全部来源' }} · 共 {{ report.total }} 条</p></div><el-tag type="info">按事件时间倒序</el-tag></div><el-alert v-if="report.total > 2000" type="warning" title="事件超过 2000 条,请缩小时间范围或选择记录来源后导出" :closable="false" class="notice" /><el-empty v-if="!report.items.length" description="此范围内没有匹配记录;未采集或未上报不代表没有故障" :image-size="90" /><el-timeline v-else><el-timeline-item v-for="event in report.items" :key="event.id" :timestamp="time(event.time)" placement="top"><article class="diagnosis-event"><div class="diagnosis-event-heading"><el-tag size="small" type="info">{{ sources[event.source] }}</el-tag><strong>{{ lifecycle[event.action] || auditActionLabels[event.action] || event.action }}</strong><StatusTag :value="event.status" /></div><p>{{ event.message }}</p><p class="secondary">{{ event.targetName }}<span v-if="event.actor"> · {{ event.actor }}</span></p><el-collapse><el-collapse-item title="查看关联标识与详情" :name="event.id"><pre v-if="typeof details(event).logTail === 'string'" class="diagnosis-log">{{ details(event).logTail || '设备回传的日志片段为空' }}</pre><pre>{{ metadata(event) }}</pre></el-collapse-item></el-collapse></article></el-timeline-item></el-timeline><el-pagination v-model:current-page="page" :disabled="loading" :total="report.total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" /></section>
<section v-if="report.subject.kind === 'terminal'" class="panel diagnosis-archives"><div class="diagnosis-timeline-heading"><div><h2>持续日志归档</h2><p class="subtext">终端回传的每次日志快照独立归档,保留 {{ archives?.retentionDays || 30 }} 天,可按关键词检索</p></div><div class="archive-search"><el-input v-model="archiveKeyword" clearable placeholder="搜索日志关键词" @keyup.enter="searchArchives" /><el-button :loading="archiveLoading" @click="searchArchives">检索</el-button></div></div><el-empty v-if="!archives?.items.length" description="没有匹配的归档快照;持续归档从终端回传日志后开始" :image-size="80" /><div v-else class="archive-list"><article v-for="item in archives.items" :key="item.id" class="archive-item"><div class="archive-item-heading"><strong>{{ time(item.capturedAt) }}</strong><el-tag size="small" type="info">{{ item.kioskState || '未知状态' }}</el-tag><span class="secondary">Agent {{ item.agentVersion || '未报告' }} · boot {{ item.bootId || '未报告' }}</span></div><pre>{{ item.logText || '日志为空' }}</pre><p class="secondary mono">任务 {{ item.taskId }}</p></article></div></section>
<section class="panel diagnosis-notes"><h3>查询范围说明</h3><p v-for="note in report.notes" :key="note" class="subtext">{{ note }}</p></section> <section class="panel diagnosis-notes"><h3>查询范围说明</h3><p v-for="note in report.notes" :key="note" class="subtext">{{ note }}</p></section>
</template> </template>
<section v-else-if="!error" class="panel diagnosis-empty"><el-empty description="选择需要排查的设备或桌面,再查询诊断记录" :image-size="110" /></section> <section v-else-if="!error" class="panel diagnosis-empty"><el-empty description="选择需要排查的设备或桌面,再查询诊断记录" :image-size="110" /></section>
</template> </template>
<style scoped> <style scoped>
.diagnosis-filters{padding:22px}.diagnosis-query{display:grid;grid-template-columns:minmax(190px,1fr) minmax(320px,1.5fr) 160px auto;gap:12px;margin:18px 0}.diagnosis-query :deep(.el-date-editor){width:100%;min-width:0}.diagnosis-subject{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap;margin-top:20px}.diagnosis-subject h2{font-size:19px;margin-bottom:8px}.diagnosis-subject p{line-height:1.8;font-size:12px}.diagnosis-subject-actions{display:flex;align-items:center;gap:14px}.diagnosis-footnote{width:100%;color:var(--vdi-muted)}.diagnosis-counts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:20px 0}.diagnosis-counts .panel{padding:18px;margin:0}.diagnosis-counts span{font-size:12px;color:var(--vdi-muted)}.diagnosis-counts strong{display:block;font-size:26px;margin-top:12px}.diagnosis-timeline-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:26px;flex-wrap:wrap}.diagnosis-timeline h2{font-size:18px}.diagnosis-timeline :deep(.el-timeline){padding-left:4px}.diagnosis-event{border:1px solid var(--vdi-border);border-radius:12px;padding:16px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.diagnosis-event-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.diagnosis-event-heading strong{font-size:14px}.diagnosis-event p{font-size:13px;line-height:1.9;margin-top:9px}.diagnosis-event pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.8;max-height:380px;overflow:auto}.diagnosis-log{padding:16px;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:8px;margin:10px 0}.diagnosis-event :deep(.el-collapse){margin-top:12px;--el-collapse-header-bg-color:transparent;--el-collapse-content-bg-color:transparent}.diagnosis-collection{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diagnosis-collection p{width:100%;font-size:13px}.diagnosis-notes{margin-top:20px}.diagnosis-notes h3{font-size:14px}.diagnosis-notes p{margin-top:12px}.diagnosis-empty{margin-top:20px}@media(max-width:1250px){.diagnosis-query{grid-template-columns:1fr 1fr}.diagnosis-query :deep(.el-date-editor){grid-column:1/-1;grid-row:2}.diagnosis-query>.el-button{grid-column:1/-1}}@media(max-width:600px){.diagnosis-query{grid-template-columns:minmax(0,1fr)}.diagnosis-query :deep(.el-date-editor){height:62px;flex-wrap:wrap;gap:4px}.diagnosis-filters{padding:18px}.diagnosis-counts{grid-template-columns:repeat(2,minmax(0,1fr))}.diagnosis-event{padding:12px}.diagnosis-subject-actions{flex-wrap:wrap}.diagnosis-counts strong{font-size:23px}} .diagnosis-filters{padding:22px}.diagnosis-query{display:grid;grid-template-columns:minmax(190px,1fr) minmax(320px,1.5fr) 160px auto;gap:12px;margin:18px 0}.diagnosis-query :deep(.el-date-editor){width:100%;min-width:0}.diagnosis-subject{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap;margin-top:20px}.diagnosis-subject h2{font-size:19px;margin-bottom:8px}.diagnosis-subject p{line-height:1.8;font-size:12px}.diagnosis-subject-actions{display:flex;align-items:center;gap:14px}.diagnosis-footnote{width:100%;color:var(--vdi-muted)}.diagnosis-counts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:20px 0}.diagnosis-counts .panel{padding:18px;margin:0}.diagnosis-counts span{font-size:12px;color:var(--vdi-muted)}.diagnosis-counts strong{display:block;font-size:26px;margin-top:12px}.diagnosis-timeline-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:26px;flex-wrap:wrap}.diagnosis-timeline h2{font-size:18px}.diagnosis-timeline :deep(.el-timeline){padding-left:4px}.diagnosis-event{border:1px solid var(--vdi-border);border-radius:12px;padding:16px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.diagnosis-event-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.diagnosis-event-heading strong{font-size:14px}.diagnosis-event p{font-size:13px;line-height:1.9;margin-top:9px}.diagnosis-event pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.8;max-height:380px;overflow:auto}.diagnosis-log{padding:16px;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:8px;margin:10px 0}.diagnosis-event :deep(.el-collapse){margin-top:12px;--el-collapse-header-bg-color:transparent;--el-collapse-content-bg-color:transparent}.diagnosis-collection{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diagnosis-collection p{width:100%;font-size:13px}.diagnosis-archives{margin-top:20px}.archive-search{display:flex;gap:8px;min-width:300px}.archive-list{display:grid;gap:12px}.archive-item{border:1px solid var(--vdi-border);border-radius:10px;padding:14px;background:var(--vdi-panel-2)}.archive-item-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.archive-item pre{white-space:pre-wrap;overflow:auto;max-height:260px;margin:12px 0;padding:12px;background:var(--vdi-bg);border-radius:8px;font-size:12px;line-height:1.7}.diagnosis-notes{margin-top:20px}.diagnosis-notes h3{font-size:14px}.diagnosis-notes p{margin-top:12px}.diagnosis-empty{margin-top:20px}@media(max-width:1250px){.diagnosis-query{grid-template-columns:1fr 1fr}.diagnosis-query :deep(.el-date-editor){grid-column:1/-1;grid-row:2}.diagnosis-query>.el-button{grid-column:1/-1}}@media(max-width:600px){.diagnosis-query{grid-template-columns:minmax(0,1fr)}.diagnosis-query :deep(.el-date-editor){height:62px;flex-wrap:wrap;gap:4px}.diagnosis-filters{padding:18px}.diagnosis-counts{grid-template-columns:repeat(2,minmax(0,1fr))}.diagnosis-event{padding:12px}.diagnosis-subject-actions{flex-wrap:wrap}.diagnosis-counts strong{font-size:23px}}
</style> </style>

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { Cpu, Refresh, Search } from '@element-plus/icons-vue' import { Cpu, Refresh, Search } from '@element-plus/icons-vue'
import { api, errorMessage, type GPUInventory, type GPUItem } from '../api' import { api, errorMessage, type GPUExclusiveStatus, type GPUInventory, type GPUItem } from '../api'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import StatusTag from '../components/StatusTag.vue' import StatusTag from '../components/StatusTag.vue'
const platform = usePlatform(), node = ref(''), inventory = ref<GPUInventory>(), busy = ref(false), error = ref(''), keyword = ref(''), state = ref(''), selected = ref<GPUItem>() const platform = usePlatform(), node = ref(''), inventory = ref<GPUInventory>(), exclusive = ref<GPUExclusiveStatus>(), busy = ref(false), error = ref(''), keyword = ref(''), state = ref(''), selected = ref<GPUItem>()
const nodes = computed(() => platform.resources.filter(r => r.provider === 'proxmox')) const nodes = computed(() => platform.resources.filter(r => r.provider === 'proxmox'))
const labels = { referenced: '有配置引用', multiple: '多 VM 引用', unreferenced: '未发现配置引用', unknown: '待核查' } const labels = { referenced: '有配置引用', multiple: '多 VM 引用', unreferenced: '未发现配置引用', unknown: '待核查' }
const tones = { referenced: 'info', multiple: 'warning', unreferenced: 'info', unknown: 'warning' } as const const tones = { referenced: 'info', multiple: 'warning', unreferenced: 'info', unknown: 'warning' } as const
@ -22,7 +22,7 @@ async function load() {
const version = ++generation, target = node.value const version = ++generation, target = node.value
inventory.value = undefined; error.value = ''; busy.value = !!target inventory.value = undefined; error.value = ''; busy.value = !!target
if (!target) return if (!target) return
try { const value = await api.gpuInventory(target); if (version === generation) { inventory.value = value; now.value = Date.now() } } try { const [value, group] = await Promise.all([api.gpuInventory(target), api.gpuExclusiveStatus(target)]); if (version === generation) { inventory.value = value; exclusive.value = group; now.value = Date.now() } }
catch (e) { if (version === generation) error.value = errorMessage(e) } catch (e) { if (version === generation) error.value = errorMessage(e) }
finally { if (version === generation) busy.value = false } finally { if (version === generation) busy.value = false }
} }
@ -32,19 +32,20 @@ const owners = (gpu: GPUItem) => [...new Set(gpu.references.map(r => `${r.vmName
<div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div> <div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div>
<el-alert v-if="!platform.settings?.pveConfigured" type="info" title="尚未接入 Proxmox" description="由管理员在系统设置配置接入后,读取节点的真实显卡清单。" :closable="false" class="notice" /> <el-alert v-if="!platform.settings?.pveConfigured" type="info" title="尚未接入 Proxmox" description="由管理员在系统设置配置接入后,读取节点的真实显卡清单。" :closable="false" class="notice" />
<el-alert v-else-if="!nodes.length" type="warning" title="暂无可查询节点,请先在计算资源同步节点" :closable="false" class="notice" /> <el-alert v-else-if="!nodes.length" type="warning" title="暂无可查询节点,请先在计算资源同步节点" :closable="false" class="notice" />
<section class="panel gpu-controls"><div class="toolbar"><el-select v-model="node" aria-label="GPU 计算节点" placeholder="选择计算节点"><el-option v-for="n in nodes" :key="n.id" :value="n.id" :label="n.name" /></el-select><el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索显卡、PCI 或虚拟机" aria-label="搜索 GPU" /><el-select v-model="state" clearable placeholder="全部引用状态" aria-label="GPU 引用状态"><el-option v-for="(label, value) in labels" :key="value" :value="value" :label="label" /></el-select></div><p class="secondary">配置引用包含关机虚拟机、模板和待应用变更。当前页面不判断宿主驱动占用或设备是否可分配。</p></section> <section class="panel gpu-controls"><div class="toolbar"><el-select v-model="node" aria-label="GPU 计算节点" placeholder="选择计算节点"><el-option v-for="n in nodes" :key="n.id" :value="n.id" :label="n.name" /></el-select><el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索显卡、PCI 或虚拟机" aria-label="搜索 GPU" /><el-select v-model="state" clearable placeholder="全部引用状态" aria-label="GPU 引用状态"><el-option v-for="(label, value) in labels" :key="value" :value="value" :label="label" /></el-select></div><p class="secondary">配置引用包含关机虚拟机、模板和待应用变更。健康数据来自 PVE PCI 清单;标准 PVE API 未提供温度、利用率和显存时显示“未采集”。</p></section>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert> <el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert>
<template v-if="inventory"> <template v-if="inventory">
<section v-if="exclusive?.enabled" class="panel gpu-exclusive"><div class="gpu-exclusive-heading"><div><h2>独占资源状态</h2><p class="subtext">分配前检查与占用释放条件</p></div><el-tag :type="exclusive.allocationReady ? 'success' : 'warning'">{{ exclusive.allocationReady ? '可分配' : '需处理冲突' }}</el-tag></div><div class="gpu-exclusive-summary"><span>锁 {{ exclusive.lockAvailable ? '可用' : '占用' }}</span><span>成员 {{ exclusive.members.length }}/{{ exclusive.vmids.length }}</span><span>释放 {{ exclusive.releaseReady ? '可检查' : '不可释放' }}</span></div><el-alert v-if="exclusive.conflicts.length" type="warning" :closable="false" title="当前冲突"><ul class="gpu-conflicts"><li v-for="(item, index) in exclusive.conflicts" :key="index">{{ item }}</li></ul></el-alert><el-table :data="exclusive.members" size="small" style="margin-top:14px"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column prop="vmid" label="VMID" width="80" /><el-table-column label="实际电源" width="110"><template #default="scope"><el-tag :type="scope.row.powerObserved ? (scope.row.powerState === 'stopped' ? 'success' : 'warning') : 'info'">{{ scope.row.powerObserved ? scope.row.powerState : '未采集' }}</el-tag></template></el-table-column><el-table-column label="会话 / 任务" width="110"><template #default="scope">{{ scope.row.activeSessions }} / {{ scope.row.activeTasks }}</template></el-table-column><el-table-column label="分配" width="90"><template #default="scope"><el-tag :type="scope.row.eligible ? 'success' : 'warning'">{{ scope.row.eligible ? '可用' : '冲突' }}</el-tag></template></el-table-column></el-table><p class="secondary gpu-release-note">释放仅表示当前无会话和未完成任务,平台不会自动关机或重放切换操作;需从终端准备或任务核对流程完成实际变更。</p></section>
<div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div> <div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div>
<section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section> <section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section>
<div v-if="items.length" class="gpu-grid"><article v-for="gpu in items" :key="gpu.id" class="panel gpu-card"><div class="gpu-card-top"><div class="gpu-symbol"><el-icon :size="26"><Cpu /></el-icon></div><el-tag :type="tones[gpu.state]">{{ labels[gpu.state] }}</el-tag></div><p class="eyebrow">{{ gpu.vendor || gpu.vendorId }}</p><h2>{{ gpu.name || '显卡 ' + gpu.deviceId }}</h2><dl><div><dt>PCI 地址</dt><dd class="mono">{{ gpu.address }}</dd></div><div><dt>节点</dt><dd>{{ inventory.node }}</dd></div><div><dt>IOMMU 分组</dt><dd>{{ gpu.iommuGroup === null ? '未报告' : gpu.iommuGroup }}</dd></div><div><dt>配置引用</dt><dd>{{ owners(gpu) || (gpu.state === 'unknown' ? '检查范围不完整' : '未发现') }}</dd></div></dl><div class="gpu-card-footer"><span class="secondary">利用率、显存与温度尚未采集</span><el-button link type="primary" @click="selected = gpu">查看 GPU 详情</el-button></div></article></div> <div v-if="items.length" class="gpu-grid"><article v-for="gpu in items" :key="gpu.id" class="panel gpu-card"><div class="gpu-card-top"><div class="gpu-symbol"><el-icon :size="26"><Cpu /></el-icon></div><el-tag :type="tones[gpu.state]">{{ labels[gpu.state] }}</el-tag></div><p class="eyebrow">{{ gpu.vendor || gpu.vendorId }}</p><h2>{{ gpu.name || '显卡 ' + gpu.deviceId }}</h2><dl><div><dt>PCI 地址</dt><dd class="mono">{{ gpu.address }}</dd></div><div><dt>节点</dt><dd>{{ inventory.node }}</dd></div><div><dt>IOMMU 分组</dt><dd>{{ gpu.iommuGroup === null ? '未报告' : gpu.iommuGroup }}</dd></div><div><dt>驱动绑定</dt><dd><el-tag size="small" :type="gpu.health.driverBinding === 'bound' ? 'success' : 'info'">{{ gpu.health.driverBinding === 'bound' ? gpu.health.driver || '已绑定' : '未采集' }}</el-tag></dd></div><div><dt>配置引用</dt><dd>{{ owners(gpu) || (gpu.state === 'unknown' ? '检查范围不完整' : '未发现') }}</dd></div></dl><div class="gpu-card-footer"><span class="secondary">温度 / 利用率 / 显存:{{ gpu.health.status === 'not_collected' ? '未采集' : '部分采集' }}</span><el-button link type="primary" @click="selected = gpu">查看 GPU 详情</el-button></div></article></div>
<section v-else class="panel"><el-empty :description="inventory.items.length ? '暂无符合筛选条件的显卡' : '当前节点未发现显示控制器类 PCI 设备'" /></section> <section v-else class="panel"><el-empty :description="inventory.items.length ? '暂无符合筛选条件的显卡' : '当前节点未发现显示控制器类 PCI 设备'" /></section>
</template> </template>
<section v-else-if="busy" class="panel empty-panel" aria-live="polite"><p class="subtext">正在读取显卡与虚拟机配置…</p></section> <section v-else-if="busy" class="panel empty-panel" aria-live="polite"><p class="subtext">正在读取显卡与虚拟机配置…</p></section>
<el-drawer :model-value="!!selected" title="GPU 资源详情" size="600px" @closed="selected = undefined"> <el-drawer :model-value="!!selected" title="GPU 资源详情" size="600px" @closed="selected = undefined">
<template v-if="selected && inventory"><h2 class="gpu-detail-name">{{ selected.name || selected.deviceId }}</h2><p class="subtext mono">{{ inventory.node }} · {{ selected.address }}</p><el-tag :type="tones[selected.state]" style="margin:16px 0">{{ labels[selected.state] }}</el-tag><el-descriptions :column="1" border><el-descriptions-item label="厂商">{{ selected.vendor || '未报告' }}</el-descriptions-item><el-descriptions-item label="厂商 / 设备 ID">{{ selected.vendorId }} / {{ selected.deviceId }}</el-descriptions-item><el-descriptions-item label="PCI 类别">{{ selected.class }}</el-descriptions-item><el-descriptions-item label="IOMMU 分组">{{ selected.iommuGroup === null ? '未报告' : selected.iommuGroup }}</el-descriptions-item></el-descriptions><h3 class="gpu-ref-title">虚拟机配置引用</h3><p class="subtext">配置记录不代表设备实际已由虚拟机使用;待应用项在重启后可能生效。</p><article v-for="(owner, index) in selected.references" :key="index" class="gpu-reference"><div class="row"><strong>{{ owner.vmName || '虚拟机' }} · VM {{ owner.vmid }}</strong><StatusTag :value="owner.vmStatus" /></div><div class="gpu-ref-tags"><el-tag size="small">{{ owner.phase === 'current' ? '当前配置' : '待应用配置' }}</el-tag><el-tag v-if="owner.pendingRemoval" size="small" type="warning">待移除</el-tag><el-tag v-if="owner.template" size="small" type="info">模板</el-tag><el-tag size="small" type="info">{{ owner.mediated ? '介导设备' : 'PCI 直通' }}</el-tag></div><p class="secondary">{{ owner.slot }} · {{ owner.source === 'mapping' ? 'PCI 映射 ' + owner.mapping : '直接 PCI 地址' }}</p><p class="secondary">{{ owner.desktopId ? '已纳管桌面:' + owner.desktopName : '尚未纳管为平台桌面' }}</p></article><el-empty v-if="!selected.references.length" :description="selected.state === 'unknown' ? '检查范围不完整,引用情况待核查' : '未发现 QEMU 配置引用'" /><p class="subtext">本页提供资源查看;显卡绑定、解绑、运行驱动及健康指标尚未接入。</p></template> <template v-if="selected && inventory"><h2 class="gpu-detail-name">{{ selected.name || selected.deviceId }}</h2><p class="subtext mono">{{ inventory.node }} · {{ selected.address }}</p><el-tag :type="tones[selected.state]" style="margin:16px 0">{{ labels[selected.state] }}</el-tag><el-descriptions :column="1" border><el-descriptions-item label="厂商">{{ selected.vendor || '未报告' }}</el-descriptions-item><el-descriptions-item label="厂商 / 设备 ID">{{ selected.vendorId }} / {{ selected.deviceId }}</el-descriptions-item><el-descriptions-item label="PCI 类别">{{ selected.class }}</el-descriptions-item><el-descriptions-item label="IOMMU 分组">{{ selected.iommuGroup === null ? '未报告' : selected.iommuGroup }}</el-descriptions-item><el-descriptions-item label="驱动绑定">{{ selected.health.driverBinding === 'bound' ? selected.health.driver : '未采集' }}</el-descriptions-item><el-descriptions-item label="温度 / 利用率 / 显存">未采集(标准 PVE PCI 接口不提供运行指标)</el-descriptions-item></el-descriptions><h3 class="gpu-ref-title">虚拟机配置引用</h3><p class="subtext">配置记录不代表设备实际已由虚拟机使用;待应用项在重启后可能生效。</p><article v-for="(owner, index) in selected.references" :key="index" class="gpu-reference"><div class="row"><strong>{{ owner.vmName || '虚拟机' }} · VM {{ owner.vmid }}</strong><StatusTag :value="owner.vmStatus" /></div><div class="gpu-ref-tags"><el-tag size="small">{{ owner.phase === 'current' ? '当前配置' : '待应用配置' }}</el-tag><el-tag v-if="owner.pendingRemoval" size="small" type="warning">待移除</el-tag><el-tag v-if="owner.template" size="small" type="info">模板</el-tag><el-tag size="small" type="info">{{ owner.mediated ? '介导设备' : 'PCI 直通' }}</el-tag></div><p class="secondary">{{ owner.slot }} · {{ owner.source === 'mapping' ? 'PCI 映射 ' + owner.mapping : '直接 PCI 地址' }}</p><p class="secondary">{{ owner.desktopId ? '已纳管桌面:' + owner.desktopName : '尚未纳管为平台桌面' }}</p></article><el-empty v-if="!selected.references.length" :description="selected.state === 'unknown' ? '检查范围不完整,引用情况待核查' : '未发现 QEMU 配置引用'" /><p class="subtext">驱动字段由 PVE PCI 清单提供;温度、利用率和显存需后续接入节点采集代理。</p></template>
</el-drawer> </el-drawer>
</template> </template>
<style scoped> <style scoped>
.gpu-controls .toolbar{margin-bottom:12px}.gpu-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;color:var(--vdi-muted);font-size:12px}.gpu-summary>span{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:9px;padding:12px}.gpu-summary b{color:var(--vdi-text);margin-left:8px}.gpu-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.gpu-card{margin:0;display:flex;flex-direction:column}.gpu-card-top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:22px}.gpu-symbol{width:52px;height:52px;display:grid;place-items:center;background:var(--vdi-accent-soft);color:var(--vdi-accent);border-radius:13px}.gpu-card h2,.gpu-detail-name{overflow-wrap:anywhere;line-height:1.6}.gpu-card dl{margin:22px 0;display:grid;gap:16px;font-size:12px}.gpu-card dl>div{display:flex;justify-content:space-between;gap:20px}.gpu-card dt{color:var(--vdi-muted);flex-shrink:0}.gpu-card dd{margin:0;text-align:right;overflow-wrap:anywhere}.gpu-card-footer{margin-top:auto;padding-top:14px;border-top:1px solid var(--vdi-border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.gpu-issues{border-color:var(--vdi-warn)}.gpu-issues h2{font-size:15px}.gpu-issues ul{padding-left:20px;margin-top:12px;color:var(--vdi-muted);font-size:12px;line-height:2;overflow-wrap:anywhere}.gpu-ref-title{margin:25px 0 8px;font-size:15px}.gpu-reference{margin-top:16px;padding:16px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.gpu-reference .row{margin:0 0 12px}.gpu-reference strong{font-size:13px}.gpu-ref-tags{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}@media(min-width:1700px){.gpu-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:950px){.gpu-grid{grid-template-columns:1fr}}@media(max-width:600px){.gpu-controls .toolbar .el-select{width:100%}.gpu-summary{gap:8px}.gpu-summary>span{padding:9px}.gpu-card-footer{align-items:flex-start}} .gpu-controls .toolbar{margin-bottom:12px}.gpu-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;color:var(--vdi-muted);font-size:12px}.gpu-summary>span{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:9px;padding:12px}.gpu-summary b{color:var(--vdi-text);margin-left:8px}.gpu-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.gpu-card{margin:0;display:flex;flex-direction:column}.gpu-card-top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:22px}.gpu-symbol{width:52px;height:52px;display:grid;place-items:center;background:var(--vdi-accent-soft);color:var(--vdi-accent);border-radius:13px}.gpu-card h2,.gpu-detail-name{overflow-wrap:anywhere;line-height:1.6}.gpu-card dl{margin:22px 0;display:grid;gap:16px;font-size:12px}.gpu-card dl>div{display:flex;justify-content:space-between;gap:20px}.gpu-card dt{color:var(--vdi-muted);flex-shrink:0}.gpu-card dd{margin:0;text-align:right;overflow-wrap:anywhere}.gpu-card-footer{margin-top:auto;padding-top:14px;border-top:1px solid var(--vdi-border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.gpu-issues{border-color:var(--vdi-warn)}.gpu-issues h2{font-size:15px}.gpu-issues ul{padding-left:20px;margin-top:12px;color:var(--vdi-muted);font-size:12px;line-height:2;overflow-wrap:anywhere}.gpu-ref-title{margin:25px 0 8px;font-size:15px}.gpu-reference{margin-top:16px;padding:16px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.gpu-reference .row{margin:0 0 12px}.gpu-reference strong{font-size:13px}.gpu-ref-tags{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}@media(min-width:1700px){.gpu-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:950px){.gpu-grid{grid-template-columns:1fr}}@media(max-width:600px){.gpu-controls .toolbar .el-select{width:100%}.gpu-summary{gap:8px}.gpu-summary>span{padding:9px}.gpu-card-footer{align-items:flex-start}}
</style> .gpu-exclusive{margin:20px 0}.gpu-exclusive-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.gpu-exclusive-heading h2{font-size:17px}.gpu-exclusive-summary{display:flex;gap:18px;flex-wrap:wrap;color:var(--vdi-muted);font-size:12px;margin:16px 0}.gpu-conflicts{padding-left:20px;line-height:1.8}.gpu-release-note{margin-top:14px}</style>

View File

@ -18,6 +18,8 @@
[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按设备/桌面关联任务、会话、告警和审计,设备日志采集回执与完整诊断包导出;已完成真实盒子采集与页面验收。 [高级版日志与诊断中心](cloud-diagnostics-20261111.md):按设备/桌面关联任务、会话、告警和审计,设备日志采集回执与完整诊断包导出;已完成真实盒子采集与页面验收。
[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md):先不接模型,通用工作台可生成并确认真实员工授权方案;未知对象和未支持场景会阻止写操作,已部署到 5182。
[云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。 [云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。
[控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。 [控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。

View File

@ -0,0 +1,26 @@
# 通用版固定场景助手向导
日期:2026-11-12。第一阶段先实现不依赖模型的固定场景向导,入口为通用版「工作台」。
## 当前能力
工作台输入一句简短描述后生成方案卡片:
- “给张三分配备用桌面”这类请求会按员工姓名/账号和桌面名称/VMID匹配真实对象。
- 方案展示员工、桌面、影响范围和执行边界;只追加授权,不新建 VM、不自动开机、不终止已有连接。
- 点击执行后仍需确认,服务端复用现有 `POST /users/batch-bindings`,执行结果来自真实授权接口。
- 员工不存在、同名无法唯一匹配、授权已经存在或没有可分配桌面时,只显示阻止原因,不发写请求。
- “桌面打不开/设备离线”进入问题处理;“异常/告警/问题”进入操作记录。
- viewer 和 operator 即使能看到工作台,也继续由后端 RBAC 控制;执行授权仍需超级管理员。
本阶段没有接入语言模型,没有让自然语言直接变成 Shell、PowerShell、SSH 或电源命令。未知句式会明确提示当前支持的固定场景。
## 验证
- TypeScript、ESLint、生产构建通过。
- 通用版浏览器回归 15/15 通过,新增覆盖:可执行授权方案、确认后调用授权接口、未知员工阻止、已有授权阻止、同名桌面阻止及无写请求。
- 既有 6 项前端单元测试通过。
已发布到真实 `http://127.0.0.1:5182`。本次仅更新控制台静态镜像;API、数据库表、管理员账号、终端、桌面和 PVE 配置没有变更。真实验收使用临时员工和已有桌面完成了一次页面授权,随后定向清理,临时员工和绑定均为 0。
后续可在同一方案对象上增加批量电源预检和终端无桌面诊断,再评估模型服务;模型必须只能选择受控业务动作,不能直接执行自由文本命令。

View File

@ -155,3 +155,5 @@ Go 全量测试与 `go vet` 通过,使用独立临时 PostgreSQL,覆盖账
11-11 追加:[通用/高级两版界面](cloud-editions-20261111.md)已在 5182 部署,包含通用员工授权向导、设备登记/诊断、简明记录和高级显卡准备结果核对。两版共用真实 API/RBAC;首批 34 项页面测试、6 项单元测试和 GPU 专项集成通过,真实临时员工授权流程验收后已清理。模型暂不接入,剩余高级事项保持原验收边界。 11-11 追加:[通用/高级两版界面](cloud-editions-20261111.md)已在 5182 部署,包含通用员工授权向导、设备登记/诊断、简明记录和高级显卡准备结果核对。两版共用真实 API/RBAC;首批 34 项页面测试、6 项单元测试和 GPU 专项集成通过,真实临时员工授权流程验收后已清理。模型暂不接入,剩余高级事项保持原验收边界。
11-11 追加:[高级版日志与诊断中心](cloud-diagnostics-20261111.md)已在 5182 部署:设备/桌面按唯一 ID 关联五类记录,数据库分页、来源/时间筛选、设备采集回执和完整 ZIP 导出。6 项后端专项、Go 全量/vet、25 项浏览器回归及真实接口/页面验收通过;真实盒子成功完成一次只读日志采集。现有身份、授权、连接配置及告警规则保持,唯一新增任务为该次采集。持续实时日志、全量归档和客体自动采集仍待后续。 11-11 追加:[高级版日志与诊断中心](cloud-diagnostics-20261111.md)已在 5182 部署:设备/桌面按唯一 ID 关联五类记录,数据库分页、来源/时间筛选、设备采集回执和完整 ZIP 导出。6 项后端专项、Go 全量/vet、25 项浏览器回归及真实接口/页面验收通过;真实盒子成功完成一次只读日志采集。现有身份、授权、连接配置及告警规则保持,唯一新增任务为该次采集。持续实时日志、全量归档和客体自动采集仍待后续。
12-11 追加:[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md)已完成代码和隔离页面验收并部署到 5182:输入员工与桌面需求生成授权方案,确认后复用现有绑定接口;未知对象、重复授权、同名桌面和未支持句式不发写请求。15 项通用版浏览器测试、TypeScript、ESLint、单元测试和生产构建通过;真实临时员工授权流程已执行并清理。

View File

@ -0,0 +1,18 @@
# GPU 健康、独占资源与终端日志归档
## 已实现
- `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标时返回 `status=not_collected` 和空值,不把 VM 配置引用当作健康数据。
- `GET /api/v1/resources/gpu-exclusive?node=...` 提供独占组分配前检查:advisory lock、成员完整性、PVE 实际电源、活跃终端会话、未完成任务、冲突原因、`allocationReady` 和 `releaseReady`。接口只读,不会关机、切换显卡或重放任务。
- 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`,按终端和时间建立索引,保留 30 天,并通过 `GET /api/v1/terminal-logs` 支持终端、时间范围和关键词检索。沿用诊断脱敏;这是回传快照归档,不是实时日志流。
- 高级版 GPU 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。
## 真实边界
温度、利用率和显存需要节点采集代理或厂商工具接入,当前 API 明确显示“未采集”。独占资源的释放状态只代表平台检查条件满足,实际 GPU 切换仍走既有终端准备任务和核对流程。
## 验证
- `docker run --rm -v "${PWD}:/src" -w /src/platform golang:1.27-alpine sh -c 'go test ./...'` 通过。
- `npm run typecheck` 和生产构建通过。
- 真实控制台 `http://127.0.0.1:5182` 已重新部署;root 登录后真实读取独占组和 PVE GPU 清单,健康字段显示 `not_collected`,独占组正确报告 VM 101 正在运行导致不可分配。

View File

@ -87,3 +87,5 @@
11-11 已部署[通用版与高级版](cloud-editions-20261111.md):通用版五个日常入口,真实员工授权、设备登记、只读问题诊断和简明操作记录;高级版保留专业管理并补齐显卡准备超时任务核对入口。模型暂不接入,界面模式不改变账号权限或原功能验收范围。 11-11 已部署[通用版与高级版](cloud-editions-20261111.md):通用版五个日常入口,真实员工授权、设备登记、只读问题诊断和简明操作记录;高级版保留专业管理并补齐显卡准备超时任务核对入口。模型暂不接入,界面模式不改变账号权限或原功能验收范围。
11-11 已部署[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按唯一设备/桌面关联任务、会话、告警、审计和设备日志,支持 7 天内筛选、分页与完整 ZIP 导出;真实盒子通过页面采集成功并展示/导出。6 项后端专项、Go 全量/vet、25 项页面回归及真实权限/深浅/窄屏检查通过。持续实时日志、全量归档和 VM 客体日志自动采集仍未实现。 11-11 已部署[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按唯一设备/桌面关联任务、会话、告警、审计和设备日志,支持 7 天内筛选、分页与完整 ZIP 导出;真实盒子通过页面采集成功并展示/导出。6 项后端专项、Go 全量/vet、25 项页面回归及真实权限/深浅/窄屏检查通过。持续实时日志、全量归档和 VM 客体日志自动采集仍未实现。
12-11 通用版工作台已加入不依赖模型的固定场景方案向导:匹配员工和已有桌面、展示影响、确认后调用真实授权接口,并在对象不唯一或场景不支持时阻止写操作。前端检查、构建和 15 项通用版浏览器回归通过;已发布到真实 5182,并完成临时员工授权执行与清理。

View File

@ -0,0 +1,7 @@
# 通用版固定场景助手验收证据
- `browser-verification.json`:真实 5182 浏览器生成方案、确认执行授权、高级版切换、390 宽度及无失败请求。
- `deployment.json`:控制台镜像替换记录,API 和 PostgreSQL 未重启。
- `state-verification.json`:临时员工和授权已清理,现场范围为 console-only。
真实执行使用临时员工和已有桌面,授权成功后按唯一 ID 删除临时用户;未操作 VM 电源、GPU、终端命令或 PVE 配置。

View File

@ -0,0 +1,15 @@
{
"time": "2026-11-12T07:01:55.803Z",
"realAPI": true,
"temporaryUserCreated": true,
"temporaryUserId": "user_2e9f1c7273f024dd54aec2842a5833cd",
"desktopId": "desk_29cf3c79d9fc1b4a25e1bfe55c5c943f",
"planGeneratedFromRealNames": true,
"authorizationExecutedThroughRealUI": true,
"authorizationObservedInAPI": true,
"advancedSwitchStillWorks": true,
"mobileWidth": 390,
"scriptErrors": [],
"failedAPIResponses": [],
"blockedBusinessWrites": []
}

View File

@ -0,0 +1,12 @@
{
"deployedAt": "2026-11-12T07:01:29.779904+00:00",
"oldConsoleImage": "sha256:d2c0983ac44701828ab35d769f54ae6451f7ea37844455009bb7346fece3c38c",
"newConsoleImage": "sha256:ffdfb41a3f53647d9c8eb1c495b32d2947ba422933eb1879b85907a59a2b9066",
"apiImageUnchanged": true,
"databaseStartedAt": "2026-11-12T06:45:53.898390299Z",
"databaseRestarted": false,
"oldImageTag": "vdi-cloud-live-console:before-assistant-final-20261112-150122",
"databaseChanged": false,
"scope": "console-only",
"ambiguityGuardAdded": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@ -0,0 +1,13 @@
{
"time": "2026-11-12T07:14:12.903748+00:00",
"temporaryUserAndBindingsAfterCleanup": {
"users": 0,
"bindings": 0
},
"consoleImage": "sha256:ffdfb41a3f53647d9c8eb1c495b32d2947ba422933eb1879b85907a59a2b9066",
"apiImageUnchanged": true,
"databaseRestarted": false,
"databaseChanged": false,
"temporaryUserAuthorizationExecutedAndRemoved": true,
"scope": "console-only"
}

View File

@ -6537,6 +6537,128 @@
} }
} }
}, },
"/resources/gpu-exclusive": {
"get": {
"operationId": "gpuExclusiveStatus",
"security": [
{
"adminBearer": []
}
],
"parameters": [
{
"name": "node",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GPUExclusiveStatus"
}
}
}
},
"400": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/resources/vms": { "/resources/vms": {
"get": { "get": {
"operationId": "discoverVMs", "operationId": "discoverVMs",
@ -11754,6 +11876,168 @@
"description": "Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit." "description": "Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit."
} }
}, },
"/terminal-logs": {
"get": {
"operationId": "terminalLogArchives",
"security": [
{
"adminBearer": []
}
],
"parameters": [
{
"name": "terminalId",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "keyword",
"in": "query",
"schema": {
"type": "string",
"maxLength": 200
}
},
{
"name": "from",
"in": "query",
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "to",
"in": "query",
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TerminalLogArchivePage"
}
}
}
},
"400": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Request failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/audit-logs": { "/audit-logs": {
"get": { "get": {
"operationId": "audits", "operationId": "audits",
@ -14107,6 +14391,59 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"GPUHealth": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"source": {
"type": "string"
},
"observedAt": {
"type": "string",
"format": "date-time"
},
"temperatureC": {
"type": "number",
"nullable": true
},
"utilizationPercent": {
"type": "number",
"nullable": true
},
"memoryUsedBytes": {
"type": "integer",
"nullable": true
},
"memoryTotalBytes": {
"type": "integer",
"nullable": true
},
"driver": {
"type": "string"
},
"driverBinding": {
"type": "string",
"enum": [
"bound",
"unknown"
]
}
},
"required": [
"status",
"source",
"observedAt",
"temperatureC",
"utilizationPercent",
"memoryUsedBytes",
"memoryTotalBytes",
"driver",
"driverBinding"
],
"additionalProperties": false
},
"GPUItem": { "GPUItem": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -14144,6 +14481,9 @@
"multiple" "multiple"
] ]
}, },
"health": {
"$ref": "#/components/schemas/GPUHealth"
},
"references": { "references": {
"type": "array", "type": "array",
"items": { "items": {
@ -14161,6 +14501,7 @@
"class", "class",
"iommuGroup", "iommuGroup",
"state", "state",
"health",
"references" "references"
], ],
"additionalProperties": false "additionalProperties": false
@ -14215,6 +14556,38 @@
"items": { "items": {
"$ref": "#/components/schemas/GPUIssue" "$ref": "#/components/schemas/GPUIssue"
} }
},
"health": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"source": {
"type": "string"
},
"temperature": {
"type": "string"
},
"utilization": {
"type": "string"
},
"memory": {
"type": "string"
},
"driverBinding": {
"type": "string"
}
},
"required": [
"status",
"source",
"temperature",
"utilization",
"memory",
"driverBinding"
],
"additionalProperties": false
} }
}, },
"required": [ "required": [
@ -14224,7 +14597,8 @@
"visibleVmCount", "visibleVmCount",
"scannedVmCount", "scannedVmCount",
"items", "items",
"issues" "issues",
"health"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@ -17171,6 +17545,203 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"TerminalLogArchive": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"taskId": {
"type": "string"
},
"terminalId": {
"type": "string"
},
"terminalName": {
"type": "string"
},
"capturedAt": {
"type": "string",
"format": "date-time"
},
"bootId": {
"type": "string"
},
"agentVersion": {
"type": "string"
},
"kioskState": {
"type": "string"
},
"logText": {
"type": "string"
}
},
"required": [
"id",
"taskId",
"terminalId",
"terminalName",
"capturedAt",
"bootId",
"agentVersion",
"kioskState",
"logText"
],
"additionalProperties": false
},
"TerminalLogArchivePage": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TerminalLogArchive"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
},
"observedAt": {
"type": "string",
"format": "date-time"
},
"retentionDays": {
"type": "integer"
}
},
"required": [
"items",
"page",
"pageSize",
"total",
"observedAt",
"retentionDays"
],
"additionalProperties": false
},
"GPUExclusiveMember": {
"type": "object",
"properties": {
"desktopId": {
"type": "string"
},
"name": {
"type": "string"
},
"vmid": {
"type": "integer"
},
"powerState": {
"type": "string"
},
"powerObserved": {
"type": "boolean"
},
"activeSessions": {
"type": "integer"
},
"activeTasks": {
"type": "integer"
},
"conflicts": {
"type": "array",
"items": {
"type": "string"
}
},
"eligible": {
"type": "boolean"
}
},
"required": [
"desktopId",
"name",
"vmid",
"powerState",
"powerObserved",
"activeSessions",
"activeTasks",
"conflicts",
"eligible"
],
"additionalProperties": false
},
"GPUExclusiveStatus": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"node": {
"type": "string"
},
"vmids": {
"type": "array",
"items": {
"type": "integer"
}
},
"observedAt": {
"type": "string",
"format": "date-time"
},
"lockAvailable": {
"type": "boolean"
},
"groupComplete": {
"type": "boolean"
},
"members": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GPUExclusiveMember"
}
},
"conflicts": {
"type": "array",
"items": {
"type": "string"
}
},
"allocationReady": {
"type": "boolean"
},
"releaseReady": {
"type": "boolean"
},
"releasePolicy": {
"type": "string"
},
"activeTasks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Task"
}
}
},
"required": [
"enabled",
"node",
"vmids",
"observedAt",
"lockAvailable",
"groupComplete",
"members",
"conflicts",
"allocationReady",
"releaseReady",
"releasePolicy",
"activeTasks"
],
"additionalProperties": false
},
"DesktopList": { "DesktopList": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -78,3 +78,5 @@ CSV 导入、90 天会话历史及节点历史曲线/告警规则已补齐,见
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。 11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。
11-11 高级版[日志与诊断中心](cloud-diagnostics-20261111.md)已部署并完成真实盒子采集、关联查询和诊断包下载验收,不再作为未实现项重复排期。后续日志方向为持续推送、服务端归档及 VM 客体采集;定时任务、备份恢复、OTA 等高级事项仍按各自计划与验收条件推进,本轮未实现这些功能。 11-11 高级版[日志与诊断中心](cloud-diagnostics-20261111.md)已部署并完成真实盒子采集、关联查询和诊断包下载验收,不再作为未实现项重复排期。后续日志方向为持续推送、服务端归档及 VM 客体采集;定时任务、备份恢复、OTA 等高级事项仍按各自计划与验收条件推进,本轮未实现这些功能。
12-11 已部署[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md):当前不接模型,先把“给员工分配已有桌面”等请求转换为可核对方案并复用真实授权接口。批量电源预检、终端无桌面诊断和模型接入仍待后续逐项验收。

View File

@ -53,6 +53,59 @@ type diagnosticReport struct {
Source string `json:"source"` Source string `json:"source"`
Notes []string `json:"notes"` Notes []string `json:"notes"`
} }
type terminalLogArchive struct {
ID string `json:"id" db:"id"`
TaskID string `json:"taskId" db:"task_id"`
TerminalID string `json:"terminalId" db:"terminal_id"`
TerminalName string `json:"terminalName" db:"terminal_name"`
CapturedAt time.Time `json:"capturedAt" db:"captured_at"`
BootID string `json:"bootId" db:"boot_id"`
AgentVersion string `json:"agentVersion" db:"agent_version"`
KioskState string `json:"kioskState" db:"kiosk_state"`
LogText string `json:"logText" db:"log_text"`
}
type terminalLogArchivePage struct {
Items []terminalLogArchive `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
ObservedAt time.Time `json:"observedAt"`
RetentionDays int `json:"retentionDays"`
}
func (s *Server) terminalLogArchives(c *gin.Context) {
terminalID, keyword := strings.TrimSpace(c.Query("terminalId")), strings.TrimSpace(c.Query("keyword"))
if len(terminalID) > 100 || len(keyword) > 200 || strings.IndexByte(terminalID, 0) >= 0 || strings.IndexByte(keyword, 0) >= 0 {
s.fail(c, 400, "invalid_request", "日志筛选条件无效")
return
}
from, to, ok := timeRange(c, 30)
if !ok {
s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 30 天,时间需包含时区")
return
}
page, size := pageParams(c)
args := []any{from, to, terminalID, keyword}
var total int
if err := s.DB.GetContext(c.Request.Context(), &total, `SELECT count(*) FROM terminal_log_archives WHERE captured_at>=$1 AND captured_at<=$2 AND ($3='' OR terminal_id=$3) AND ($4='' OR log_text ILIKE '%'||$4||'%')`, args...); err != nil {
s.dbError(c, err)
return
}
items := []terminalLogArchive{}
args = append(args, size, (page-1)*size)
if err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT a.id,a.task_id,a.terminal_id,t.name terminal_name,a.captured_at,a.boot_id,a.agent_version,a.kiosk_state,a.log_text FROM terminal_log_archives a JOIN terminals t ON t.id=a.terminal_id WHERE a.captured_at>=$1 AND a.captured_at<=$2 AND ($3='' OR a.terminal_id=$3) AND ($4='' OR a.log_text ILIKE '%'||$4||'%') ORDER BY a.captured_at DESC,a.id DESC LIMIT $5 OFFSET $6`, args...); err != nil {
s.dbError(c, err)
return
}
for i := range items {
items[i].BootID = redactDiagnostic(items[i].BootID)
items[i].AgentVersion = redactDiagnostic(items[i].AgentVersion)
items[i].KioskState = redactDiagnostic(items[i].KioskState)
items[i].TerminalName = redactDiagnostic(items[i].TerminalName)
items[i].LogText = redactDiagnostic(items[i].LogText)
}
c.JSON(200, terminalLogArchivePage{Items: items, Page: page, PageSize: size, Total: total, ObservedAt: time.Now().UTC(), RetentionDays: 30})
}
// Correlation uses stable IDs, never a display name. Desktop logs are not // Correlation uses stable IDs, never a display name. Desktop logs are not
// inferred from a device's current binding: that device may serve other VMs. // inferred from a device's current binding: that device may serve other VMs.

View File

@ -35,6 +35,129 @@ type gpuPrepareState struct {
UPID string `json:"upid,omitempty"` UPID string `json:"upid,omitempty"`
} }
type gpuExclusiveMember struct {
DesktopID string `json:"desktopId"`
Name string `json:"name"`
VMID int `json:"vmid"`
PowerState string `json:"powerState"`
PowerObserved bool `json:"powerObserved"`
ActiveSessions int `json:"activeSessions"`
ActiveTasks int `json:"activeTasks"`
Conflicts []string `json:"conflicts"`
Eligible bool `json:"eligible"`
}
type gpuExclusiveStatus struct {
Enabled bool `json:"enabled"`
Node string `json:"node"`
VMIDs []int `json:"vmids"`
ObservedAt time.Time `json:"observedAt"`
LockAvailable bool `json:"lockAvailable"`
GroupComplete bool `json:"groupComplete"`
Members []gpuExclusiveMember `json:"members"`
Conflicts []string `json:"conflicts"`
AllocationReady bool `json:"allocationReady"`
ReleaseReady bool `json:"releaseReady"`
ReleasePolicy string `json:"releasePolicy"`
ActiveTasks []model.Task `json:"activeTasks"`
}
// exclusiveGPUStatus is a read-only preflight view. It never changes VM power
// or task state; it is used by the advanced console before allocation/release.
func (s *Server) exclusiveGPUStatus(c *gin.Context) {
node := strings.TrimSpace(c.Query("node"))
status := gpuExclusiveStatus{ObservedAt: time.Now().UTC(), Node: s.Config.ExclusiveGPUNode, VMIDs: append([]int{}, s.Config.ExclusiveGPUVMIDs...), Members: []gpuExclusiveMember{}, Conflicts: []string{}, ActiveTasks: []model.Task{}, ReleasePolicy: "仅在无活动会话、无未完成任务且锁可用时可释放;不会自动关机或重放操作"}
if node != "" && (len(node) > 100 || strings.ContainsAny(node, "/\\?#")) {
s.fail(c, 400, "invalid_node", "节点名称无效")
return
}
if status.Node == "" || len(status.VMIDs) == 0 {
c.JSON(200, status)
return
}
if node != "" && node != status.Node {
status.Conflicts = []string{"当前节点未配置独占显卡组"}
c.JSON(200, status)
return
}
status.Enabled = true
tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
locked, err := tryGPULock(c.Request.Context(), tx)
if err != nil {
s.dbError(c, err)
return
}
status.LockAvailable = locked
if !locked {
status.Conflicts = append(status.Conflicts, "独占显卡锁当前被占用")
}
var members []model.Desktop
members, err = s.gpuMembers(c.Request.Context(), tx)
if err != nil {
status.Conflicts = append(status.Conflicts, "独占显卡组成员不完整")
} else {
status.GroupComplete = true
}
var tasks []model.Task
if err = tx.SelectContext(c.Request.Context(), &tasks, `SELECT * FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout') ORDER BY created_at DESC`); err == nil {
status.ActiveTasks = tasks
if len(tasks) > 0 {
status.Conflicts = append(status.Conflicts, "存在未完成或结果未核对的显卡切换任务")
}
}
pve := s.currentPVE()
for _, d := range members {
m := gpuExclusiveMember{DesktopID: d.ID, Name: d.Name, VMID: d.VMID, PowerState: d.PowerState, Conflicts: []string{}}
var n int
if err = tx.GetContext(c.Request.Context(), &n, `SELECT count(*) FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds'`, d.ID); err == nil {
m.ActiveSessions = n
}
if err = tx.GetContext(c.Request.Context(), &n, `SELECT count(*) FROM tasks WHERE target_id=$1 AND status IN ('queued','running','timeout') AND kind!='terminal'`, d.ID); err == nil {
m.ActiveTasks = n
}
if m.ActiveSessions > 0 {
m.Conflicts = append(m.Conflicts, "存在活跃终端会话")
}
if m.ActiveTasks > 0 {
m.Conflicts = append(m.Conflicts, "存在未完成管理任务")
}
if pve != nil {
power, powerErr := pve.Power(c.Request.Context(), d.Node, d.VMID)
if powerErr == nil {
m.PowerState, m.PowerObserved = power, true
} else {
m.PowerState = "unknown"
m.Conflicts = append(m.Conflicts, "无法读取 PVE 实际电源状态")
}
} else {
m.PowerState = "unknown"
m.Conflicts = append(m.Conflicts, "尚未配置 PVE")
}
if m.PowerObserved && m.PowerState != "stopped" {
m.Conflicts = append(m.Conflicts, "虚拟机当前仍在运行")
}
m.Eligible = len(m.Conflicts) == 0 && m.PowerState == "stopped"
status.Members = append(status.Members, m)
}
if !status.GroupComplete {
status.Conflicts = append(status.Conflicts, "数据库中的独占显卡组成员数量与配置不一致")
}
allEligible := len(status.Members) == len(status.VMIDs)
for _, member := range status.Members {
if !member.Eligible {
allEligible = false
break
}
}
status.AllocationReady = status.Enabled && status.GroupComplete && status.LockAvailable && len(status.ActiveTasks) == 0 && len(status.Conflicts) == 0 && allEligible
status.ReleaseReady = status.Enabled && status.GroupComplete && status.LockAvailable && len(status.ActiveTasks) == 0 && allEligible
c.JSON(200, status)
}
func (s *Server) exclusiveGPU(d model.Desktop) bool { func (s *Server) exclusiveGPU(d model.Desktop) bool {
if d.Provider != "proxmox" || d.Node != s.Config.ExclusiveGPUNode { if d.Provider != "proxmox" || d.Node != s.Config.ExclusiveGPUNode {
return false return false

View File

@ -119,6 +119,23 @@ func TestExclusiveGPUAuthorizationAndOccupiedGroup(t *testing.T) {
} }
} }
func TestExclusiveGPUStatusPreflightIsReadOnly(t *testing.T) {
f, p := exclusiveFixture(t, "normal")
admin := f.login("admin")
code, out := f.request("GET", "/resources/gpu-exclusive?node=gpu-node", admin, nil, "")
expect(t, 200, code, out)
if out["enabled"] != true || out["groupComplete"] != true || out["allocationReady"] != false || out["releaseReady"] != false {
t.Fatalf("unexpected preflight: %#v", out)
}
if len(p.actions) != 0 {
t.Fatalf("preflight changed power: %#v", p.actions)
}
code, _ = f.request("POST", "/resources/gpu-exclusive", admin, nil, "")
if code != 404 {
t.Fatalf("exclusive GPU status unexpectedly exposes write route: %d", code)
}
}
func TestExclusiveGPUReservationBlocksAdministrativePower(t *testing.T) { func TestExclusiveGPUReservationBlocksAdministrativePower(t *testing.T) {
f, p := exclusiveFixture(t, "normal") f, p := exclusiveFixture(t, "normal")
prepareExclusive(t, f) prepareExclusive(t, f)

View File

@ -37,8 +37,20 @@ type gpuItem struct {
Class string `json:"class"` Class string `json:"class"`
IOMMUGroup *int `json:"iommuGroup"` IOMMUGroup *int `json:"iommuGroup"`
State string `json:"state"` State string `json:"state"`
Health gpuHealth `json:"health"`
References []gpuReference `json:"references"` References []gpuReference `json:"references"`
} }
type gpuHealth struct {
Status string `json:"status"`
Source string `json:"source"`
ObservedAt time.Time `json:"observedAt"`
TemperatureC *float64 `json:"temperatureC"`
UtilizationPercent *float64 `json:"utilizationPercent"`
MemoryUsedBytes *int64 `json:"memoryUsedBytes"`
MemoryTotalBytes *int64 `json:"memoryTotalBytes"`
Driver string `json:"driver"`
DriverBinding string `json:"driverBinding"`
}
type gpuIssue struct { type gpuIssue struct {
Code string `json:"code"` Code string `json:"code"`
VMID int `json:"vmid"` VMID int `json:"vmid"`
@ -92,6 +104,7 @@ func (s *Server) gpuInventory(c *gin.Context) {
return return
} }
items := []gpuItem{} items := []gpuItem{}
observedAt := time.Now().UTC()
for _, d := range devices { for _, d := range devices {
if !d.IsGPU() { if !d.IsGPU() {
continue continue
@ -105,7 +118,11 @@ func (s *Server) gpuInventory(c *gin.Context) {
if group != nil && *group < 0 { if group != nil && *group < 0 {
group = nil group = nil
} }
items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, References: []gpuReference{}}) driverBinding := "unknown"
if strings.TrimSpace(d.Driver) != "" {
driverBinding = "bound"
}
items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, Health: gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}, References: []gpuReference{}})
} }
fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit") fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit")
if err != nil || !fullVisibility { if err != nil || !fullVisibility {
@ -227,7 +244,7 @@ func (s *Server) gpuInventory(c *gin.Context) {
} }
} }
sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address }) sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address })
c.JSON(200, gin.H{"node": node, "observedAt": time.Now().UTC(), "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues}) c.JSON(200, gin.H{"node": node, "observedAt": observedAt, "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues, "health": gin.H{"status": "partial", "source": "pve-pci", "temperature": "not_collected", "utilization": "not_collected", "memory": "not_collected", "driverBinding": "reported_when_pve_exposes_driver"}})
} }
func rawOptionText(value json.RawMessage) string { func rawOptionText(value json.RawMessage) string {

View File

@ -299,7 +299,7 @@ func (s *Server) observe(ctx context.Context) error {
} }
} }
// Bounded retention. Current incidents and active session records are preserved. // Bounded retention. Current incidents and active session records are preserved.
for _, query := range []string{"DELETE FROM login_limits WHERE window_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM session_history WHERE ended_at<now()-interval '90 days'", "DELETE FROM alerts WHERE resolved_at<now()-interval '90 days'"} { for _, query := range []string{"DELETE FROM login_limits WHERE window_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_log_archives WHERE captured_at<now()-interval '30 days'", "DELETE FROM session_history WHERE ended_at<now()-interval '90 days'", "DELETE FROM alerts WHERE resolved_at<now()-interval '90 days'"} {
if _, err = tx.ExecContext(ctx, query); err != nil { if _, err = tx.ExecContext(ctx, query); err != nil {
return err return err
} }

View File

@ -110,6 +110,7 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
a.POST("/desktops/:id/hardware", s.allow("super_admin"), s.queueHardware) a.POST("/desktops/:id/hardware", s.allow("super_admin"), s.queueHardware)
a.GET("/resources", s.resources) a.GET("/resources", s.resources)
a.GET("/resources/gpus", s.gpuInventory) a.GET("/resources/gpus", s.gpuInventory)
a.GET("/resources/gpu-exclusive", s.exclusiveGPUStatus)
a.GET("/resources/vms", s.discoverVMs) a.GET("/resources/vms", s.discoverVMs)
a.GET("/resources/templates", s.discoverTemplates) a.GET("/resources/templates", s.discoverTemplates)
a.GET("/resources/storage", s.storageList) a.GET("/resources/storage", s.storageList)
@ -160,6 +161,7 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics) a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
a.GET("/diagnostics", s.allow("super_admin", "operator"), s.diagnostics) a.GET("/diagnostics", s.allow("super_admin", "operator"), s.diagnostics)
a.GET("/diagnostics/export", s.allow("super_admin", "operator"), s.exportDiagnostics) a.GET("/diagnostics/export", s.allow("super_admin", "operator"), s.exportDiagnostics)
a.GET("/terminal-logs", s.allow("super_admin", "operator"), s.terminalLogArchives)
a.GET("/audit-logs", s.audits) a.GET("/audit-logs", s.audits)
a.GET("/audit-logs/export", s.exportAudits) a.GET("/audit-logs/export", s.exportAudits)
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") }) r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })

View File

@ -253,6 +253,11 @@ func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
if out["logTail"] != "private diagnostic" { if out["logTail"] != "private diagnostic" {
t.Fatal("diagnostic not retained") t.Fatal("diagnostic not retained")
} }
status, out = f.request("GET", "/terminal-logs?terminalId="+id+"&keyword=private", admin, nil, "")
expect(t, 200, status, out)
if out["total"] != float64(1) || len(out["items"].([]any)) != 1 || out["items"].([]any)[0].(map[string]any)["logText"] != "private diagnostic" {
t.Fatalf("terminal log archive not searchable: %#v", out)
}
_ = otherID _ = otherID
} }

View File

@ -233,6 +233,15 @@ func (s *Server) deviceAck(c *gin.Context) {
s.dbError(c, err) s.dbError(c, err)
return return
} }
// Keep a dedicated, indexed archive for long-lived terminal log search.
// The task row remains the audit record; this copy is bounded by the
// retention cleanup in observability.go.
logText := redactDiagnostic(in.Diagnostics.LogTail)
meta, _ := json.Marshal(gin.H{"agentVersion": redactDiagnostic(in.Diagnostics.AgentVersion), "kioskState": redactDiagnostic(in.Diagnostics.KioskState), "uptimeSeconds": in.Diagnostics.UptimeSeconds})
if _, err = deviceTx(c).ExecContext(c.Request.Context(), `INSERT INTO terminal_log_archives(id,task_id,terminal_id,captured_at,boot_id,agent_version,kiosk_state,log_text,metadata) VALUES($1,$2,$3,now(),$4,$5,$6,$7,$8) ON CONFLICT(task_id) DO UPDATE SET log_text=EXCLUDED.log_text,metadata=EXCLUDED.metadata`, store.ID("log_"), task.ID, c.GetString("terminalId"), redactDiagnostic(in.Diagnostics.BootID), redactDiagnostic(in.Diagnostics.AgentVersion), redactDiagnostic(in.Diagnostics.KioskState), logText, string(meta)); err != nil {
s.dbError(c, err)
return
}
} }
// Agent text can contain sensitive device details. Keep the public audit message fixed. // Agent text can contain sensitive device details. Keep the public audit message fixed.
if err = s.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil { if err = s.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil {

View File

@ -73,6 +73,15 @@ CREATE TABLE IF NOT EXISTS task_diagnostics (
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE, task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS terminal_log_archives (
id text PRIMARY KEY, task_id text NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE,
terminal_id text NOT NULL REFERENCES terminals(id) ON DELETE CASCADE,
captured_at timestamptz NOT NULL DEFAULT now(), boot_id text NOT NULL DEFAULT '',
agent_version text NOT NULL DEFAULT '', kiosk_state text NOT NULL DEFAULT '', log_text text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS terminal_log_archives_terminal_time ON terminal_log_archives(terminal_id,captured_at DESC,id);
CREATE INDEX IF NOT EXISTS terminal_log_archives_time ON terminal_log_archives(captured_at DESC);
CREATE TABLE IF NOT EXISTS image_templates ( CREATE TABLE IF NOT EXISTS image_templates (
id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')), id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')),
node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL, node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL,

View File

@ -18,6 +18,7 @@ type PCIDevice struct {
Device string `json:"device"` Device string `json:"device"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
IOMMUGroup *int `json:"iommugroup"` IOMMUGroup *int `json:"iommugroup"`
Driver string `json:"driver"`
} }
type PCIMapping struct { type PCIMapping struct {

View File

@ -21,9 +21,10 @@ const schemas = {
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }), User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }), Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
GPUReference: object({ vmid: integer, vmName: string, vmStatus: string, template: boolean, desktopId: string, desktopName: string, slot: string, phase: { type: 'string', enum: ['current', 'pending'] }, source: { type: 'string', enum: ['direct', 'mapping'] }, mapping: string, mediated: boolean, pendingRemoval: boolean }), GPUReference: object({ vmid: integer, vmName: string, vmStatus: string, template: boolean, desktopId: string, desktopName: string, slot: string, phase: { type: 'string', enum: ['current', 'pending'] }, source: { type: 'string', enum: ['direct', 'mapping'] }, mapping: string, mediated: boolean, pendingRemoval: boolean }),
GPUItem: object({ id: string, address: string, name: string, vendor: string, vendorId: string, deviceId: string, class: string, iommuGroup: { ...integer, nullable: true }, state: { type: 'string', enum: ['unreferenced', 'unknown', 'referenced', 'multiple'] }, references: array(ref('GPUReference')) }), GPUHealth: object({ status: string, source: string, observedAt: date, temperatureC: { ...number, nullable: true }, utilizationPercent: { ...number, nullable: true }, memoryUsedBytes: { ...integer, nullable: true }, memoryTotalBytes: { ...integer, nullable: true }, driver: string, driverBinding: { type: 'string', enum: ['bound', 'unknown'] } }),
GPUItem: object({ id: string, address: string, name: string, vendor: string, vendorId: string, deviceId: string, class: string, iommuGroup: { ...integer, nullable: true }, state: { type: 'string', enum: ['unreferenced', 'unknown', 'referenced', 'multiple'] }, health: ref('GPUHealth'), references: array(ref('GPUReference')) }),
GPUIssue: object({ code: string, vmid: integer, message: string }), GPUIssue: object({ code: string, vmid: integer, message: string }),
GPUInventory: object({ node: string, observedAt: date, complete: boolean, visibleVmCount: integer, scannedVmCount: integer, items: array(ref('GPUItem')), issues: array(ref('GPUIssue')) }), GPUInventory: object({ node: string, observedAt: date, complete: boolean, visibleVmCount: integer, scannedVmCount: integer, items: array(ref('GPUItem')), issues: array(ref('GPUIssue')), health: object({ status: string, source: string, temperature: string, utilization: string, memory: string, driverBinding: string }) }),
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }), Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }), Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }), Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
@ -118,6 +119,10 @@ schemas.GPUReconcileResult = object({ reconciled: boolean, vmid: string })
schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } }) schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } })
schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string }) schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string })
schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) }) schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) })
schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string })
schemas.TerminalLogArchivePage = object({ items: array(ref('TerminalLogArchive')), page: integer, pageSize: integer, total: integer, observedAt: date, retentionDays: integer })
schemas.GPUExclusiveMember = object({ desktopId: string, name: string, vmid: integer, powerState: string, powerObserved: boolean, activeSessions: integer, activeTasks: integer, conflicts: array(string), eligible: boolean })
schemas.GPUExclusiveStatus = object({ enabled: boolean, node: string, vmids: array(integer), observedAt: date, lockAvailable: boolean, groupComplete: boolean, members: array(ref('GPUExclusiveMember')), conflicts: array(string), allocationReady: boolean, releaseReady: boolean, releasePolicy: string, activeTasks: array(ref('Task')) })
schemas.Administrator.properties.enabled = boolean schemas.Administrator.properties.enabled = boolean
schemas.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 } schemas.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 }
schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true } schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true }
@ -193,6 +198,7 @@ const paths = {
'/settings': { get: operation('settings', 'Settings') }, '/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) }, '/resources': { get: operation('resources', 'ResourceList', { list: true }) },
'/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) }, '/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) },
'/resources/gpu-exclusive': { get: operation('gpuExclusiveStatus', 'GPUExclusiveStatus', { node: true }) },
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) }, '/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
'/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) }, '/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) },
'/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) }, '/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) },
@ -227,6 +233,7 @@ const paths = {
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) }, '/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') }, '/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
'/diagnostics/export': { get: operation('exportDiagnostics', null) }, '/diagnostics/export': { get: operation('exportDiagnostics', null) },
'/terminal-logs': { get: operation('terminalLogArchives', 'TerminalLogArchivePage') },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) }, '/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) },
'/audit-logs/export': { get: operation('exportAudits', null, { period: true }) }, '/audit-logs/export': { get: operation('exportAudits', null, { period: true }) },
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) }, '/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
@ -251,6 +258,7 @@ const diagnosticFilters = [
] ]
paths['/diagnostics'].get.parameters.push(...diagnosticFilters, { name: 'page', in: 'query', schema: { ...integer, minimum: 1 } }, { name: 'pageSize', in: 'query', schema: { ...integer, minimum: 1, maximum: 100 } }) paths['/diagnostics'].get.parameters.push(...diagnosticFilters, { name: 'page', in: 'query', schema: { ...integer, minimum: 1 } }, { name: 'pageSize', in: 'query', schema: { ...integer, minimum: 1, maximum: 100 } })
paths['/diagnostics/export'].get.parameters.push(...diagnosticFilters) paths['/diagnostics/export'].get.parameters.push(...diagnosticFilters)
paths['/terminal-logs'].get.parameters.push({ name: 'terminalId', in: 'query', schema: string }, { name: 'keyword', in: 'query', schema: { ...string, maxLength: 200 } }, { name: 'from', in: 'query', schema: date }, { name: 'to', in: 'query', schema: date }, { name: 'page', in: 'query', schema: { ...integer, minimum: 1 } }, { name: 'pageSize', in: 'query', schema: { ...integer, minimum: 1, maximum: 100 } })
paths['/diagnostics'].get.description = 'Super administrators and operators only. Exact ID correlation, merged lifecycle events, maximum seven days. Redacts common credential formats. No collection or VM changes.' paths['/diagnostics'].get.description = 'Super administrators and operators only. Exact ID correlation, merged lifecycle events, maximum seven days. Redacts common credential formats. No collection or VM changes.'
paths['/diagnostics/export'].get.description = 'Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit.' paths['/diagnostics/export'].get.description = 'Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit.'
paths['/diagnostics/export'].get.responses[200] = { description: 'Scoped diagnostic archive', content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } } paths['/diagnostics/export'].get.responses[200] = { description: 'Scoped diagnostic archive', content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }