功能:补充固定场景助手与GPU健康诊断能力
This commit is contained in:
parent
9f05aa20ea
commit
b6026f9759
@ -1,9 +1,9 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { role?: string; empty?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) {
|
||||
async function fixture(page: Page, options: { role?: string; empty?: boolean; ambiguous?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) {
|
||||
const writes: { path: string; body: Record<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[] }]
|
||||
const desktops = options.empty ? [] : ['d1', 'd2'].map((id, i) => ({ id, name: i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() }))
|
||||
const desktops = options.empty ? [] : ['d1', 'd2', ...(options.ambiguous ? ['d3'] : [])].map((id, i) => ({ id, name: options.ambiguous && i === 2 ? '备用桌面' : i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() }))
|
||||
const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }]
|
||||
const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved })
|
||||
@ -129,3 +129,38 @@ test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, inf
|
||||
await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true })
|
||||
})
|
||||
|
||||
test('通用工作台固定场景助手生成方案,确认后复用真实授权接口', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('可执行方案', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '分配已有桌面', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.assistant-plan').getByText('备用桌面', { exact: false }).first()).toBeVisible()
|
||||
await page.getByRole('button', { name: '执行方案', exact: true }).click()
|
||||
await page.getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '已执行', exact: true })).toBeVisible()
|
||||
expect(calls.writes.filter(w => w.path === '/users/batch-bindings')).toHaveLength(1)
|
||||
expect(calls.writes.find(w => w.path === '/users/batch-bindings')?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'grant' })
|
||||
})
|
||||
|
||||
test('通用工作台助手对未知或重复授权给出阻止方案,不发写请求', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给不存在的人分配一台桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('没有匹配到员工', { exact: false })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配办公桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('授权已存在', { exact: false })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('通用工作台助手不会在多个同名桌面之间自动选择', async ({ page }) => {
|
||||
const calls = await fixture(page, { ambiguous: true }); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('匹配到多个桌面', { exact: false })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
@ -52,6 +52,8 @@ export type User = components['schemas']['User']
|
||||
export type Resource = components['schemas']['Resource']
|
||||
export type GPUInventory = components['schemas']['GPUInventory']
|
||||
export type GPUItem = components['schemas']['GPUItem']
|
||||
export type GPUExclusiveStatus = components['schemas']['GPUExclusiveStatus']
|
||||
export type TerminalLogArchivePage = components['schemas']['TerminalLogArchivePage']
|
||||
export type Task = components['schemas']['Task']
|
||||
export type Audit = components['schemas']['Audit']
|
||||
export type AuditQuery = NonNullable<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' })),
|
||||
collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })),
|
||||
gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })),
|
||||
gpuExclusiveStatus: (node: string) => result(client.GET('/resources/gpu-exclusive', { params: { query: { node } } })),
|
||||
terminalLogArchives: (filters: NonNullable<paths['/terminal-logs']['get']['parameters']['query']> = {}) => result(client.GET('/terminal-logs', { params: { query: filters } })),
|
||||
pveConnection: () => result(client.GET('/settings/pve')),
|
||||
checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })),
|
||||
savePveConnection: (body: PveConnectionInput) => result(client.PUT('/settings/pve', { body })),
|
||||
|
||||
312
console/src/api/schema.d.ts
vendored
312
console/src/api/schema.d.ts
vendored
@ -692,6 +692,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/resources/gpu-exclusive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["gpuExclusiveStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/resources/vms": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1238,6 +1254,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/terminal-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["terminalLogArchives"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/audit-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1588,6 +1620,19 @@ export interface components {
|
||||
mediated: boolean;
|
||||
pendingRemoval: boolean;
|
||||
};
|
||||
GPUHealth: {
|
||||
status: string;
|
||||
source: string;
|
||||
/** Format: date-time */
|
||||
observedAt: string;
|
||||
temperatureC: number | null;
|
||||
utilizationPercent: number | null;
|
||||
memoryUsedBytes: number | null;
|
||||
memoryTotalBytes: number | null;
|
||||
driver: string;
|
||||
/** @enum {string} */
|
||||
driverBinding: "bound" | "unknown";
|
||||
};
|
||||
GPUItem: {
|
||||
id: string;
|
||||
address: string;
|
||||
@ -1599,6 +1644,7 @@ export interface components {
|
||||
iommuGroup: number | null;
|
||||
/** @enum {string} */
|
||||
state: "unreferenced" | "unknown" | "referenced" | "multiple";
|
||||
health: components["schemas"]["GPUHealth"];
|
||||
references: components["schemas"]["GPUReference"][];
|
||||
};
|
||||
GPUIssue: {
|
||||
@ -1615,6 +1661,14 @@ export interface components {
|
||||
scannedVmCount: number;
|
||||
items: components["schemas"]["GPUItem"][];
|
||||
issues: components["schemas"]["GPUIssue"][];
|
||||
health: {
|
||||
status: string;
|
||||
source: string;
|
||||
temperature: string;
|
||||
utilization: string;
|
||||
memory: string;
|
||||
driverBinding: string;
|
||||
};
|
||||
};
|
||||
Task: {
|
||||
id: string;
|
||||
@ -2344,6 +2398,53 @@ export interface components {
|
||||
source: string;
|
||||
notes: string[];
|
||||
};
|
||||
TerminalLogArchive: {
|
||||
id: string;
|
||||
taskId: string;
|
||||
terminalId: string;
|
||||
terminalName: string;
|
||||
/** Format: date-time */
|
||||
capturedAt: string;
|
||||
bootId: string;
|
||||
agentVersion: string;
|
||||
kioskState: string;
|
||||
logText: string;
|
||||
};
|
||||
TerminalLogArchivePage: {
|
||||
items: components["schemas"]["TerminalLogArchive"][];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
/** Format: date-time */
|
||||
observedAt: string;
|
||||
retentionDays: number;
|
||||
};
|
||||
GPUExclusiveMember: {
|
||||
desktopId: string;
|
||||
name: string;
|
||||
vmid: number;
|
||||
powerState: string;
|
||||
powerObserved: boolean;
|
||||
activeSessions: number;
|
||||
activeTasks: number;
|
||||
conflicts: string[];
|
||||
eligible: boolean;
|
||||
};
|
||||
GPUExclusiveStatus: {
|
||||
enabled: boolean;
|
||||
node: string;
|
||||
vmids: number[];
|
||||
/** Format: date-time */
|
||||
observedAt: string;
|
||||
lockAvailable: boolean;
|
||||
groupComplete: boolean;
|
||||
members: components["schemas"]["GPUExclusiveMember"][];
|
||||
conflicts: string[];
|
||||
allocationReady: boolean;
|
||||
releaseReady: boolean;
|
||||
releasePolicy: string;
|
||||
activeTasks: components["schemas"]["Task"][];
|
||||
};
|
||||
DesktopList: {
|
||||
items: components["schemas"]["Desktop"][];
|
||||
page: number;
|
||||
@ -7833,6 +7934,109 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
gpuExclusiveStatus: {
|
||||
parameters: {
|
||||
query: {
|
||||
node: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GPUExclusiveStatus"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
discoverVMs: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@ -11882,6 +12086,114 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
terminalLogArchives: {
|
||||
parameters: {
|
||||
query?: {
|
||||
terminalId?: string;
|
||||
keyword?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TerminalLogArchivePage"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Request failed */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
audits: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@ -1,16 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
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 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 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>
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task } from '../api'
|
||||
import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task, type TerminalLogArchivePage } from '../api'
|
||||
import { auditActionLabels } from '../api/audit-labels'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAuth } from '../stores/auth'
|
||||
@ -13,7 +13,7 @@ const end = new Date()
|
||||
const form = reactive({ kind: 'terminal' as 'terminal' | 'desktop', id: '', source: '' as NonNullable<DiagnosticQuery['source']>, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null })
|
||||
const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' }
|
||||
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 currentCollection = computed(() => data.tasks.find(t => t.id === collection.value?.id) || collection.value)
|
||||
const objects = computed(() => form.kind === 'terminal' ? data.terminals.map(t => ({ id: t.id, label: `${t.name} / ${t.sn}` })) : data.desktops.map(d => ({ id: d.id, label: `${d.name} / VM ${d.vmid}` })))
|
||||
@ -34,10 +34,17 @@ async function load(reset = false) {
|
||||
if (!applied.value) return
|
||||
const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 }
|
||||
loading.value = true; error.value = ''; report.value = null
|
||||
try { const result = await api.diagnostics(filters); if (version === generation) report.value = result }
|
||||
try { const result = await api.diagnostics(filters); if (version === generation) { report.value = result; archives.value = result.subject.kind === 'terminal' ? await api.terminalLogArchives({ terminalId: result.subject.id, from: filters.from, to: filters.to, page: 1, pageSize: 20 }) : null } }
|
||||
catch (e) { if (version === generation) error.value = errorMessage(e) }
|
||||
finally { if (version === generation) loading.value = false }
|
||||
}
|
||||
async function searchArchives() {
|
||||
if (!report.value || report.value.subject.kind !== 'terminal') return
|
||||
archiveLoading.value = true
|
||||
try { archives.value = await api.terminalLogArchives({ terminalId: report.value.subject.id, keyword: archiveKeyword.value || undefined, from: applied.value?.from, to: applied.value?.to, page: 1, pageSize: 20 }) }
|
||||
catch (e) { ElMessage.error(errorMessage(e)) }
|
||||
finally { archiveLoading.value = false }
|
||||
}
|
||||
async function download() {
|
||||
if (!report.value || !applied.value || exporting.value || loading.value) return
|
||||
const filters = { ...applied.value }, version = exportGeneration
|
||||
@ -85,10 +92,11 @@ watch(() => [route.query.targetType, route.query.targetId], applyRoute)
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<section v-else-if="!error" class="panel diagnosis-empty"><el-empty description="选择需要排查的设备或桌面,再查询诊断记录" :image-size="110" /></section>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from '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 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 labels = { referenced: '有配置引用', multiple: '多 VM 引用', unreferenced: '未发现配置引用', unknown: '待核查' }
|
||||
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
|
||||
inventory.value = undefined; error.value = ''; busy.value = !!target
|
||||
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) }
|
||||
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>
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
<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}}
|
||||
</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>
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
|
||||
[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按设备/桌面关联任务、会话、告警和审计,设备日志采集回执与完整诊断包导出;已完成真实盒子采集与页面验收。
|
||||
|
||||
[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md):先不接模型,通用工作台可生成并确认真实员工授权方案;未知对象和未支持场景会阻止写操作,已部署到 5182。
|
||||
|
||||
[云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。
|
||||
|
||||
[控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。
|
||||
|
||||
26
docs/cloud-assistant-wizard-20261112.md
Normal file
26
docs/cloud-assistant-wizard-20261112.md
Normal 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。
|
||||
|
||||
后续可在同一方案对象上增加批量电源预检和终端无桌面诊断,再评估模型服务;模型必须只能选择受控业务动作,不能直接执行自由文本命令。
|
||||
@ -155,3 +155,5 @@ Go 全量测试与 `go vet` 通过,使用独立临时 PostgreSQL,覆盖账
|
||||
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 项浏览器回归及真实接口/页面验收通过;真实盒子成功完成一次只读日志采集。现有身份、授权、连接配置及告警规则保持,唯一新增任务为该次采集。持续实时日志、全量归档和客体自动采集仍待后续。
|
||||
|
||||
12-11 追加:[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md)已完成代码和隔离页面验收并部署到 5182:输入员工与桌面需求生成授权方案,确认后复用现有绑定接口;未知对象、重复授权、同名桌面和未支持句式不发写请求。15 项通用版浏览器测试、TypeScript、ESLint、单元测试和生产构建通过;真实临时员工授权流程已执行并清理。
|
||||
|
||||
18
docs/cloud-gpu-health-exclusive-logs-20261112.md
Normal file
18
docs/cloud-gpu-health-exclusive-logs-20261112.md
Normal 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 正在运行导致不可分配。
|
||||
@ -87,3 +87,5 @@
|
||||
11-11 已部署[通用版与高级版](cloud-editions-20261111.md):通用版五个日常入口,真实员工授权、设备登记、只读问题诊断和简明操作记录;高级版保留专业管理并补齐显卡准备超时任务核对入口。模型暂不接入,界面模式不改变账号权限或原功能验收范围。
|
||||
|
||||
11-11 已部署[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按唯一设备/桌面关联任务、会话、告警、审计和设备日志,支持 7 天内筛选、分页与完整 ZIP 导出;真实盒子通过页面采集成功并展示/导出。6 项后端专项、Go 全量/vet、25 项页面回归及真实权限/深浅/窄屏检查通过。持续实时日志、全量归档和 VM 客体日志自动采集仍未实现。
|
||||
|
||||
12-11 通用版工作台已加入不依赖模型的固定场景方案向导:匹配员工和已有桌面、展示影响、确认后调用真实授权接口,并在对象不唯一或场景不支持时阻止写操作。前端检查、构建和 15 项通用版浏览器回归通过;已发布到真实 5182,并完成临时员工授权执行与清理。
|
||||
|
||||
7
docs/evidence/cloud-assistant-wizard-20261112/README.md
Normal file
7
docs/evidence/cloud-assistant-wizard-20261112/README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# 通用版固定场景助手验收证据
|
||||
|
||||
- `browser-verification.json`:真实 5182 浏览器生成方案、确认执行授权、高级版切换、390 宽度及无失败请求。
|
||||
- `deployment.json`:控制台镜像替换记录,API 和 PostgreSQL 未重启。
|
||||
- `state-verification.json`:临时员工和授权已清理,现场范围为 console-only。
|
||||
|
||||
真实执行使用临时员工和已有桌面,授权成功后按唯一 ID 删除临时用户;未操作 VM 电源、GPU、终端命令或 PVE 配置。
|
||||
@ -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": []
|
||||
}
|
||||
@ -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 |
@ -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"
|
||||
}
|
||||
@ -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": {
|
||||
"get": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"get": {
|
||||
"operationId": "audits",
|
||||
@ -14107,6 +14391,59 @@
|
||||
],
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -14144,6 +14481,9 @@
|
||||
"multiple"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"$ref": "#/components/schemas/GPUHealth"
|
||||
},
|
||||
"references": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -14161,6 +14501,7 @@
|
||||
"class",
|
||||
"iommuGroup",
|
||||
"state",
|
||||
"health",
|
||||
"references"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@ -14215,6 +14556,38 @@
|
||||
"items": {
|
||||
"$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": [
|
||||
@ -14224,7 +14597,8 @@
|
||||
"visibleVmCount",
|
||||
"scannedVmCount",
|
||||
"items",
|
||||
"issues"
|
||||
"issues",
|
||||
"health"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@ -17171,6 +17545,203 @@
|
||||
],
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@ -78,3 +78,5 @@ CSV 导入、90 天会话历史及节点历史曲线/告警规则已补齐,见
|
||||
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。
|
||||
|
||||
11-11 高级版[日志与诊断中心](cloud-diagnostics-20261111.md)已部署并完成真实盒子采集、关联查询和诊断包下载验收,不再作为未实现项重复排期。后续日志方向为持续推送、服务端归档及 VM 客体采集;定时任务、备份恢复、OTA 等高级事项仍按各自计划与验收条件推进,本轮未实现这些功能。
|
||||
|
||||
12-11 已部署[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md):当前不接模型,先把“给员工分配已有桌面”等请求转换为可核对方案并复用真实授权接口。批量电源预检、终端无桌面诊断和模型接入仍待后续逐项验收。
|
||||
|
||||
@ -53,6 +53,59 @@ type diagnosticReport struct {
|
||||
Source string `json:"source"`
|
||||
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
|
||||
// inferred from a device's current binding: that device may serve other VMs.
|
||||
|
||||
@ -35,6 +35,129 @@ type gpuPrepareState struct {
|
||||
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 {
|
||||
if d.Provider != "proxmox" || d.Node != s.Config.ExclusiveGPUNode {
|
||||
return false
|
||||
|
||||
@ -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) {
|
||||
f, p := exclusiveFixture(t, "normal")
|
||||
prepareExclusive(t, f)
|
||||
|
||||
@ -37,8 +37,20 @@ type gpuItem struct {
|
||||
Class string `json:"class"`
|
||||
IOMMUGroup *int `json:"iommuGroup"`
|
||||
State string `json:"state"`
|
||||
Health gpuHealth `json:"health"`
|
||||
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 {
|
||||
Code string `json:"code"`
|
||||
VMID int `json:"vmid"`
|
||||
@ -92,6 +104,7 @@ func (s *Server) gpuInventory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
items := []gpuItem{}
|
||||
observedAt := time.Now().UTC()
|
||||
for _, d := range devices {
|
||||
if !d.IsGPU() {
|
||||
continue
|
||||
@ -105,7 +118,11 @@ func (s *Server) gpuInventory(c *gin.Context) {
|
||||
if group != nil && *group < 0 {
|
||||
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")
|
||||
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 })
|
||||
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 {
|
||||
|
||||
@ -299,7 +299,7 @@ func (s *Server) observe(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -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.GET("/resources", s.resources)
|
||||
a.GET("/resources/gpus", s.gpuInventory)
|
||||
a.GET("/resources/gpu-exclusive", s.exclusiveGPUStatus)
|
||||
a.GET("/resources/vms", s.discoverVMs)
|
||||
a.GET("/resources/templates", s.discoverTemplates)
|
||||
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("/diagnostics", s.allow("super_admin", "operator"), s.diagnostics)
|
||||
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/export", s.exportAudits)
|
||||
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
|
||||
|
||||
@ -253,6 +253,11 @@ func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
|
||||
if out["logTail"] != "private diagnostic" {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -233,6 +233,15 @@ func (s *Server) deviceAck(c *gin.Context) {
|
||||
s.dbError(c, err)
|
||||
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.
|
||||
if err = s.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil {
|
||||
|
||||
@ -73,6 +73,15 @@ CREATE TABLE IF NOT EXISTS task_diagnostics (
|
||||
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
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 (
|
||||
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,
|
||||
|
||||
@ -18,6 +18,7 @@ type PCIDevice struct {
|
||||
Device string `json:"device"`
|
||||
DeviceName string `json:"device_name"`
|
||||
IOMMUGroup *int `json:"iommugroup"`
|
||||
Driver string `json:"driver"`
|
||||
}
|
||||
|
||||
type PCIMapping struct {
|
||||
|
||||
@ -21,9 +21,10 @@ const schemas = {
|
||||
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 }),
|
||||
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 }),
|
||||
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 }),
|
||||
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 }),
|
||||
@ -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.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.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.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 }
|
||||
schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true }
|
||||
@ -193,6 +198,7 @@ const paths = {
|
||||
'/settings': { get: operation('settings', 'Settings') },
|
||||
'/resources': { get: operation('resources', 'ResourceList', { list: 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/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: 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 }) },
|
||||
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
|
||||
'/diagnostics/export': { get: operation('exportDiagnostics', null) },
|
||||
'/terminal-logs': { get: operation('terminalLogArchives', 'TerminalLogArchivePage') },
|
||||
'/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) },
|
||||
'/audit-logs/export': { get: operation('exportAudits', null, { period: true }) },
|
||||
'/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/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/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' } } } }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user