功能:完善云管助手与GPU故障恢复核对

This commit is contained in:
songyiming committed 2026-11-13 19:08:40 +08:00
1 parent b6026f9759
commit 9f7b01e4db
42 files changed
+3576 -120

No files matched your search

+1 -1
View File
@@ -1 +1 @@
export const auditActionLabels: Record<string, string> = { export_diagnostics: '导出诊断包', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' } export const auditActionLabels: Record<string, string> = { export_diagnostics: '导出诊断包', assistant_grant_desktop: '助手分配桌面', assistant_revoke_desktop: '助手撤销桌面', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' }
+9 -1
View File
@@ -37,10 +37,12 @@ export type AdministratorSession = components['schemas']['AdministratorSession']
export type CreateAdministrator = components['schemas']['CreateAdministrator'] export type CreateAdministrator = components['schemas']['CreateAdministrator']
export type UpdateAdministrator = components['schemas']['UpdateAdministrator'] export type UpdateAdministrator = components['schemas']['UpdateAdministrator']
export type Desktop = components['schemas']['Desktop'] export type Desktop = components['schemas']['Desktop']
export type DesktopReadiness = components['schemas']['DesktopReadiness']
export type ImageTemplate = components['schemas']['ImageTemplate'] export type ImageTemplate = components['schemas']['ImageTemplate']
export type DesktopPool = components['schemas']['DesktopPool'] export type DesktopPool = components['schemas']['DesktopPool']
export type PoolInput = components['schemas']['PoolInput'] export type PoolInput = components['schemas']['PoolInput']
export type TemplateInput = components['schemas']['TemplateInput'] export type TemplateInput = components['schemas']['TemplateInput']
export type TemplateUpdate = components['schemas']['TemplateUpdate']
export type CloneInput = components['schemas']['CloneInput'] export type CloneInput = components['schemas']['CloneInput']
export type Storage = components['schemas']['Storage'] export type Storage = components['schemas']['Storage']
export type ConnectionInput = components['schemas']['ConnectionInput'] export type ConnectionInput = components['schemas']['ConnectionInput']
@@ -64,6 +66,8 @@ export type BindingInput = components['schemas']['BindingInput']
export type ImportInput = components['schemas']['ImportInput'] export type ImportInput = components['schemas']['ImportInput']
export type EnrollmentInput = components['schemas']['EnrollmentInput'] export type EnrollmentInput = components['schemas']['EnrollmentInput']
export type DiscoveredVM = components['schemas']['DiscoveredVM'] export type DiscoveredVM = components['schemas']['DiscoveredVM']
export type AssistantPlanResult = components['schemas']['AssistantPlanResult']
export type AssistantExecuteResult = components['schemas']['AssistantExecuteResult']
export type DesktopAction = components['schemas']['DesktopAction']['action'] export type DesktopAction = components['schemas']['DesktopAction']['action']
export type TerminalAction = components['schemas']['TerminalAction']['action'] export type TerminalAction = components['schemas']['TerminalAction']['action']
let accessToken = '' let accessToken = ''
@@ -91,6 +95,9 @@ const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() })
const query = { page: 1, pageSize: 100 } const query = { page: 1, pageSize: 100 }
export const api = { export const api = {
diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })), diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })),
assistantPlan: (text: string) => result(client.POST('/assistant/plan', { body: { text } })),
assistantExecute: (runId: string, key = crypto.randomUUID()) => result(client.POST('/assistant/execute', { params: { header: { 'Idempotency-Key': key } }, body: { runId } })),
assistantRun: (id: string) => result(client.GET('/assistant/runs/{id}', { params: { path: { id } } })),
exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })), exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })),
collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })), collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })),
gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })),
@@ -161,7 +168,7 @@ export const api = {
templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })), templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })),
discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })), discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })),
registerTemplate: (body: TemplateInput) => result(client.POST('/templates', { body })), registerTemplate: (body: TemplateInput) => result(client.POST('/templates', { body })),
updateTemplate: (id: string, enabled: boolean) => result(client.PATCH('/templates/{id}', { params: { path: { id } }, body: { enabled } })), updateTemplate: (id: string, body: TemplateUpdate) => result(client.PATCH('/templates/{id}', { params: { path: { id } }, body })),
validateTemplate: (id: string) => result(client.POST('/templates/{id}/validate', { params: { path: { id } } })), validateTemplate: (id: string) => result(client.POST('/templates/{id}/validate', { params: { path: { id } } })),
pools: (page = 1) => result(client.GET('/pools', { params: { query: { ...query, page } } })), pools: (page = 1) => result(client.GET('/pools', { params: { query: { ...query, page } } })),
savePool: (id: string, body: PoolInput) => id ? result(client.PUT('/pools/{id}', { params: { path: { id } }, body })) : result(client.POST('/pools', { body })), savePool: (id: string, body: PoolInput) => id ? result(client.PUT('/pools/{id}', { params: { path: { id } }, body })) : result(client.POST('/pools', { body })),
@@ -170,6 +177,7 @@ export const api = {
checkClone: (body: CloneInput) => result(client.POST('/desktops/clone/check', { body })), checkClone: (body: CloneInput) => result(client.POST('/desktops/clone/check', { body })),
cloneDesktop: (body: CloneInput, key: string) => result(client.POST('/desktops/clone', { params: { header: { 'Idempotency-Key': key } }, body })), cloneDesktop: (body: CloneInput, key: string) => result(client.POST('/desktops/clone', { params: { header: { 'Idempotency-Key': key } }, body })),
configureDesktop: (id: string, body: ConnectionInput) => result(client.PUT('/desktops/{id}/connection', { params: { path: { id } }, body })), configureDesktop: (id: string, body: ConnectionInput) => result(client.PUT('/desktops/{id}/connection', { params: { path: { id } }, body })),
checkDesktopReadiness: (id: string) => result(client.POST('/desktops/{id}/readiness/check', { params: { path: { id } } })),
sync: () => result(client.POST('/resources/sync', { params: { header: commandHeaders() } })), sync: () => result(client.POST('/resources/sync', { params: { header: commandHeaders() } })),
importDesktop: (body: ImportInput) => result(client.POST('/desktops/import', { body })), importDesktop: (body: ImportInput) => result(client.POST('/desktops/import', { body })),
desktopAction: (id: string, action: DesktopAction) => result(client.POST('/desktops/{id}/actions', { params: { path: { id }, header: commandHeaders() }, body: { action } })), desktopAction: (id: string, action: DesktopAction) => result(client.POST('/desktops/{id}/actions', { params: { path: { id }, header: commandHeaders() }, body: { action } })),
+839 -1
View File
@@ -644,6 +644,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/assistant/plan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["assistantPlan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/execute": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["assistantExecute"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/runs/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["assistantRun"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": { "/settings": {
parameters: { parameters: {
query?: never; query?: never;
@@ -708,6 +756,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/gpu-agent/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["ingestGPUHealth"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/resources/vms": { "/resources/vms": {
parameters: { parameters: {
query?: never; query?: never;
@@ -900,6 +964,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/desktops/{id}/readiness/check": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["checkDesktopReadiness"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/resources/sync": { "/resources/sync": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1270,6 +1350,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/terminal/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["appendTerminalLogs"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/audit-logs": { "/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1569,6 +1665,22 @@ export interface components {
poolId?: string | null; poolId?: string | null;
setupRequired?: boolean; setupRequired?: boolean;
}; };
DesktopReadinessCheck: {
name: string;
/** @enum {string} */
status: "pass" | "fail" | "unknown";
message: string;
};
DesktopReadiness: {
desktopId: string;
/** Format: date-time */
observedAt: string;
ready: boolean;
powerState: string;
serviceState: string;
setupRequired: boolean;
checks: components["schemas"]["DesktopReadinessCheck"][];
};
Terminal: { Terminal: {
id: string; id: string;
sn: string; sn: string;
@@ -1670,6 +1782,25 @@ export interface components {
driverBinding: string; driverBinding: string;
}; };
}; };
GPUAgentSample: {
address: string;
/** Format: date-time */
observedAt?: string | null;
temperatureC?: number | null;
utilizationPercent?: number | null;
memoryUsedBytes?: number | null;
memoryTotalBytes?: number | null;
driver?: string;
driverBinding?: string;
};
GPUAgentReport: {
node: string;
samples: components["schemas"]["GPUAgentSample"][];
};
GPUAgentAccepted: {
accepted: number;
node: string;
};
Task: { Task: {
id: string; id: string;
kind: string; kind: string;
@@ -1887,6 +2018,69 @@ export interface components {
message?: string; message?: string;
diagnostics?: components["schemas"]["DeviceDiagnostics"]; diagnostics?: components["schemas"]["DeviceDiagnostics"];
}; };
AssistantInput: {
text: string;
};
AssistantCandidate: {
id: string;
label: string;
kind: string;
};
AssistantPlan: {
action: string;
/** @enum {string} */
kind: "action" | "route" | "blocked";
title: string;
summary: string;
userId: string;
userName: string;
desktopId: string;
desktopName: string;
desktopVmid: number;
missing: string[];
candidates: components["schemas"]["AssistantCandidate"][];
risk: string;
confirmationRequired: boolean;
path: string;
reason: string;
};
AssistantPlanResult: {
runId: string;
status: string;
input: string;
plan: components["schemas"]["AssistantPlan"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
expiresAt: string;
};
AssistantExecuteInput: {
runId: string;
};
AssistantExecuteResult: {
runId: string;
status: string;
result: {
userId: string;
desktopId: string;
action: string;
message: string;
};
};
AssistantRun: {
runId: string;
input: string;
action: string;
status: string;
plan: components["schemas"]["AssistantPlan"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
expiresAt: string;
/** Format: date-time */
executedAt: string | null;
result: Record<string, never>;
};
PveConnectionInput: { PveConnectionInput: {
url: string; url: string;
tokenId: string; tokenId: string;
@@ -2242,6 +2436,7 @@ export interface components {
node: string; node: string;
vmid: number; vmid: number;
enabled: boolean; enabled: boolean;
hardwareEncodingVerified: boolean;
/** Format: date-time */ /** Format: date-time */
validatedAt: string; validatedAt: string;
}; };
@@ -2252,9 +2447,11 @@ export interface components {
os: "Windows" | "Ubuntu" | "麒麟"; os: "Windows" | "Ubuntu" | "麒麟";
node: string; node: string;
vmid: number; vmid: number;
hardwareEncodingVerified?: boolean;
}; };
TemplateUpdate: { TemplateUpdate: {
enabled: boolean; enabled?: boolean;
hardwareEncodingVerified?: boolean;
}; };
DesktopPool: { DesktopPool: {
id: string; id: string;
@@ -2409,6 +2606,8 @@ export interface components {
agentVersion: string; agentVersion: string;
kioskState: string; kioskState: string;
logText: string; logText: string;
/** @enum {string} */
source: "snapshot" | "stream";
}; };
TerminalLogArchivePage: { TerminalLogArchivePage: {
items: components["schemas"]["TerminalLogArchive"][]; items: components["schemas"]["TerminalLogArchive"][];
@@ -2419,6 +2618,17 @@ export interface components {
observedAt: string; observedAt: string;
retentionDays: number; retentionDays: number;
}; };
TerminalLogEntry: {
/** Format: date-time */
capturedAt?: string | null;
/** @enum {string} */
level?: "debug" | "info" | "warn" | "error";
message: string;
};
TerminalLogBatch: {
bootId?: string;
entries: components["schemas"]["TerminalLogEntry"][];
};
GPUExclusiveMember: { GPUExclusiveMember: {
desktopId: string; desktopId: string;
name: string; name: string;
@@ -7624,6 +7834,321 @@ export interface operations {
}; };
}; };
}; };
assistantPlan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantInput"];
};
};
responses: {
/** @description Successful response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantPlanResult"];
};
};
/** @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"];
};
};
};
};
assistantExecute: {
parameters: {
query?: never;
header: {
"Idempotency-Key": string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantExecuteInput"];
};
};
responses: {
/** @description Successful response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantExecuteResult"];
};
};
/** @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"];
};
};
};
};
assistantRun: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantRun"];
};
};
/** @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"];
};
};
};
};
settings: { settings: {
parameters: { parameters: {
query?: never; query?: never;
@@ -8037,6 +8562,111 @@ export interface operations {
}; };
}; };
}; };
ingestGPUHealth: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GPUAgentReport"];
};
};
responses: {
/** @description Successful response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GPUAgentAccepted"];
};
};
/** @description Request failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
discoverVMs: { discoverVMs: {
parameters: { parameters: {
query?: { query?: {
@@ -9521,6 +10151,109 @@ export interface operations {
}; };
}; };
}; };
checkDesktopReadiness: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DesktopReadiness"];
};
};
/** @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"];
};
};
};
};
syncResources: { syncResources: {
parameters: { parameters: {
query?: never; query?: never;
@@ -12194,6 +12927,111 @@ export interface operations {
}; };
}; };
}; };
appendTerminalLogs: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TerminalLogBatch"];
};
};
responses: {
/** @description Successful response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Accepted"];
};
};
/** @description Request failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Request failed */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
audits: { audits: {
parameters: { parameters: {
query?: { query?: {
+1 -1
View File
@@ -30,7 +30,7 @@ const items = [
{ path: '/monitor', title: '监控告警', icon: DataAnalysis, group: 'manage' }, { path: '/monitor', title: '监控告警', icon: DataAnalysis, group: 'manage' },
{ path: '/templates', title: '镜像模板', icon: Files, group: 'manage' }, { path: '/templates', title: '镜像模板', icon: Files, group: 'manage' },
{ path: '/pools', title: '桌面池', icon: Collection, group: 'manage' }, { path: '/pools', title: '桌面池', icon: Collection, group: 'manage' },
{ path: '/users', title: '用户与授权', icon: User, group: 'manage' }, { path: '/users', title: '员工与授权', icon: User, group: 'work' },
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' }, { path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
{ path: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' }, { path: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' },
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' }, { path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
+1 -1
View File
@@ -21,7 +21,7 @@ export const router = createRouter({ history: createWebHistory(), routes: [
{ path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } }, { path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } },
{ path: 'terminals', component: () => import('../views/Terminals.vue'), meta: { title: '终端管理' } }, { path: 'terminals', component: () => import('../views/Terminals.vue'), meta: { title: '终端管理' } },
{ path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } }, { path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } },
{ path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } }, { path: 'users', component: () => import('../views/Users.vue'), meta: { title: '员工与授权' } },
{ path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } }, { path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } },
{ path: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } }, { path: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } },
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } }, { path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },
+21 -2
View File
@@ -7,6 +7,8 @@ import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue' import StatusTag from '../components/StatusTag.vue'
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction() const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
const search = ref(''), page = ref(1), creating = ref(false), editing = ref(false), reviewing = ref(false), person = ref<User | null>(null) const search = ref(''), page = ref(1), creating = ref(false), editing = ref(false), reviewing = ref(false), person = ref<User | null>(null)
const profileVisible = ref(false), profileId = ref(''), profileUsername = ref('')
const profile = reactive({ displayName: '', enabled: true })
const form = reactive({ username: '', displayName: '' }), selected = ref<string[]>([]), action = ref<'grant' | 'revoke'>('grant') const form = reactive({ username: '', displayName: '' }), selected = ref<string[]>([]), action = ref<'grant' | 'revoke'>('grant')
const filtered = computed(() => data.users.filter(u => `${u.displayName} ${u.username}`.toLowerCase().includes(search.value.toLowerCase()))) const filtered = computed(() => data.users.filter(u => `${u.displayName} ${u.username}`.toLowerCase().includes(search.value.toLowerCase())))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10)) const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
@@ -28,6 +30,18 @@ async function create() {
if (!auth.canAdmin || !form.username.trim() || !form.displayName.trim()) return if (!auth.canAdmin || !form.username.trim() || !form.displayName.trim()) return
if (await run(() => api.createUser({ username: form.username.trim(), displayName: form.displayName.trim() }), '员工已添加,请继续分配桌面')) { creating.value = false; form.username = ''; form.displayName = '' } if (await run(() => api.createUser({ username: form.username.trim(), displayName: form.displayName.trim() }), '员工已添加,请继续分配桌面')) { creating.value = false; form.username = ''; form.displayName = '' }
} }
function editProfile(user: User) {
profileId.value = user.id
profileUsername.value = user.username
Object.assign(profile, { displayName: user.displayName, enabled: user.enabled })
profileVisible.value = true
}
async function saveProfile() {
if (!auth.canAdmin || !profile.displayName.trim() || busy.value) return
const existing = data.users.find(user => user.id === profileId.value)
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更会作废该员工未消费的连接票据。停用会阻止后续连接,但保留桌面授权和已有串流。')) return
if (await run(() => api.updateUser(profileId.value, { displayName: profile.displayName.trim(), enabled: profile.enabled }), '员工资料已保存')) profileVisible.value = false
}
async function save() { async function save() {
if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return
const userId = person.value.id, desktopIds = [...selected.value], operation = action.value const userId = person.value.id, desktopIds = [...selected.value], operation = action.value
@@ -36,9 +50,14 @@ async function save() {
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>员工与桌面</h1><p class="subtext">给员工分配已有桌面,其他授权自动保留</p></div><el-button v-if="auth.canAdmin" type="primary" @click="creating = true">添加员工</el-button></div> <div class="page-heading"><div><h1>员工与桌面</h1><p class="subtext">给员工分配已有桌面,其他授权自动保留</p></div><el-button v-if="auth.canAdmin" type="primary" @click="creating = true">添加员工</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找员工" placeholder="输入姓名或账号" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无员工,请先添加员工"><el-table-column label="员工" min-width="150"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }} · {{ row.enabled ? '已启用' : '已停用' }}</div></template></el-table-column><el-table-column label="已分配桌面" min-width="210"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }} <StatusTag v-if="data.desktops.find(d => d.id === id)" :value="data.desktops.find(d => d.id === id)!.powerState" /></div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="使用设备" min-width="145"><template #default="{ row }">{{ data.terminals.find(t => t.id === row.terminalId)?.name || '尚未关联设备' }}</template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="180"><template #default="{ row }"><el-button link type="primary" @click="open(row, 'grant')">分配桌面</el-button><el-button link :disabled="!row.desktopIds.length" @click="open(row, 'revoke')">撤销授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section> <section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找员工" placeholder="输入姓名或账号" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无员工,请先添加员工"><el-table-column label="员工" min-width="150"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }} · {{ row.enabled ? '已启用' : '已停用' }}</div></template></el-table-column><el-table-column label="已分配桌面" min-width="210"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }} <StatusTag v-if="data.desktops.find(d => d.id === id)" :value="data.desktops.find(d => d.id === id)!.powerState" /></div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="使用设备" min-width="145"><template #default="{ row }">{{ data.terminals.find(t => t.id === row.terminalId)?.name || '尚未关联设备' }}</template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="270"><template #default="{ row }"><div class="row-actions"><el-button link type="primary" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" @click="open(row, 'grant')">分配桌面</el-button><el-button link :disabled="!row.desktopIds.length" @click="open(row, 'revoke')">撤销授权</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section>
<section class="panel simple-section"><h2>桌面日常操作</h2><div class="toolbar"><el-input v-model="desktopSearch" aria-label="查找桌面" placeholder="桌面名称" clearable @input="desktopPage = 1" /></div><el-table :data="desktopRows" empty-text="暂无已有桌面,请由运维纳管"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column label="使用人" min-width="150"><template #default="{ row }">{{ data.users.filter(u => u.desktopIds.includes(row.id)).map(u => u.displayName).join('、') || '尚未分配' }}</template></el-table-column><el-table-column label="状态" min-width="180"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary">{{ row.setupRequired ? '等待初始化' : row.serviceState === 'ready' ? '连接服务就绪' : '连接服务尚未确认就绪' }}</div><el-tag v-if="pending(row.id)" type="warning" size="small">任务未完成,请先核对</el-tag></template></el-table-column><el-table-column v-if="auth.canOperate" label="操作" min-width="200"><template #default="{ row }"><el-button v-if="row.powerState === 'stopped'" link type="primary" :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'start')">开机</el-button><template v-if="row.powerState === 'running'"><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'shutdown')">正常关机</el-button><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'reboot')">重启桌面</el-button></template></template></el-table-column></el-table><el-pagination v-model:current-page="desktopPage" :total="desktops.length" :page-size="10" layout="prev, pager, next" /></section> <section class="panel simple-section"><h2>桌面日常操作</h2><div class="toolbar"><el-input v-model="desktopSearch" aria-label="查找桌面" placeholder="桌面名称" clearable @input="desktopPage = 1" /></div><el-table :data="desktopRows" empty-text="暂无已有桌面,请由运维纳管"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column label="使用人" min-width="150"><template #default="{ row }">{{ data.users.filter(u => u.desktopIds.includes(row.id)).map(u => u.displayName).join('、') || '尚未分配' }}</template></el-table-column><el-table-column label="状态" min-width="180"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary">{{ row.setupRequired ? '等待初始化' : row.serviceState === 'ready' ? '连接服务就绪' : '连接服务尚未确认就绪' }}</div><el-tag v-if="pending(row.id)" type="warning" size="small">任务未完成,请先核对</el-tag></template></el-table-column><el-table-column v-if="auth.canOperate" label="操作" min-width="200"><template #default="{ row }"><el-button v-if="row.powerState === 'stopped'" link type="primary" :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'start')">开机</el-button><template v-if="row.powerState === 'running'"><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'shutdown')">正常关机</el-button><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'reboot')">重启桌面</el-button></template></template></el-table-column></el-table><el-pagination v-model:current-page="desktopPage" :total="desktops.length" :page-size="10" layout="prev, pager, next" /></section>
<p class="subtext">分配表示允许后续连接,不会新建桌面或自动开机。关联设备、批量导入、员工停用等管理操作可在高级版中完成。</p> <p class="subtext">分配表示允许后续连接,不会新建桌面或自动开机。关联设备、批量导入和批量授权等集中管理功能可在高级版中完成。</p>
<el-dialog v-model="creating" title="添加员工" width="460px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="create"><el-form-item label="员工姓名" required><el-input v-model="form.displayName" aria-label="员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="form.username" aria-label="员工账号" maxlength="80" /></el-form-item><p class="subtext">此账号用于关联盒子与桌面授权,桌面系统密码单独管理。</p></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.displayName.trim() || !form.username.trim()" @click="create">添加员工</el-button></template></el-dialog> <el-dialog v-model="creating" title="添加员工" width="460px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="create"><el-form-item label="员工姓名" required><el-input v-model="form.displayName" aria-label="员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="form.username" aria-label="员工账号" maxlength="80" /></el-form-item><p class="subtext">此账号用于关联盒子与桌面授权,桌面系统密码单独管理。</p></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.displayName.trim() || !form.username.trim()" @click="create">添加员工</el-button></template></el-dialog>
<el-dialog v-model="profileVisible" title="编辑员工资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="员工账号(固定)"><el-input :model-value="profileUsername" disabled readonly /></el-form-item><el-form-item label="姓名" required><el-input v-model="profile.displayName" aria-label="编辑员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑员工启用状态" /></el-form-item><p class="subtext">员工账号用于授权关系,创建后不能修改;停用会阻止后续连接,但保留已有桌面授权。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存员工资料</el-button></template></el-dialog>
<el-dialog v-model="editing" :title="action === 'grant' ? '分配已有桌面' : '撤销指定授权'" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><template v-if="person"><h3>{{ person.displayName }} · {{ person.username }}</h3><p v-if="!person.enabled" class="subtext">员工目前已停用,保存授权后仍需管理员启用才能连接。</p><template v-if="!reviewing"><p class="subtext">选择要{{ action === 'grant' ? '分配' : '撤销' }}的桌面:</p><el-checkbox-group v-model="selected" class="simple-checks"><el-checkbox v-for="d in choices" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><el-empty v-if="!choices.length" description="没有符合条件的桌面" :image-size="64" /></template><div v-else class="simple-plan"><h3>确认变更方案</h3><p>为 {{ person.displayName }} {{ action === 'grant' ? '追加分配' : '撤销以下' }} {{ selected.length }} 台桌面的使用权限:</p><ul><li v-for="id in selected" :key="id">{{ desktopName(id) }}</li></ul><p>其他桌面授权、员工状态和设备关联保持原值。已有串流不会被强制断开。</p></div></template><template #footer><el-button :disabled="busy" @click="reviewing ? reviewing = false : editing = false">{{ reviewing ? '返回修改' : '取消' }}</el-button><el-button v-if="!reviewing" type="primary" :disabled="!selected.length" @click="reviewing = true">查看方案</el-button><el-button v-else type="primary" :loading="busy" @click="save">确认并保存授权</el-button></template></el-dialog> <el-dialog v-model="editing" :title="action === 'grant' ? '分配已有桌面' : '撤销指定授权'" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><template v-if="person"><h3>{{ person.displayName }} · {{ person.username }}</h3><p v-if="!person.enabled" class="subtext">员工目前已停用,保存授权后仍需管理员启用才能连接。</p><template v-if="!reviewing"><p class="subtext">选择要{{ action === 'grant' ? '分配' : '撤销' }}的桌面:</p><el-checkbox-group v-model="selected" class="simple-checks"><el-checkbox v-for="d in choices" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><el-empty v-if="!choices.length" description="没有符合条件的桌面" :image-size="64" /></template><div v-else class="simple-plan"><h3>确认变更方案</h3><p>为 {{ person.displayName }} {{ action === 'grant' ? '追加分配' : '撤销以下' }} {{ selected.length }} 台桌面的使用权限:</p><ul><li v-for="id in selected" :key="id">{{ desktopName(id) }}</li></ul><p>其他桌面授权、员工状态和设备关联保持原值。已有串流不会被强制断开。</p></div></template><template #footer><el-button :disabled="busy" @click="reviewing ? reviewing = false : editing = false">{{ reviewing ? '返回修改' : '取消' }}</el-button><el-button v-if="!reviewing" type="primary" :disabled="!selected.length" @click="reviewing = true">查看方案</el-button><el-button v-else type="primary" :loading="busy" @click="save">确认并保存授权</el-button></template></el-dialog>
</template> </template>
<style scoped>
.row-actions{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
</style>
+13 -29
View File
@@ -2,7 +2,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { api, errorMessage, type Desktop, type User } from '../api' import { api, errorMessage, type AssistantPlanResult } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAction } from '../composables/useAction' import { useAction } from '../composables/useAction'
const data = usePlatform(), auth = useAuth(), { busy, confirm } = useAction() const data = usePlatform(), auth = useAuth(), { busy, confirm } = useAction()
@@ -10,42 +10,26 @@ const running = computed(() => data.desktops.filter(d => d.powerState === 'runni
const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length) const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length)
const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length) const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length)
const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length) const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length)
const request = ref(''), plan = ref<{ kind: 'grant' | 'route' | 'blocked'; title: string; summary: string; user?: User; desktop?: Desktop; path?: string; reason?: string } | null>(null), executed = ref(false) const request = ref(''), plan = ref<AssistantPlanResult | null>(null), runningPlan = ref(false), executed = ref(false), assistantError = ref('')
const unassignedDesktops = computed(() => data.desktops.filter(d => !data.users.some(u => u.desktopIds.includes(d.id)))) async function generatePlan() {
function generatePlan() {
executed.value = false
const text = request.value.trim() const text = request.value.trim()
if (!text) { plan.value = null; return } if (!text || runningPlan.value) return
if (/分配|授权|安排/.test(text)) { runningPlan.value = true; assistantError.value = ''; executed.value = false
const userMatches = data.users.filter(u => text.includes(u.displayName) || text.includes(u.username)) try { plan.value = await api.assistantPlan(text) } catch (e) { assistantError.value = errorMessage(e) }
const desktopMatches = data.desktops.filter(d => text.includes(d.name) || text.includes(String(d.vmid))) finally { runningPlan.value = false }
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: '固定场景未匹配' }
} }
function chooseCandidate(candidate: { label: string }) { request.value = `${request.value.trim()} ${candidate.label}`.trim(); void generatePlan() }
async function executePlan() { async function executePlan() {
if (!plan.value || plan.value.kind !== 'grant' || !plan.value.user || !plan.value.desktop || !auth.canAdmin || busy.value) return if (!plan.value || plan.value.plan.kind !== 'action' || !auth.canAdmin || busy.value || executed.value) return
if (!await confirm('确认分配桌面', `将为 ${plan.value.user.displayName} 追加“${plan.value.desktop.name}”的使用权限。其他授权保持不变。`)) return if (!await confirm('确认执行方案', plan.value.plan.summary)) return
try { try { await api.assistantExecute(plan.value.runId); await data.refresh(); executed.value = true; ElMessage.success('方案已执行,操作已保存') }
await api.batchBindings({ userIds: [plan.value.user.id], desktopIds: [plan.value.desktop.id], action: 'grant' }) catch (e) { ElMessage.error(errorMessage(e)); await generatePlan() }
await data.refresh(); executed.value = true; ElMessage.success('方案已执行,授权已保存')
} catch (e) { ElMessage.error(errorMessage(e)) }
} }
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div> <div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div>
<div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div> <div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div>
<section class="panel simple-section 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 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" :loading="runningPlan" :disabled="!request.trim()" @click="generatePlan">生成方案</el-button></div><el-alert v-if="assistantError" type="error" :title="assistantError" :closable="false" class="notice" /><article v-if="plan" class="assistant-plan" :class="`is-${plan.plan.kind}`"><div><el-tag :type="plan.plan.kind === 'action' ? 'success' : plan.plan.kind === 'blocked' ? 'warning' : 'info'">{{ plan.plan.kind === 'action' ? '可执行方案' : plan.plan.kind === 'route' ? '查询方案' : '需要补充信息' }}</el-tag><h3>{{ plan.plan.title }}</h3><p>{{ plan.plan.summary }}</p><p v-if="plan.plan.reason" class="secondary">原因:{{ plan.plan.reason }}</p></div><div v-if="plan.plan.candidates.length" class="assistant-candidates"><p class="secondary">请选择准确对象:</p><el-button v-for="candidate in plan.plan.candidates" :key="`${candidate.kind}-${candidate.id}`" size="small" @click="chooseCandidate(candidate)">{{ candidate.label }}</el-button></div><div class="assistant-plan-actions"><el-button v-if="plan.plan.kind === 'action'" type="primary" :loading="busy" :disabled="!auth.canAdmin || executed" @click="executePlan">{{ executed ? '已执行' : auth.canAdmin ? '确认并执行' : '当前角色无权执行' }}</el-button><router-link v-else-if="plan.plan.kind === 'route' && plan.plan.path" :to="plan.plan.path"><el-button>打开处理页面</el-button></router-link></div><dl v-if="plan.plan.kind === 'action'"><div><dt>员工</dt><dd>{{ plan.plan.userName }} · {{ plan.plan.userId }}</dd></div><div><dt>桌面</dt><dd>{{ plan.plan.desktopName }} · VM {{ plan.plan.desktopVmid }}</dd></div><div><dt>执行影响</dt><dd>{{ plan.plan.action === 'grant_desktop' ? '只追加授权,不开机、不建机、不终止已有连接' : '只撤销授权,不关机、不终止已有连接' }}</dd></div><div><dt>方案有效期</dt><dd>{{ new Date(plan.expiresAt).toLocaleTimeString('zh-CN') }}</dd></div></dl></article><div class="simple-shortcuts"><router-link to="/people"><strong>员工与桌面 →</strong><span>添加员工,分配或撤销已有桌面</span></router-link><router-link to="/devices"><strong>查看终端设备 →</strong><span>看在线状态,登记新盒子</span></router-link><router-link to="/help"><strong>桌面打不开 →</strong><span>检查设备、使用人和桌面连接条件</span></router-link></div></section>
<section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section> <section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section>
</template> </template>
<style scoped> <style scoped>
+13 -3
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Grid, List, Search, Plus, ArrowRight, Monitor } from '@element-plus/icons-vue' import { Grid, List, Search, Plus, ArrowRight, Monitor } from '@element-plus/icons-vue'
import { api, type Desktop, type DesktopAction, type DiscoveredVM, type ImportInput, type ConnectionInput } from '../api' import { ElMessage } from 'element-plus'
import { api, errorMessage, type Desktop, type DesktopAction, type DesktopReadiness, type DiscoveredVM, type ImportInput, type ConnectionInput } from '../api'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction' import { useAction } from '../composables/useAction'
@@ -18,9 +19,17 @@ const batchIds = ref<string[]>([]), batchAction = ref<DesktopAction | 'history'>
watch(() => data.desktops, values => { batchIds.value = batchIds.value.filter(id => values.some(d => d.id === id)) }) watch(() => data.desktops, values => { batchIds.value = batchIds.value.filter(id => values.some(d => d.id === id)) })
const hardwareDesktop = ref<Desktop>(), snapshotDesktop = ref<Desktop>() const hardwareDesktop = ref<Desktop>(), snapshotDesktop = ref<Desktop>()
const cloneVisible = ref(false), connectionDesktop = ref<Desktop>() const cloneVisible = ref(false), connectionDesktop = ref<Desktop>()
const readiness = ref<DesktopReadiness>(), readinessBusy = ref(false)
const connection = reactive<ConnectionInput>({ endpoint: '', width: 2560, height: 1440, fps: 30, initialized: false }) const connection = reactive<ConnectionInput>({ endpoint: '', width: 2560, height: 1440, fps: 30, initialized: false })
function openConnection(d: Desktop) { connectionDesktop.value = d; Object.assign(connection, { endpoint: d.endpoint, width: d.width, height: d.height, fps: d.fps, initialized: false }) } function openConnection(d: Desktop) { connectionDesktop.value = d; readiness.value = undefined; Object.assign(connection, { endpoint: d.endpoint, width: d.width, height: d.height, fps: d.fps, initialized: false }) }
async function saveConnection() { if (connectionDesktop.value && await run(() => api.configureDesktop(connectionDesktop.value!.id, { ...connection }), '连接配置已保存')) connectionDesktop.value = undefined } async function saveConnection() { if (connectionDesktop.value && await run(() => api.configureDesktop(connectionDesktop.value!.id, { ...connection }), '连接配置已保存')) connectionDesktop.value = undefined }
async function checkReadiness(d: Desktop) {
readinessBusy.value = true
try { readiness.value = await api.checkDesktopReadiness(d.id); await data.refresh(); ElMessage.success(readiness.value.ready ? '桌面已满足连接条件' : '检查完成,请按结果处理未通过项') }
catch (e) { ElMessage.error(errorMessage(e)) }
finally { readinessBusy.value = false }
}
watch(selectedId, () => { readiness.value = undefined })
const selected = computed(() => data.desktops.find(d => d.id === selectedId.value)) const selected = computed(() => data.desktops.find(d => d.id === selectedId.value))
const filtered = computed(() => data.desktops.filter(d => `${d.name} ${d.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === d.powerState))) const filtered = computed(() => data.desktops.filter(d => `${d.name} ${d.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === d.powerState)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10)) const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
@@ -51,12 +60,13 @@ async function submitImport() { if (!form.name.trim() || !form.node) return; if
<el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column v-if="auth.canOperate" label="选择" width="60"><template #default="{ row }"><el-checkbox :model-value="batchIds.includes(row.id)" @change="toggleDesktop(row.id)" :aria-label="`选择桌面 ${row.name}`" :disabled="batchIds.length >= 50 && !batchIds.includes(row.id)" /></template></el-table-column><el-table-column label="桌面 / VMID" min-width="175"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="secondary">VM {{ row.vmid }} · {{ row.provider === 'demo' ? '演示资源' : 'Proxmox' }}</div></template></el-table-column><el-table-column prop="os" label="系统" width="100" /><el-table-column label="节点 / 池" min-width="150"><template #default="{ row }">{{ row.node }}<div class="secondary">{{ row.pool || '未入池' }}</div></template></el-table-column><el-table-column label="电源 / 连接服务" width="155"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary" style="margin-top:5px">连接服务:{{ row.setupRequired ? '待初始化' : row.serviceState === 'ready' ? '就绪' : '未检测' }}</div></template></el-table-column><el-table-column label="操作" min-width="200"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || activeTask(row)" @click="action(row, row.powerState === 'stopped' ? 'start' : 'shutdown')">{{ row.powerState === 'stopped' ? '开机' : '关机' }}</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || row.powerState !== 'running' || activeTask(row)" @click="action(row, 'reboot')">重启</el-button><el-tag v-if="activeTask(row)" size="small">任务中</el-tag></div></template></el-table-column></el-table> <el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column v-if="auth.canOperate" label="选择" width="60"><template #default="{ row }"><el-checkbox :model-value="batchIds.includes(row.id)" @change="toggleDesktop(row.id)" :aria-label="`选择桌面 ${row.name}`" :disabled="batchIds.length >= 50 && !batchIds.includes(row.id)" /></template></el-table-column><el-table-column label="桌面 / VMID" min-width="175"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="secondary">VM {{ row.vmid }} · {{ row.provider === 'demo' ? '演示资源' : 'Proxmox' }}</div></template></el-table-column><el-table-column prop="os" label="系统" width="100" /><el-table-column label="节点 / 池" min-width="150"><template #default="{ row }">{{ row.node }}<div class="secondary">{{ row.pool || '未入池' }}</div></template></el-table-column><el-table-column label="电源 / 连接服务" width="155"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary" style="margin-top:5px">连接服务:{{ row.setupRequired ? '待初始化' : row.serviceState === 'ready' ? '就绪' : '未检测' }}</div></template></el-table-column><el-table-column label="操作" min-width="200"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || activeTask(row)" @click="action(row, row.powerState === 'stopped' ? 'start' : 'shutdown')">{{ row.powerState === 'stopped' ? '开机' : '关机' }}</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || row.powerState !== 'running' || activeTask(row)" @click="action(row, 'reboot')">重启</el-button><el-tag v-if="activeTask(row)" size="small">任务中</el-tag></div></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /> <el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" />
</section> </section>
<el-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'desktop', targetId: selected.id } }">查看此桌面关联诊断 →</router-link><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.setupRequired ? '待初始化,暂不能连接' : selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">从模板创建后需完成系统初始化和 Sunshine 配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer> <el-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'desktop', targetId: selected.id } }">查看此桌面关联诊断 →</router-link><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.setupRequired ? '待初始化,暂不能连接' : selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><div class="readiness-box"><div class="readiness-head"><strong>连接就绪检查</strong><el-tag v-if="readiness" :type="readiness.ready ? 'success' : 'warning'">{{ readiness.ready ? '可连接' : '未就绪' }}</el-tag></div><p v-if="!readiness" class="subtext">检查真实电源状态、桌面地址、初始化确认和 Sunshine serverinfo。</p><ul v-else class="readiness-list"><li v-for="check in readiness.checks" :key="check.name"><el-tag size="small" :type="check.status === 'pass' ? 'success' : check.status === 'unknown' ? 'info' : 'danger'">{{ check.status === 'pass' ? '通过' : check.status === 'unknown' ? '待确认' : '未通过' }}</el-tag><span>{{ check.message }}</span></li></ul><el-button v-if="auth.canOperate" :loading="readinessBusy" @click="checkReadiness(selected)">检测连接就绪</el-button></div><p class="subtext" style="margin:20px 0">从模板创建后需完成系统身份、硬件编码、Sunshine 服务与终端配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer>
<el-dialog v-model="importVisible" title="纳管已有 VM" width="520px"><el-form label-position="top" @submit.prevent="submitImport"><el-form-item label="选择 VM" required><el-select v-model="vmKey" placeholder="选择节点和虚拟机" @change="chooseVM"><el-option v-for="v in discovered" :key="`${v.node}/${v.vmid}`" :value="`${v.node}/${v.vmid}`" :label="`${v.node} / ${v.vmid} / ${v.name}`" /></el-select></el-form-item><el-form-item label="桌面名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in (['Windows', 'Ubuntu', '麒麟'] as const)" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="桌面连接地址(可后续补充)"><el-input v-model="form.endpoint" placeholder="VM 的 IP 或主机名" /></el-form-item><p class="subtext">纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="importVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.node || !form.name.trim()">确认纳管</el-button></div></el-form></el-dialog> <el-dialog v-model="importVisible" title="纳管已有 VM" width="520px"><el-form label-position="top" @submit.prevent="submitImport"><el-form-item label="选择 VM" required><el-select v-model="vmKey" placeholder="选择节点和虚拟机" @change="chooseVM"><el-option v-for="v in discovered" :key="`${v.node}/${v.vmid}`" :value="`${v.node}/${v.vmid}`" :label="`${v.node} / ${v.vmid} / ${v.name}`" /></el-select></el-form-item><el-form-item label="桌面名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in (['Windows', 'Ubuntu', '麒麟'] as const)" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="桌面连接地址(可后续补充)"><el-input v-model="form.endpoint" placeholder="VM 的 IP 或主机名" /></el-form-item><p class="subtext">纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="importVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.node || !form.name.trim()">确认纳管</el-button></div></el-form></el-dialog>
<DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" /> <DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" />
<el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog> <el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog>
</template> </template>
<style scoped> <style scoped>
.readiness-box{margin:20px 0;padding:16px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2)}.readiness-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.readiness-list{display:grid;gap:9px;margin:14px 0;padding:0;list-style:none}.readiness-list li{display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--vdi-muted);line-height:1.5}.readiness-list .el-tag{flex-shrink:0}
.desktop-batch-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 20px;font-size:12px}.desktop-batch-bar .el-button{margin:0}.desktop-select{position:absolute;top:2px;right:8px;z-index:2}.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px} .desktop-batch-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 20px;font-size:12px}.desktop-batch-bar .el-button{margin:0}.desktop-select{position:absolute;top:2px;right:8px;z-index:2}.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px}
@media(min-width:1700px){.desktop-grid{grid-template-columns:repeat(4,minmax(0,1fr))}} @media(min-width:1700px){.desktop-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:1200px){.desktop-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1200px){.desktop-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+4 -1
View File
@@ -27,6 +27,9 @@ async function load() {
finally { if (version === generation) busy.value = false } finally { if (version === generation) busy.value = false }
} }
const owners = (gpu: GPUItem) => [...new Set(gpu.references.map(r => `${r.vmName || 'VM'} · ${r.vmid}`))].join('、') const owners = (gpu: GPUItem) => [...new Set(gpu.references.map(r => `${r.vmName || 'VM'} · ${r.vmid}`))].join('、')
const exclusiveLabel = computed(() => !exclusive.value ? '' : exclusive.value.allocationReady ? '可分配' : exclusive.value.conflicts.length ? '需处理冲突' : '当前占用')
const memberLabel = (member: GPUExclusiveStatus['members'][number]) => member.eligible ? '可用' : member.conflicts.some(item => item.includes('仍在运行')) ? '占用中' : '冲突'
const memberTone = (member: GPUExclusiveStatus['members'][number]) => member.eligible ? 'success' : member.conflicts.some(item => item.includes('仍在运行')) ? 'info' : 'warning'
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div> <div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div>
@@ -35,7 +38,7 @@ const owners = (gpu: GPUItem) => [...new Set(gpu.references.map(r => `${r.vmName
<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> <section class="panel gpu-controls"><div class="toolbar"><el-select v-model="node" aria-label="GPU 计算节点" placeholder="选择计算节点"><el-option v-for="n in nodes" :key="n.id" :value="n.id" :label="n.name" /></el-select><el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索显卡、PCI 或虚拟机" aria-label="搜索 GPU" /><el-select v-model="state" clearable placeholder="全部引用状态" aria-label="GPU 引用状态"><el-option v-for="(label, value) in labels" :key="value" :value="value" :label="label" /></el-select></div><p class="secondary">配置引用包含关机虚拟机、模板和待应用变更。健康数据来自 PVE PCI 清单;标准 PVE API 未提供温度、利用率和显存时显示“未采集”。</p></section>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert> <el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert>
<template v-if="inventory"> <template v-if="inventory">
<section v-if="exclusive?.enabled" class="panel gpu-exclusive"><div class="gpu-exclusive-heading"><div><h2>独占资源状态</h2><p class="subtext">分配前检查与占用释放条件</p></div><el-tag :type="exclusive.allocationReady ? 'success' : 'warning'">{{ exclusive.allocationReady ? '可分配' : '需处理冲突' }}</el-tag></div><div class="gpu-exclusive-summary"><span>锁 {{ exclusive.lockAvailable ? '可用' : '占用' }}</span><span>成员 {{ exclusive.members.length }}/{{ exclusive.vmids.length }}</span><span>释放 {{ exclusive.releaseReady ? '可检查' : '不可释放' }}</span></div><el-alert v-if="exclusive.conflicts.length" type="warning" :closable="false" title="当前冲突"><ul class="gpu-conflicts"><li v-for="(item, index) in exclusive.conflicts" :key="index">{{ item }}</li></ul></el-alert><el-table :data="exclusive.members" size="small" style="margin-top:14px"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column prop="vmid" label="VMID" width="80" /><el-table-column label="实际电源" width="110"><template #default="scope"><el-tag :type="scope.row.powerObserved ? (scope.row.powerState === 'stopped' ? 'success' : 'warning') : 'info'">{{ scope.row.powerObserved ? scope.row.powerState : '未采集' }}</el-tag></template></el-table-column><el-table-column label="会话 / 任务" width="110"><template #default="scope">{{ scope.row.activeSessions }} / {{ scope.row.activeTasks }}</template></el-table-column><el-table-column label="分配" width="90"><template #default="scope"><el-tag :type="scope.row.eligible ? 'success' : 'warning'">{{ scope.row.eligible ? '可用' : '冲突' }}</el-tag></template></el-table-column></el-table><p class="secondary gpu-release-note">释放仅表示当前无会话和未完成任务,平台不会自动关机或重放切换操作;需从终端准备或任务核对流程完成实际变更。</p></section> <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' : exclusive.conflicts.length ? 'warning' : 'info'">{{ exclusiveLabel }}</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="memberTone(scope.row)">{{ memberLabel(scope.row) }}</el-tag></template></el-table-column></el-table><p class="secondary gpu-release-note">释放仅表示当前无会话和未完成任务,平台不会自动关机或重放切换操作;需从终端准备或任务核对流程完成实际变更。</p></section>
<div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div> <div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div>
<section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section> <section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section>
<div v-if="items.length" class="gpu-grid"><article v-for="gpu in items" :key="gpu.id" class="panel gpu-card"><div class="gpu-card-top"><div class="gpu-symbol"><el-icon :size="26"><Cpu /></el-icon></div><el-tag :type="tones[gpu.state]">{{ labels[gpu.state] }}</el-tag></div><p class="eyebrow">{{ gpu.vendor || gpu.vendorId }}</p><h2>{{ gpu.name || '显卡 ' + gpu.deviceId }}</h2><dl><div><dt>PCI 地址</dt><dd class="mono">{{ gpu.address }}</dd></div><div><dt>节点</dt><dd>{{ inventory.node }}</dd></div><div><dt>IOMMU 分组</dt><dd>{{ gpu.iommuGroup === null ? '未报告' : gpu.iommuGroup }}</dd></div><div><dt>驱动绑定</dt><dd><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> <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>
+11 -6
View File
@@ -8,7 +8,7 @@ import SystemIcon from '../components/SystemIcon.vue'
import CloneDesktopDialog from '../components/CloneDesktopDialog.vue' import CloneDesktopDialog from '../components/CloneDesktopDialog.vue'
const auth = useAuth(), items = ref<ImageTemplate[]>([]), discovered = ref<DiscoveredVM[]>([]) const auth = useAuth(), items = ref<ImageTemplate[]>([]), discovered = ref<DiscoveredVM[]>([])
const loading = ref(false), busy = ref(false), error = ref(''), discoverError = ref(''), visible = ref(false), cloneVisible = ref(false), templateId = ref(''), keyword = ref(''), vmKey = ref('') const loading = ref(false), busy = ref(false), error = ref(''), discoverError = ref(''), visible = ref(false), cloneVisible = ref(false), templateId = ref(''), keyword = ref(''), vmKey = ref('')
const form = reactive<TemplateInput>({ name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000 }) const form = reactive<TemplateInput>({ name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000, hardwareEncodingVerified: false })
const filtered = computed(() => items.value.filter(t => `${t.name} ${t.version} ${t.os} ${t.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()))) const filtered = computed(() => items.value.filter(t => `${t.name} ${t.version} ${t.os} ${t.vmid}`.toLowerCase().includes(keyword.value.toLowerCase())))
const candidates = computed(() => discovered.value.filter(v => !items.value.some(t => t.node === v.node && t.vmid === v.vmid))) const candidates = computed(() => discovered.value.filter(v => !items.value.some(t => t.node === v.node && t.vmid === v.vmid)))
async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.templates); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } } async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.templates); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
@@ -17,7 +17,7 @@ onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
async function openRegister() { async function openRegister() {
visible.value = true; discoverError.value = ''; discovered.value = []; vmKey.value = ''; busy.value = true visible.value = true; discoverError.value = ''; discovered.value = []; vmKey.value = ''; busy.value = true
Object.assign(form, { name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000 }) Object.assign(form, { name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000, hardwareEncodingVerified: false })
try { discovered.value = await allPages(api.discoverTemplates) } catch (e) { discoverError.value = errorMessage(e) } finally { busy.value = false } try { discovered.value = await allPages(api.discoverTemplates) } catch (e) { discoverError.value = errorMessage(e) } finally { busy.value = false }
} }
function choose() { const vm = candidates.value.find(v => `${v.node}/${v.vmid}` === vmKey.value); if (vm) Object.assign(form, { node: vm.node, vmid: vm.vmid, name: vm.name }) } function choose() { const vm = candidates.value.find(v => `${v.node}/${v.vmid}` === vmKey.value); if (vm) Object.assign(form, { node: vm.node, vmid: vm.vmid, name: vm.name }) }
@@ -28,7 +28,12 @@ async function register() {
} }
async function update(item: ImageTemplate, validate = false) { async function update(item: ImageTemplate, validate = false) {
busy.value = true busy.value = true
try { if (validate) await api.validateTemplate(item.id); else await api.updateTemplate(item.id, !item.enabled); ElMessage.success(validate ? '模板配置已重新验证' : '模板状态已更新'); await load() } try { if (validate) await api.validateTemplate(item.id); else await api.updateTemplate(item.id, { enabled: !item.enabled }); ElMessage.success(validate ? '模板配置已重新验证' : '模板状态已更新'); await load() }
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
}
async function toggleEncoding(item: ImageTemplate) {
busy.value = true
try { await api.updateTemplate(item.id, { hardwareEncodingVerified: !item.hardwareEncodingVerified }); ElMessage.success(item.hardwareEncodingVerified ? '已取消硬件编码验收' : '已确认硬件编码'); await load() }
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false } catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
} }
function clone(item: ImageTemplate) { templateId.value = item.id; cloneVisible.value = true } function clone(item: ImageTemplate) { templateId.value = item.id; cloneVisible.value = true }
@@ -41,15 +46,15 @@ function clone(item: ImageTemplate) { templateId.value = item.id; cloneVisible.v
<el-table :data="filtered" empty-text="尚未登记模板。请先在 PVE 制作专用模板,再点击登记模板。"> <el-table :data="filtered" empty-text="尚未登记模板。请先在 PVE 制作专用模板,再点击登记模板。">
<el-table-column label="模板 / 版本" min-width="200"><template #default="{ row }"><div class="template-title"><SystemIcon :os="row.os" :size="30" /><div><strong>{{ row.name }}</strong><div class="secondary">{{ row.version }} · {{ row.os }}</div></div></div></template></el-table-column> <el-table-column label="模板 / 版本" min-width="200"><template #default="{ row }"><div class="template-title"><SystemIcon :os="row.os" :size="30" /><div><strong>{{ row.name }}</strong><div class="secondary">{{ row.version }} · {{ row.os }}</div></div></div></template></el-table-column>
<el-table-column prop="node" label="源节点" min-width="120" /><el-table-column prop="vmid" label="模板 VMID" width="120" /> <el-table-column prop="node" label="源节点" min-width="120" /><el-table-column prop="vmid" label="模板 VMID" width="120" />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column> <el-table-column label="状态" min-width="150"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag><el-tag v-if="row.hardwareEncodingVerified" type="success" effect="plain" style="margin-left:6px">硬件编码已验收</el-tag><el-tag v-else type="warning" effect="plain" style="margin-left:6px">待验收</el-tag></template></el-table-column>
<el-table-column label="最近校验" min-width="180"><template #default="{ row }">{{ new Date(row.validatedAt).toLocaleString('zh-CN') }}</template></el-table-column> <el-table-column label="最近校验" min-width="180"><template #default="{ row }">{{ new Date(row.validatedAt).toLocaleString('zh-CN') }}</template></el-table-column>
<el-table-column v-if="auth.canAdmin" label="操作" min-width="255"><template #default="{ row }"><el-button link type="primary" :disabled="busy || !row.enabled" @click="clone(row)">创建桌面</el-button><el-button link :disabled="busy" @click="update(row, true)">重新验证</el-button><el-button link :disabled="busy" @click="update(row)">{{ row.enabled ? '停用' : '启用' }}</el-button></template></el-table-column> <el-table-column v-if="auth.canAdmin" label="操作" min-width="330"><template #default="{ row }"><el-button link type="primary" :disabled="busy || !row.enabled || !row.hardwareEncodingVerified" @click="clone(row)">创建桌面</el-button><el-button link :disabled="busy" @click="update(row, true)">重新验证</el-button><el-button link :disabled="busy" @click="toggleEncoding(row)">{{ row.hardwareEncodingVerified ? '取消编码验收' : '确认硬件编码' }}</el-button><el-button link :disabled="busy" @click="update(row)">{{ row.enabled ? '停用' : '启用' }}</el-button></template></el-table-column>
</el-table> </el-table>
<p class="catalog-note">模板登记会校验 PVE 模板标记、磁盘与直通配置。模板修改后需重新验证;停用会阻止新的创建请求。现有业务虚拟机不会自动转换为模板。</p> <p class="catalog-note">模板登记会校验 PVE 模板标记、磁盘与直通配置。模板修改后需重新验证;停用会阻止新的创建请求。现有业务虚拟机不会自动转换为模板。</p>
</section> </section>
<el-dialog v-model="visible" title="登记镜像模板" width="560px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="登记镜像模板" width="560px" :close-on-click-modal="false">
<el-alert v-if="discoverError" type="error" :title="discoverError" :closable="false" /><el-alert v-else-if="!busy && !candidates.length" type="info" title="未发现可登记的模板,请先在 PVE 准备专用模板;现有运行桌面不能直接作为模板登记。" :closable="false" /> <el-alert v-if="discoverError" type="error" :title="discoverError" :closable="false" /><el-alert v-else-if="!busy && !candidates.length" type="info" title="未发现可登记的模板,请先在 PVE 准备专用模板;现有运行桌面不能直接作为模板登记。" :closable="false" />
<el-form label-position="top" :disabled="busy"><el-form-item label="PVE 模板"><el-select v-model="vmKey" placeholder="选择发现的模板" filterable @change="choose"><el-option v-for="v in candidates" :key="`${v.node}/${v.vmid}`" :label="`${v.name} · ${v.node} / ${v.vmid}`" :value="`${v.node}/${v.vmid}`" /></el-select></el-form-item><el-form-item label="模板名称"><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="版本"><el-input v-model="form.version" maxlength="40" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in ['Windows', 'Ubuntu', '麒麟']" :key="os" :value="os" /></el-select></el-form-item></el-form> <el-form label-position="top" :disabled="busy"><el-form-item label="PVE 模板"><el-select v-model="vmKey" placeholder="选择发现的模板" filterable @change="choose"><el-option v-for="v in candidates" :key="`${v.node}/${v.vmid}`" :label="`${v.name} · ${v.node} / ${v.vmid}`" :value="`${v.node}/${v.vmid}`" /></el-select></el-form-item><el-form-item label="模板名称"><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="版本"><el-input v-model="form.version" maxlength="40" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in ['Windows', 'Ubuntu', '麒麟']" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="模板能力验收"><el-checkbox v-model="form.hardwareEncodingVerified">已验证硬件编码可用(Sunshine 使用 GPU 硬件编码)</el-checkbox><p class="subtext">只有完成硬件编码验收的模板才允许创建新桌面。Windows、Ubuntu 和麒麟均按同一规则处理。</p></el-form-item></el-form>
<template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!vmKey || !form.name.trim() || !form.version.trim()" @click="register">校验并登记</el-button></template> <template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!vmKey || !form.name.trim() || !form.version.trim()" @click="register">校验并登记</el-button></template>
</el-dialog> </el-dialog>
<CloneDesktopDialog v-model="cloneVisible" :template-id="templateId" /> <CloneDesktopDialog v-model="cloneVisible" :template-id="templateId" />
+12 -11
View File
@@ -20,35 +20,36 @@ const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.val
const terminalName = (id: string | null) => data.terminals.find(t => t.id === id)?.name || '未关联' const terminalName = (id: string | null) => data.terminals.find(t => t.id === id)?.name || '未关联'
const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || id const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || id
function edit(u: User) { selectedId.value = u.id; binding.terminalId = u.terminalId; binding.desktopIds = [...u.desktopIds]; binding.enabled = u.enabled; bindingVisible.value = true } function edit(u: User) { selectedId.value = u.id; binding.terminalId = u.terminalId; binding.desktopIds = [...u.desktopIds]; binding.enabled = u.enabled; bindingVisible.value = true }
async function create() { if (!createForm.username.trim() || !createForm.displayName.trim()) return; if (await run(() => api.createUser(createForm), '用户已创建')) { createVisible.value = false; createForm.username = ''; createForm.displayName = '' } } async function create() { if (!createForm.username.trim() || !createForm.displayName.trim()) return; if (await run(() => api.createUser(createForm), '员工已创建')) { createVisible.value = false; createForm.username = ''; createForm.displayName = '' } }
async function save() { if (busy.value) return; if (!await confirm('保存用户授权', '更新关联终端、用户状态和所选桌面。未消费的旧连接票据将失效;已有串流不会被强制中断。')) return; if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false } async function save() { if (busy.value) return; if (!await confirm('保存员工授权', '更新关联终端、员工状态和所选桌面。未消费的旧连接票据将失效;已有串流不会被强制中断。')) return; if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false }
const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id) const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id)
function editProfile(u: User) { profileId.value = u.id; profileUsername.value = u.username; Object.assign(profile, { displayName: u.displayName, enabled: u.enabled }); profileVisible.value = true } function editProfile(u: User) { profileId.value = u.id; profileUsername.value = u.username; Object.assign(profile, { displayName: u.displayName, enabled: u.enabled }); profileVisible.value = true }
async function saveProfile() { async function saveProfile() {
if (!profile.displayName.trim() || busy.value) return if (!profile.displayName.trim() || busy.value) return
const existing = data.users.find(u => u.id === profileId.value) const existing = data.users.find(u => u.id === profileId.value)
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用用户' : '停用用户', '状态变更将作废该用户未消费的连接票据。停用阻止后续连接,保留授权关系和已有串流。')) return if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更将作废该员工未消费的连接票据。停用阻止后续连接,保留授权关系和已有串流。')) return
if (await run(() => api.updateUser(profileId.value, profile), '用户资料已保存')) profileVisible.value = false if (await run(() => api.updateUser(profileId.value, profile), '员工资料已保存')) profileVisible.value = false
} }
function selectUsers(users: User[]) { checked.value = users } function selectUsers(users: User[]) { checked.value = users }
function openBatch() { batchUsers.value = [...checked.value]; batch.action = 'grant'; batch.desktopIds = []; batchVisible.value = true } function openBatch() { batchUsers.value = [...checked.value]; batch.action = 'grant'; batch.desktopIds = []; batchVisible.value = true }
async function saveBatch() { async function saveBatch() {
if (!batchUsers.value.length || !batch.desktopIds.length || busy.value) return if (!batchUsers.value.length || !batch.desktopIds.length || busy.value) return
const action = batch.action === 'grant' ? '分配' : '撤销' const action = batch.action === 'grant' ? '分配' : '撤销'
if (!await confirm(`批量${action}桌面`, `将为 ${batchUsers.value.length} 位用户${action}所选 ${batch.desktopIds.length} 台桌面的授权。其他授权保留,已有串流不会被强制中断。`)) return if (!await confirm(`批量${action}桌面`, `将为 ${batchUsers.value.length} 位员工${action}所选 ${batch.desktopIds.length} 台桌面的授权。其他授权保留,已有串流不会被强制中断。`)) return
if (await run(() => api.batchBindings({ userIds: batchUsers.value.map(u => u.id), desktopIds: batch.desktopIds, action: batch.action }), `已批量${action}桌面授权`)) batchVisible.value = false if (await run(() => api.batchBindings({ userIds: batchUsers.value.map(u => u.id), desktopIds: batch.desktopIds, action: batch.action }), `已批量${action}桌面授权`)) batchVisible.value = false
} }
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>用户与授权</h1><p class="subtext">每位用户可获得多个桌面;每台终端关联一位使用者</p></div><div v-if="auth.canAdmin" class="actions"><el-button @click="csvKind = 'users'; csvVisible = true">导入用户</el-button><el-button @click="csvKind = 'bindings'; csvVisible = true">导入授权</el-button><el-button :disabled="!checked.length || busy" @click="openBatch">批量授权<span v-if="checked.length">({{ checked.length }})</span></el-button><el-button type="primary" @click="createVisible = true">新建用户</el-button></div></div> <div class="page-heading"><div><h1>员工与授权</h1><p class="subtext">管理员工资料、终端关联和桌面使用授权</p></div><div v-if="auth.canAdmin" class="actions"><el-button @click="csvKind = 'users'; csvVisible = true">导入员工</el-button><el-button @click="csvKind = 'bindings'; csvVisible = true">导入授权</el-button><el-button :disabled="!checked.length || busy" @click="openBatch">批量授权<span v-if="checked.length">({{ checked.length }})</span></el-button><el-button type="primary" @click="createVisible = true">新建员工</el-button></div></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 用户账号" aria-label="搜索用户" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" row-key="id" empty-text="暂无终端用户" @selection-change="selectUsers"><el-table-column v-if="auth.canAdmin" type="selection" width="44" /><el-table-column label="用户 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="185"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理桌面使用者。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p> <section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 员工账号" aria-label="搜索员工" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" row-key="id" empty-text="暂无员工" @selection-change="selectUsers"><el-table-column v-if="auth.canAdmin" type="selection" width="44" /><el-table-column label="员工 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="220"><template #default="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="busy" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑授权</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理云桌面员工。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p>
<el-dialog v-model="createVisible" title="新建终端用户" width="480px"><el-form label-position="top" @submit.prevent="create"><el-form-item label="姓名" required><el-input v-model="createForm.displayName" maxlength="80" /></el-form-item><el-form-item label="用户账号" required><el-input v-model="createForm.username" maxlength="80" /></el-form-item><p class="subtext">首版使用设备绑定模式,此账号用于授权关系,不是桌面系统密码。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :disabled="!createForm.username.trim() || !createForm.displayName.trim()" :loading="busy">创建用户</el-button></div></el-form></el-dialog> <el-dialog v-model="createVisible" title="新建员工" width="480px"><el-form label-position="top" @submit.prevent="create"><el-form-item label="姓名" required><el-input v-model="createForm.displayName" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="createForm.username" maxlength="80" /></el-form-item><p class="subtext">首版使用设备绑定模式,此账号用于授权关系,不是桌面系统密码。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :disabled="!createForm.username.trim() || !createForm.displayName.trim()" :loading="busy">创建员工</el-button></div></el-form></el-dialog>
<el-dialog v-model="profileVisible" title="编辑用户资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="用户账号"><el-input :model-value="profileUsername" disabled /></el-form-item><el-form-item label="姓名"><el-input v-model="profile.displayName" maxlength="80" aria-label="编辑用户姓名" /></el-form-item><el-form-item label="用户状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑用户启用状态" /></el-form-item><p class="subtext">登录名固定;停用保留桌面授权,阻止后续连接。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存用户资料</el-button></template></el-dialog> <el-dialog v-model="profileVisible" title="编辑员工资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="员工账号(固定)"><el-input :model-value="profileUsername" disabled readonly /></el-form-item><el-form-item label="姓名"><el-input v-model="profile.displayName" maxlength="80" aria-label="编辑员工姓名" /></el-form-item><el-form-item label="员工状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑员工启用状态" /></el-form-item><p class="subtext">员工账号用于授权关系,创建后不能修改;停用保留桌面授权,阻止后续连接。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存员工资料</el-button></template></el-dialog>
<el-dialog v-model="batchVisible" title="批量桌面授权" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy"><el-form-item label="操作方式"><el-radio-group v-model="batch.action" aria-label="批量授权操作"><el-radio-button value="grant">追加分配</el-radio-button><el-radio-button value="revoke">撤销指定</el-radio-button></el-radio-group></el-form-item><el-form-item :label="`目标用户(${batchUsers.length} 位)`"><div class="batch-users"><el-tag v-for="u in batchUsers" :key="u.id" type="info">{{ u.displayName }} / {{ u.username }}</el-tag></div></el-form-item><el-form-item label="所选桌面"><el-checkbox-group v-model="batch.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><p class="subtext">只追加或撤销所选桌面,保留其他授权;停用用户仍需启用后才能连接。每次最多 100 位用户和 100 台桌面。</p></el-form><template #footer><el-button :disabled="busy" @click="batchVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!batchUsers.length || !batch.desktopIds.length || batchUsers.length > 100" @click="saveBatch">提交批量授权</el-button></template></el-dialog> <el-dialog v-model="batchVisible" title="批量桌面授权" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy"><el-form-item label="操作方式"><el-radio-group v-model="batch.action" aria-label="批量授权操作"><el-radio-button value="grant">追加分配</el-radio-button><el-radio-button value="revoke">撤销指定</el-radio-button></el-radio-group></el-form-item><el-form-item :label="`目标员工(${batchUsers.length} 位)`"><div class="batch-users"><el-tag v-for="u in batchUsers" :key="u.id" type="info">{{ u.displayName }} / {{ u.username }}</el-tag></div></el-form-item><el-form-item label="所选桌面"><el-checkbox-group v-model="batch.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><p class="subtext">只追加或撤销所选桌面,保留其他授权;停用员工仍需启用后才能连接。每次最多 100 位员工和 100 台桌面。</p></el-form><template #footer><el-button :disabled="busy" @click="batchVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!batchUsers.length || !batch.desktopIds.length || batchUsers.length > 100" @click="saveBatch">提交批量授权</el-button></template></el-dialog>
<el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" @submit.prevent="save" :disabled="busy"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id) || (t.enabled === false && t.id !== binding.terminalId)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="用户状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog> <el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" @submit.prevent="save" :disabled="busy"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id) || (t.enabled === false && t.id !== binding.terminalId)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="员工状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog>
<CsvImportDialog v-model="csvVisible" :kind="csvKind" @completed="data.refresh" /> <CsvImportDialog v-model="csvVisible" :kind="csvKind" @completed="data.refresh" />
</template> </template>
<style scoped> <style scoped>
.row-actions{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
.batch-users{display:flex;flex-wrap:wrap;gap:8px;max-height:140px;overflow:auto} .batch-users{display:flex;flex-wrap:wrap;gap:8px;max-height:140px;overflow:auto}
</style> </style>
+1
View File
@@ -37,6 +37,7 @@ services:
PVE_CA_FILE: /run/secrets/pve-ca.pem PVE_CA_FILE: /run/secrets/pve-ca.pem
VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-} VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-}
VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-} VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-}
VDI_GPU_AGENT_TOKEN: ${VDI_GPU_AGENT_TOKEN:-}
volumes: volumes:
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro - ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
depends_on: depends_on:
+30
View File
@@ -0,0 +1,30 @@
# 通用版固定场景助手
通用版助手面向非技术用户,使用“自然语言输入 + 受控业务脚本 + 确认执行”的方式工作。当前不依赖 AI 模型;后端先用固定场景解析,后续可以只替换解析层接入模型。
## 当前脚本
- 分配已有桌面
- 撤销桌面授权
- 桌面连接问题处理
- 设备与桌面状态查询入口
分配和撤销会先生成方案,要求员工和桌面唯一匹配;没有明确桌面时展示候选项,不再自动选择第一台空闲桌面。方案有效期 5 分钟,确认执行时后端会重新检查用户、桌面、授权、权限和并发状态。
## 后端接口
- `POST /api/v1/assistant/plan`:根据一句话生成方案
- `POST /api/v1/assistant/execute`:确认并执行方案
- `GET /api/v1/assistant/runs/{id}`:查看方案和执行结果
方案记录写入 `assistant_runs`,包含原始输入、结构化意图、状态、有效期、执行结果和操作者。执行沿用授权锁和审计日志;同一个方案重复提交会返回已保存结果,不会重复修改授权。
## 安全边界
助手只产生允许的业务动作,不能执行任意 Shell、SQL 或 PVE 命令。AI 接入后也只能输出受限结构化意图,最终权限判断和资源变更仍由平台后端完成。
## 验证
- Go 全量测试通过,包含缺少桌面时展示候选、明确方案执行、状态重新校验和重复确认幂等测试。
- 前端 typecheck、lint 和生产构建通过。
- 真实环境已验证:root 账号生成“撤销桌面授权”方案并执行,再生成“分配桌面”方案恢复原授权;重复提交同一方案返回已执行结果,没有重复写入。
@@ -2,9 +2,9 @@
## 已实现 ## 已实现
- `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标时返回 `status=not_collected` 和空值,不把 VM 配置引用当作健康数据。 - `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标;配置 `VDI_GPU_AGENT_TOKEN` 后,节点采集器通过 `POST /api/v1/gpu-agent/health` 上报近 10 分钟的真实指标,页面才显示 `status=collected`,没有上报时保持 `not_collected`。
- `GET /api/v1/resources/gpu-exclusive?node=...` 提供独占组分配前检查:advisory lock、成员完整性、PVE 实际电源、活跃终端会话、未完成任务、冲突原因、`allocationReady` 和 `releaseReady`。接口只读,不会关机、切换显卡或重放任务。 - `GET /api/v1/resources/gpu-exclusive?node=...` 提供独占组分配前检查:advisory lock、成员完整性、PVE 实际电源、活跃终端会话、未完成任务、冲突原因、`allocationReady` 和 `releaseReady`。接口只读,不会关机、切换显卡或重放任务。
- 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`,按终端和时间建立索引,保留 30 天,并通过 `GET /api/v1/terminal-logs` 支持终端、时间范围和关键词检索。沿用诊断脱敏;这是回传快照归档,不是实时日志流。 - 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`;终端正常运行时可通过设备令牌调用 `POST /api/v1/terminal/logs` 持续上报小批量事件,写入 `terminal_log_entries`。两类记录在 `GET /api/v1/terminal-logs` 统一检索,均按终端和时间建立索引、保留 30 天并执行脱敏。
- 高级版 GPU 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。 - 高级版 GPU 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。
## 真实边界 ## 真实边界
+24
View File
@@ -0,0 +1,24 @@
# GPU 切换偶发超时的自动核对
## 原因
2026-11-13 16:02(北京时间),Windows 10(VMID 100)的启动请求已被 PVE 接受,但查询原任务状态时 HTTP 超时。原任务实际以 `exitstatus=OK` 结束;旧逻辑把一次读取失败升级成持续阻塞,需要管理员手动核对。
## 行为
- PVE 任务状态和实际电源的 GET 查询,对网络暂时性故障及 HTTP 408/429/500/502/503/504 最多读取 3 次,间隔 250/500 毫秒,并遵守调用方取消和超时。权限错误不重试。
- 电源 POST 不自动重发。拿不到原任务 UPID 时保留阻塞,需要核查,避免重复关机或显卡争用。
- 后台在没有进行中的 GPU/创建操作时,间隔至少 15 秒选择一条待核对任务;恢复过程中仍持有独占组锁。每次核对最长 60 秒。
- 已知原任务结束后,检查独占组配置、全部成员电源、员工/终端授权和 Sunshine 响应。只有这些条件全部满足才自动完成为成功,并更新桌面状态、记录系统审计。
- 任务已结束但准备流程未完成,自动结束为失败并解除该任务的阻塞,用户可以重新选择桌面;后台不会补发后续电源操作。
- 仍无法确认电源、原任务未结束或发现多台成员同时运行时保持阻塞。最近 24 小时内具有 UPID 的不确定任务继续后台核对,过期任务保留供管理员处理。
- API 重启后发现的运行中 GPU 任务只恢复观察,不重放命令。尚未提交电源操作的任务结束为可重试失败。
- 前 5 分钟内,设备查询可自动核对任务时返回 `running`,让现有终端继续轮询。同一终端重新准备同一桌面复用原任务,不重复创建。
## 验证方式
测试使用独立 Docker PostgreSQL(`vdi-cloud-gpu-recovery-test-db`),不连接业务库;PVE 和 Sunshine 均使用测试 HTTP 服务。覆盖读取失败后恢复、不确定 POST 不重放、实际多 VM 冲突、原任务失败、连接服务未启动、授权撤销、配置变化、API 重启、同一任务复用和有限等待。
现场部署仅更新 API,无 VM 电源操作、终端重启或 GPU 重新绑定。回退镜像为 `vdi-cloud-live-api:before-gpu-recovery-20261113`。
验证结果:带专用数据库的 `go test ./... -count=1` 和 `go vet ./...` 均通过;GPU 故障注入及重启恢复用例通过。部署后真实 `/healthz` 返回生产模式 `ok`,独占组冲突为空,VM 100 保持运行,101/102 保持关机。临时测试数据库容器已清理。
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -14,10 +14,11 @@ type Config struct {
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
ExclusiveGPUNode string ExclusiveGPUNode string
ExclusiveGPUVMIDs []int ExclusiveGPUVMIDs []int
GPUAgentToken string
} }
func Load() (Config, error) { func Load() (Config, error) {
c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")} c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE"), GPUAgentToken: os.Getenv("VDI_GPU_AGENT_TOKEN")}
c.ExclusiveGPUNode = strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_NODE")) c.ExclusiveGPUNode = strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_NODE"))
ids := strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_VMIDS")) ids := strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_VMIDS"))
if (c.ExclusiveGPUNode == "") != (ids == "") { if (c.ExclusiveGPUNode == "") != (ids == "") {
+10 -9
View File
@@ -31,15 +31,16 @@ type Desktop struct {
} }
type ImageTemplate struct { type ImageTemplate struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Version string `json:"version" db:"version"` Version string `json:"version" db:"version"`
OS string `json:"os" db:"os"` OS string `json:"os" db:"os"`
Node string `json:"node" db:"node"` Node string `json:"node" db:"node"`
VMID int `json:"vmid" db:"vmid"` VMID int `json:"vmid" db:"vmid"`
Digest string `json:"-" db:"digest"` Digest string `json:"-" db:"digest"`
Enabled bool `json:"enabled" db:"enabled"` Enabled bool `json:"enabled" db:"enabled"`
ValidatedAt time.Time `json:"validatedAt" db:"validated_at"` HardwareEncodingVerified bool `json:"hardwareEncodingVerified" db:"hardware_encoding_verified"`
ValidatedAt time.Time `json:"validatedAt" db:"validated_at"`
} }
type DesktopPool struct { type DesktopPool struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
+12 -2
View File
@@ -43,7 +43,7 @@ func (s *Server) settings(c *gin.Context) {
if pve != nil { if pve != nil {
provider = "proxmox" provider = "proxmox"
} }
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"diagnostics-center", "admin-mfa", "administrator-sessions", "task-history", "audit-query-export", "terminal-metric-alerts", "terminal-system-metrics", "gpu-inventory", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}}) c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"common-assistant", "diagnostics-center", "admin-mfa", "administrator-sessions", "task-history", "audit-query-export", "terminal-metric-alerts", "terminal-system-metrics", "gpu-inventory", "gpu-health-agent", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}})
} }
func (s *Server) resources(c *gin.Context) { func (s *Server) resources(c *gin.Context) {
items := []model.Resource{} items := []model.Resource{}
@@ -514,6 +514,7 @@ func (s *Server) RunWorker(ctx context.Context) {
workerCtx, stop := context.WithCancel(ctx) workerCtx, stop := context.WithCancel(ctx)
provisionDone := make(chan struct{}, 1) provisionDone := make(chan struct{}, 1)
provisionBusy := false provisionBusy := false
var nextGPUCheck time.Time
defer func() { defer func() {
stop() stop()
if provisionBusy { if provisionBusy {
@@ -532,7 +533,8 @@ func (s *Server) RunWorker(ctx context.Context) {
if err = conn.PingContext(ctx); err != nil { if err = conn.PingContext(ctx); err != nil {
return return
} }
// Unknown outcomes are never automatically retried after a worker restart. // Unknown commands are never replayed. Known GPU tasks are observed
// separately below so a transient PVE read failure can recover.
var expired []model.Task var expired []model.Task
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop','gpu-prepare') AND created_at<now()-interval '2 minutes') OR (kind='gpu-prepare' AND created_at<now()-interval '5 minutes') OR (kind='batch-desktop' AND ((status='queued' AND created_at<now()-interval '2 hours') OR (status='running' AND updated_at<now()-interval '2 minutes'))) OR (kind IN ('provision','hardware','snapshot') AND updated_at<now()-interval '35 minutes'))") == nil { if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop','gpu-prepare') AND created_at<now()-interval '2 minutes') OR (kind='gpu-prepare' AND created_at<now()-interval '5 minutes') OR (kind='batch-desktop' AND ((status='queued' AND created_at<now()-interval '2 hours') OR (status='running' AND updated_at<now()-interval '2 minutes'))) OR (kind IN ('provision','hardware','snapshot') AND updated_at<now()-interval '35 minutes'))") == nil {
for _, t := range expired { for _, t := range expired {
@@ -563,6 +565,14 @@ func (s *Server) RunWorker(ctx context.Context) {
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放") _ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
} }
} }
if !provisionBusy && !time.Now().Before(nextGPUCheck) {
nextGPUCheck = time.Now().Add(15 * time.Second)
if recovery, readErr := s.nextGPURecovery(ctx); readErr == nil {
provisionBusy = true
go func(task model.Task) { s.recoverGPUPrepare(workerCtx, task); provisionDone <- struct{}{} }(recovery)
continue
}
}
var t model.Task var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE ((status='queued' AND kind!='terminal') OR (kind='provision' AND status='running' AND provider_ref!='') OR (kind IN ('hardware','snapshot','batch-desktop') AND status='running')) AND ($1 OR kind NOT IN ('provision','hardware','snapshot','batch-desktop','gpu-prepare')) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, !provisionBusy) err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE ((status='queued' AND kind!='terminal') OR (kind='provision' AND status='running' AND provider_ref!='') OR (kind IN ('hardware','snapshot','batch-desktop') AND status='running')) AND ($1 OR kind NOT IN ('provision','hardware','snapshot','batch-desktop','gpu-prepare')) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, !provisionBusy)
if err != nil { if err != nil {
+302
View File
@@ -0,0 +1,302 @@
package server
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
type assistantCandidate struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
}
type assistantPlan struct {
Action string `json:"action"`
Kind string `json:"kind"`
Title string `json:"title"`
Summary string `json:"summary"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
DesktopID string `json:"desktopId,omitempty"`
DesktopName string `json:"desktopName,omitempty"`
DesktopVMID int `json:"desktopVmid,omitempty"`
Missing []string `json:"missing"`
Candidates []assistantCandidate `json:"candidates"`
Risk string `json:"risk"`
ConfirmationRequired bool `json:"confirmationRequired"`
Path string `json:"path,omitempty"`
Reason string `json:"reason,omitempty"`
}
type assistantRun struct {
ID string `json:"id" db:"id"`
Input string `json:"input" db:"input_text"`
Action string `json:"action" db:"action"`
Status string `json:"status" db:"status"`
Plan assistantPlan `json:"plan"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
ExecutedAt *time.Time `json:"executedAt,omitempty" db:"executed_at"`
Result map[string]any `json:"result,omitempty" db:"-"`
}
func assistantTextMatch(text, value string) bool {
text, value = strings.ToLower(strings.TrimSpace(text)), strings.ToLower(strings.TrimSpace(value))
return value != "" && strings.Contains(text, value)
}
func (s *Server) assistantPlanHandler(c *gin.Context) {
var in struct {
Text string `json:"text" binding:"required,max=500"`
}
if !s.decode(c, &in) {
return
}
text := strings.TrimSpace(in.Text)
plan := assistantPlan{Kind: "blocked", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}, ConfirmationRequired: false}
revoke := strings.Contains(text, "撤销") || strings.Contains(text, "收回") || strings.Contains(text, "取消授权")
grant := !revoke && (strings.Contains(text, "分配") || strings.Contains(text, "授权") || strings.Contains(text, "安排") || strings.Contains(text, "开通"))
if !grant && !revoke {
if strings.Contains(text, "打不开") || strings.Contains(text, "无法连接") || strings.Contains(text, "连不上") || strings.Contains(text, "故障") {
plan = assistantPlan{Kind: "route", Action: "diagnose_desktop", Title: "进入桌面问题处理", Summary: "先检查终端在线状态、员工关联、桌面授权、电源和连接服务。", Path: "/help", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}}
} else if strings.Contains(text, "状态") || strings.Contains(text, "在线") || strings.Contains(text, "离线") {
plan = assistantPlan{Kind: "route", Action: "query_status", Title: "查看当前状态", Summary: "打开设备与桌面状态页面,查看在线设备、桌面电源和最近刷新时间。", Path: "/devices", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}}
} else {
plan.Reason = "暂不支持这个固定场景"
plan.Summary = "请描述分配、撤销、桌面连接问题或设备状态;复杂运维操作请使用高级版。"
}
} else {
ctx := c.Request.Context()
var users []model.User
var desktops []model.Desktop
if err := s.DB.SelectContext(ctx, &users, "SELECT * FROM users WHERE enabled ORDER BY display_name,id"); err != nil {
s.dbError(c, err)
return
}
if err := s.DB.SelectContext(ctx, &desktops, "SELECT * FROM desktops WHERE provider IN ('demo','proxmox') ORDER BY name,id"); err != nil {
s.dbError(c, err)
return
}
userMatches := []model.User{}
for _, user := range users {
if assistantTextMatch(text, user.DisplayName) || assistantTextMatch(text, user.Username) {
userMatches = append(userMatches, user)
}
}
if len(userMatches) != 1 {
plan.Missing = append(plan.Missing, "user")
for _, user := range userMatches {
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: user.ID, Label: user.DisplayName + " · " + user.Username, Kind: "user"})
}
if len(userMatches) == 0 {
for i, user := range users {
if i >= 20 {
break
}
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: user.ID, Label: user.DisplayName + " · " + user.Username, Kind: "user"})
}
}
plan.Title, plan.Summary, plan.Reason = "需要确认员工", "请写出员工姓名或账号;如果有同名员工,请选择准确对象。", "没有唯一匹配到员工"
} else {
user := userMatches[0]
desktopMatches := []model.Desktop{}
for _, desktop := range desktops {
if assistantTextMatch(text, desktop.Name) || strings.Contains(text, strings.TrimSpace(stringID(desktop.VMID))) {
desktopMatches = append(desktopMatches, desktop)
}
}
if len(desktopMatches) != 1 {
plan.Missing = append(plan.Missing, "desktop")
for _, desktop := range desktopMatches {
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"})
}
if len(desktopMatches) == 0 {
for i, desktop := range desktops {
if i >= 20 {
break
}
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"})
}
}
plan.Title, plan.Summary, plan.Reason = "需要确认桌面", "请写出桌面名称或 VMID,再继续生成方案。", "没有唯一匹配到桌面"
} else {
desktop := desktopMatches[0]
plan.UserID, plan.UserName, plan.DesktopID, plan.DesktopName, plan.DesktopVMID = user.ID, user.DisplayName, desktop.ID, desktop.Name, desktop.VMID
plan.Action, plan.Kind, plan.Risk, plan.ConfirmationRequired = map[bool]string{true: "grant_desktop", false: "revoke_desktop"}[grant], "action", "medium", true
if grant {
plan.Title, plan.Summary = "分配已有桌面", "为 "+user.DisplayName+" 追加“"+desktop.Name+"”使用权限。不会创建 VM、不会自动开机。"
var exists bool
if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if exists {
plan.Kind, plan.Reason, plan.Summary = "blocked", "授权已经存在", user.DisplayName+" 已经拥有“"+desktop.Name+"”的使用权限。"
}
} else {
plan.Title, plan.Summary = "撤销桌面授权", "撤销 "+user.DisplayName+" 对“"+desktop.Name+"”的使用权限,不会关机或中断已建立的连接。"
var exists bool
if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if !exists {
plan.Kind, plan.Reason, plan.Summary = "blocked", "授权不存在", user.DisplayName+" 当前没有“"+desktop.Name+"”的使用权限。"
}
}
}
}
}
planJSON, _ := json.Marshal(plan)
id := store.ID("assist_")
status := "planned"
if plan.Kind == "blocked" {
status = "blocked"
}
expires := time.Now().UTC().Add(5 * time.Minute)
fingerprint := sha256.Sum256(append([]byte(text+"|"+c.MustGet("admin").(model.Administrator).Username+"|"), planJSON...))
if _, err := s.DB.ExecContext(c.Request.Context(), `INSERT INTO assistant_runs(id,actor,input_text,action,status,plan,fingerprint,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, s.admin(c).Username, text, plan.Action, status, string(planJSON), hex.EncodeToString(fingerprint[:]), expires); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"runId": id, "status": status, "input": text, "plan": plan, "createdAt": time.Now().UTC(), "expiresAt": expires})
}
func stringID(value int) string { return strconv.Itoa(value) }
func (s *Server) assistantExecute(c *gin.Context) {
var in struct {
RunID string `json:"runId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var row struct {
ID string `db:"id"`
Input string `db:"input_text"`
Action string `db:"action"`
Status string `db:"status"`
Plan string `db:"plan"`
ExpiresAt time.Time `db:"expires_at"`
Result []byte `db:"result"`
}
if err = tx.GetContext(ctx, &row, "SELECT id,input_text,action,status,plan,expires_at,result FROM assistant_runs WHERE id=$1 AND actor=$2 FOR UPDATE", in.RunID, s.admin(c).Username); err != nil {
s.dbError(c, err)
return
}
if row.Status == "executed" {
result := map[string]any{}
_ = json.Unmarshal(row.Result, &result)
c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result, "idempotent": true})
return
}
if row.Status != "planned" {
s.fail(c, 409, "assistant_not_executable", "方案已经执行、阻止或失效")
return
}
if time.Now().UTC().After(row.ExpiresAt) {
_, _ = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='expired' WHERE id=$1", in.RunID)
s.fail(c, 409, "assistant_plan_expired", "方案已过期,请重新生成")
return
}
var plan assistantPlan
if json.Unmarshal([]byte(row.Plan), &plan) != nil || plan.Kind != "action" || (plan.Action != "grant_desktop" && plan.Action != "revoke_desktop") {
s.fail(c, 409, "assistant_invalid_plan", "方案内容无效,请重新生成")
return
}
if !s.lockAuthorization(c, tx) {
return
}
var user model.User
var desktop model.Desktop
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", plan.UserID); err != nil {
s.fail(c, 409, "assistant_state_changed", "员工已不存在或状态已变化,请重新生成方案")
return
}
if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", plan.DesktopID); err != nil {
s.fail(c, 409, "assistant_state_changed", "桌面已不存在或状态已变化,请重新生成方案")
return
}
var exists bool
if err = tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if plan.Action == "grant" || plan.Action == "grant_desktop" {
if exists {
s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作创建,请重新生成方案")
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) VALUES($1,$2)", user.ID, desktop.ID)
} else {
if !exists {
s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作撤销,请重新生成方案")
return
}
_, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=$2", user.ID, desktop.ID)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", user.ID)
}
if err == nil {
err = accountAudit(ctx, tx, s.admin(c).Username, "assistant_"+plan.Action, user.ID, c.GetString("requestId"))
}
if err != nil {
s.dbError(c, err)
return
}
result := map[string]any{"userId": user.ID, "desktopId": desktop.ID, "action": plan.Action, "message": "操作已保存"}
encoded, _ := json.Marshal(result)
if _, err = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='executed',executed_at=now(),result=$1 WHERE id=$2", string(encoded), in.RunID); err != nil {
s.dbError(c, err)
return
}
if err = tx.Commit(); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result})
}
func (s *Server) assistantRun(c *gin.Context) {
var row struct {
ID string `db:"id"`
Input string `db:"input_text"`
Action string `db:"action"`
Status string `db:"status"`
Plan string `db:"plan"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
ExecutedAt *time.Time `db:"executed_at"`
Result []byte `db:"result"`
}
query := "SELECT id,input_text,action,status,plan,created_at,expires_at,executed_at,result FROM assistant_runs WHERE id=$1"
args := []any{c.Param("id")}
if s.admin(c).Role != "super_admin" {
query += " AND actor=$2"
args = append(args, s.admin(c).Username)
}
if err := s.DB.GetContext(c.Request.Context(), &row, query, args...); err != nil {
s.dbError(c, err)
return
}
var plan assistantPlan
_ = json.Unmarshal([]byte(row.Plan), &plan)
result := map[string]any{}
_ = json.Unmarshal(row.Result, &result)
c.JSON(200, gin.H{"runId": row.ID, "input": row.Input, "action": row.Action, "status": row.Status, "plan": plan, "createdAt": row.CreatedAt, "expiresAt": row.ExpiresAt, "executedAt": row.ExecutedAt, "result": result})
}
@@ -0,0 +1,44 @@
package server
import "testing"
func TestAssistantPlanRequiresExplicitDesktopAndExecutesWithRecheck(t *testing.T) {
f := newFixtureWorker(t, false)
admin := f.login("admin")
code, out := f.request("POST", "/assistant/plan", admin, map[string]any{"text": "给王工分配一台桌面"}, "")
expect(t, 200, code, out)
if out["status"] != "blocked" {
t.Fatalf("unexpected plan status: %#v", out)
}
plan := out["plan"].(map[string]any)
if plan["kind"] != "blocked" || plan["reason"] != "没有唯一匹配到桌面" {
t.Fatalf("assistant selected a desktop without confirmation: %#v", plan)
}
code, out = f.request("POST", "/assistant/plan", admin, map[string]any{"text": "给王工分配国产办公桌面"}, "")
expect(t, 200, code, out)
if out["status"] != "planned" || out["plan"].(map[string]any)["kind"] != "action" {
t.Fatalf("unexpected executable plan: %#v", out)
}
runID := out["runId"].(string)
code, out = f.request("POST", "/assistant/execute", admin, map[string]any{"runId": runID}, "assistant-test-1")
expect(t, 200, code, out)
var exists bool
if err := f.db.Get(&exists, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id JOIN desktops d ON d.id=b.desktop_id WHERE u.display_name='王工' AND d.name='国产办公桌面')`); err != nil || !exists {
t.Fatalf("assistant did not create binding: %v", err)
}
code, out = f.request("POST", "/assistant/execute", admin, map[string]any{"runId": runID}, "assistant-test-2")
if code != 200 || out["idempotent"] != true {
t.Fatalf("assistant execution was not idempotent: %d %#v", code, out)
}
}
func TestAssistantPlanBlocksAmbiguousUser(t *testing.T) {
f := newFixtureWorker(t, false)
admin := f.login("admin")
code, out := f.request("POST", "/assistant/plan", admin, map[string]any{"text": "请分配研发办公桌面"}, "")
expect(t, 200, code, out)
plan := out["plan"].(map[string]any)
if plan["kind"] != "blocked" || plan["reason"] != "没有唯一匹配到员工" {
t.Fatalf("missing employee was not blocked: %#v", plan)
}
}
+17 -8
View File
@@ -48,11 +48,12 @@ func (s *Server) templateList(c *gin.Context) {
func (s *Server) registerTemplate(c *gin.Context) { func (s *Server) registerTemplate(c *gin.Context) {
pve := s.currentPVE() pve := s.currentPVE()
var in struct { var in struct {
Name string `json:"name" binding:"required,max=80"` Name string `json:"name" binding:"required,max=80"`
Version string `json:"version" binding:"required,max=40"` Version string `json:"version" binding:"required,max=40"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"` OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
Node string `json:"node" binding:"required,max=100"` Node string `json:"node" binding:"required,max=100"`
VMID int `json:"vmid" binding:"min=100,max=999999999"` VMID int `json:"vmid" binding:"min=100,max=999999999"`
HardwareEncodingVerified bool `json:"hardwareEncodingVerified"`
} }
if !s.decode(c, &in) { if !s.decode(c, &in) {
return return
@@ -82,7 +83,7 @@ func (s *Server) registerTemplate(c *gin.Context) {
return return
} }
var item model.ImageTemplate var item model.ImageTemplate
err = s.DB.GetContext(c.Request.Context(), &item, `INSERT INTO image_templates(id,name,version,os,node,vmid,digest) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, store.ID("tpl_"), in.Name, in.Version, in.OS, in.Node, in.VMID, digest) err = s.DB.GetContext(c.Request.Context(), &item, `INSERT INTO image_templates(id,name,version,os,node,vmid,digest,hardware_encoding_verified) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, store.ID("tpl_"), in.Name, in.Version, in.OS, in.Node, in.VMID, digest, in.HardwareEncodingVerified)
if conflict(err) { if conflict(err) {
s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记") s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记")
return return
@@ -96,13 +97,18 @@ func (s *Server) registerTemplate(c *gin.Context) {
} }
func (s *Server) updateTemplate(c *gin.Context) { func (s *Server) updateTemplate(c *gin.Context) {
var in struct { var in struct {
Enabled *bool `json:"enabled" binding:"required"` Enabled *bool `json:"enabled"`
HardwareEncodingVerified *bool `json:"hardwareEncodingVerified"`
} }
if !s.decode(c, &in) { if !s.decode(c, &in) {
return return
} }
if in.Enabled == nil && in.HardwareEncodingVerified == nil {
s.fail(c, 400, "invalid_request", "至少提供一个模板状态字段")
return
}
var item model.ImageTemplate var item model.ImageTemplate
if err := s.DB.GetContext(c.Request.Context(), &item, "UPDATE image_templates SET enabled=$1 WHERE id=$2 RETURNING *", *in.Enabled, c.Param("id")); err != nil { if err := s.DB.GetContext(c.Request.Context(), &item, `UPDATE image_templates SET enabled=COALESCE($1,enabled), hardware_encoding_verified=COALESCE($2,hardware_encoding_verified) WHERE id=$3 RETURNING *`, in.Enabled, in.HardwareEncodingVerified, c.Param("id")); err != nil {
s.dbError(c, err) s.dbError(c, err)
return return
} }
@@ -303,6 +309,9 @@ func (s *Server) clonePreflight(ctx context.Context, in cloneInput) (model.Image
if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1 AND enabled", in.TemplateID); err != nil { if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1 AND enabled", in.TemplateID); err != nil {
return template, errors.New("模板不存在或已停用") return template, errors.New("模板不存在或已停用")
} }
if !template.HardwareEncodingVerified {
return template, errors.New("模板尚未确认硬件编码,请先在镜像模板中完成硬件编码验收")
}
config, err := pve.VMConfig(ctx, template.Node, template.VMID) config, err := pve.VMConfig(ctx, template.Node, template.VMID)
if err != nil { if err != nil {
return template, errors.New("无法读取源模板配置") return template, errors.New("无法读取源模板配置")
+31 -1
View File
@@ -97,7 +97,7 @@ func fakeCatalogPVE(t *testing.T, f *fixture, outcome string) *catalogPVE {
} }
func registerCatalog(t *testing.T, f *fixture, admin string) (string, string) { func registerCatalog(t *testing.T, f *fixture, admin string) (string, string) {
t.Helper() t.Helper()
status, out := f.request("POST", "/templates", admin, map[string]any{"name": "Ubuntu base", "version": "1.0", "os": "Ubuntu", "node": "node-a", "vmid": 8000}, "") status, out := f.request("POST", "/templates", admin, map[string]any{"name": "Ubuntu base", "version": "1.0", "os": "Ubuntu", "node": "node-a", "vmid": 8000, "hardwareEncodingVerified": true}, "")
expect(t, 201, status, out) expect(t, 201, status, out)
tpl := out["id"].(string) tpl := out["id"].(string)
status, out = f.request("POST", "/pools", admin, map[string]any{"name": "Engineering", "type": "dedicated", "maxCount": 1, "templateId": tpl, "enabled": true}, "") status, out = f.request("POST", "/pools", admin, map[string]any{"name": "Engineering", "type": "dedicated", "maxCount": 1, "templateId": tpl, "enabled": true}, "")
@@ -218,6 +218,36 @@ func TestClonePreflightAndCatalogRBAC(t *testing.T) {
status, out = f.request("POST", "/desktops/clone", admin, body, "disabled") status, out = f.request("POST", "/desktops/clone", admin, body, "disabled")
expect(t, 409, status, out) expect(t, 409, status, out)
} }
func TestTemplateValidationRequiresHardwareReacceptanceAfterSourceChange(t *testing.T) {
f := newFixture(t)
p := fakeCatalogPVE(t, f, "success")
admin := f.login("admin")
tpl, _ := registerCatalog(t, f, admin)
status, out := f.request("POST", "/templates/"+tpl+"/validate", admin, nil, "")
expect(t, 200, status, out)
if out["hardwareEncodingVerified"] != true {
t.Fatal("unchanged template lost hardware encoding acceptance", out)
}
p.changed.Store(true)
status, out = f.request("POST", "/templates/"+tpl+"/validate", admin, nil, "")
expect(t, 200, status, out)
var digest string
if err := f.db.Get(&digest, "SELECT digest FROM image_templates WHERE id=$1", tpl); err != nil {
t.Fatal(err)
}
if out["hardwareEncodingVerified"] != false || digest != "v2" {
t.Fatal("changed template remained accepted", out)
}
status, out = f.request("POST", "/desktops/clone/check", admin, cloneBody(tpl, ""), "")
expect(t, 200, status, out)
if out["available"] != false {
t.Fatal("unverified changed template passed clone preflight", out)
}
}
func TestAmbiguousAndFailedCloneRetainPoolCapacity(t *testing.T) { func TestAmbiguousAndFailedCloneRetainPoolCapacity(t *testing.T) {
for _, outcome := range []string{"ambiguous", "failed"} { for _, outcome := range []string{"ambiguous", "failed"} {
t.Run(outcome, func(t *testing.T) { t.Run(outcome, func(t *testing.T) {
@@ -0,0 +1,121 @@
package server
import (
"context"
"database/sql"
"net"
"strings"
"time"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
)
type desktopReadinessCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
type desktopReadiness struct {
DesktopID string `json:"desktopId"`
ObservedAt time.Time `json:"observedAt"`
Ready bool `json:"ready"`
PowerState string `json:"powerState"`
ServiceState string `json:"serviceState"`
SetupRequired bool `json:"setupRequired"`
Checks []desktopReadinessCheck `json:"checks"`
}
// checkDesktopReadiness verifies the parts of the provisioning contract that
// the platform can observe without logging into the guest OS. It never starts
// or stops a VM and never changes guest configuration.
func (s *Server) checkDesktopReadiness(c *gin.Context) {
ctx := c.Request.Context()
var desktop model.Desktop
if err := s.DB.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
if err == sql.ErrNoRows {
s.fail(c, 404, "desktop_not_found", "桌面不存在")
} else {
s.dbError(c, err)
}
return
}
result := desktopReadiness{
DesktopID: desktop.ID, ObservedAt: time.Now().UTC(), Ready: false,
PowerState: desktop.PowerState, ServiceState: desktop.ServiceState,
SetupRequired: desktop.SetupRequired, Checks: []desktopReadinessCheck{},
}
add := func(name, status, message string) {
result.Checks = append(result.Checks, desktopReadinessCheck{Name: name, Status: status, Message: message})
}
if desktop.Provider != "proxmox" {
add("provider", "fail", "桌面不是 Proxmox 资源,无法执行真实连接探测")
c.JSON(200, result)
return
}
if desktop.Endpoint == "" {
add("endpoint", "fail", "尚未配置桌面 IP 或主机名")
} else {
add("endpoint", "pass", "已配置桌面连接地址")
}
if desktop.SetupRequired {
add("initialization", "fail", "尚未确认系统身份、硬件编码、Sunshine 和终端配对")
} else {
add("initialization", "pass", "已完成平台初始化确认")
}
pve := s.currentPVE()
if pve == nil {
add("power", "unknown", "尚未配置 Proxmox,无法读取真实电源状态")
} else {
power, err := pve.Power(ctx, desktop.Node, desktop.VMID)
if err != nil {
result.PowerState = "unknown"
add("power", "unknown", "无法读取 Proxmox 实际电源状态")
} else {
result.PowerState = power
if power == "running" {
add("power", "pass", "虚拟机正在运行")
} else {
add("power", "fail", "虚拟机未运行,请先开机")
}
}
}
serviceReady := false
if desktop.Endpoint != "" && result.PowerState == "running" {
serviceReady = sunshineReady(ctx, desktop.Endpoint)
if serviceReady {
result.ServiceState = "ready"
add("sunshine", "pass", "Sunshine serverinfo 已响应")
} else {
result.ServiceState = "unknown"
add("sunshine", "fail", "Sunshine 未响应,请检查服务、网络和硬件编码配置")
}
} else {
add("sunshine", "unknown", "虚拟机未运行或尚未配置连接地址,暂不探测 Sunshine")
}
result.Ready = !desktop.SetupRequired && result.PowerState == "running" && serviceReady
if _, err := s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state=$2,updated_at=now() WHERE id=$3", result.PowerState, result.ServiceState, desktop.ID); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, result)
}
// sunshineReadyAt is split out so the real probe remains fixed to Sunshine's
// serverinfo port while tests can use an isolated local listener.
func sunshineReadyAt(ctx context.Context, endpoint, port string) bool {
host := endpoint
if parsed, _, err := net.SplitHostPort(endpoint); err == nil {
host = parsed
}
if strings.ContainsAny(host, "/\\@?#") || host == "" {
return false
}
return sunshineHTTPProbe(ctx, host, port)
}
@@ -0,0 +1,38 @@
package server
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestSunshineReadyAt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/serverinfo" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<root status_code="200"><hostname>desktop-test</hostname></root>`))
}))
t.Cleanup(server.Close)
host, port, ok := splitTestEndpoint(t, server.URL)
if !ok || !sunshineReadyAt(context.Background(), host, port) {
t.Fatal("expected Sunshine probe to pass")
}
if sunshineReadyAt(context.Background(), host, "1") {
t.Fatal("unexpected Sunshine probe success on wrong port")
}
}
func splitTestEndpoint(t *testing.T, raw string) (string, string, bool) {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
return "", "", false
}
host, port, err := net.SplitHostPort(parsed.Host)
return host, port, err == nil && host != "" && port != ""
}
+9 -2
View File
@@ -63,6 +63,7 @@ type terminalLogArchive struct {
AgentVersion string `json:"agentVersion" db:"agent_version"` AgentVersion string `json:"agentVersion" db:"agent_version"`
KioskState string `json:"kioskState" db:"kiosk_state"` KioskState string `json:"kioskState" db:"kiosk_state"`
LogText string `json:"logText" db:"log_text"` LogText string `json:"logText" db:"log_text"`
Source string `json:"source" db:"source"`
} }
type terminalLogArchivePage struct { type terminalLogArchivePage struct {
Items []terminalLogArchive `json:"items"` Items []terminalLogArchive `json:"items"`
@@ -87,13 +88,19 @@ func (s *Server) terminalLogArchives(c *gin.Context) {
page, size := pageParams(c) page, size := pageParams(c)
args := []any{from, to, terminalID, keyword} args := []any{from, to, terminalID, keyword}
var total int 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 { if err := s.DB.GetContext(c.Request.Context(), &total, `SELECT count(*) FROM (
SELECT captured_at,terminal_id,log_text FROM terminal_log_archives
UNION ALL SELECT captured_at,terminal_id,message FROM terminal_log_entries
) logs 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) s.dbError(c, err)
return return
} }
items := []terminalLogArchive{} items := []terminalLogArchive{}
args = append(args, size, (page-1)*size) 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 { if err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT logs.id,logs.task_id,logs.terminal_id,t.name terminal_name,logs.captured_at,logs.boot_id,logs.agent_version,logs.kiosk_state,logs.log_text,logs.source FROM (
SELECT a.id,a.task_id,a.terminal_id,a.captured_at,a.boot_id,a.agent_version,a.kiosk_state,a.log_text,'snapshot' source FROM terminal_log_archives a
UNION ALL SELECT e.id,'' task_id,e.terminal_id,e.captured_at,e.boot_id,'' agent_version,e.level kiosk_state,e.message log_text,'stream' source FROM terminal_log_entries e
) logs JOIN terminals t ON t.id=logs.terminal_id WHERE logs.captured_at>=$1 AND logs.captured_at<=$2 AND ($3='' OR logs.terminal_id=$3) AND ($4='' OR logs.log_text ILIKE '%'||$4||'%') ORDER BY logs.captured_at DESC,logs.id DESC LIMIT $5 OFFSET $6`, args...); err != nil {
s.dbError(c, err) s.dbError(c, err)
return return
} }
+22 -14
View File
@@ -291,8 +291,12 @@ func (s *Server) prepareGPUDesktop(c *gin.Context) {
var previous model.Task var previous model.Task
err = tx.GetContext(ctx, &previous, `SELECT * FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout') ORDER BY created_at DESC LIMIT 1`) err = tx.GetContext(ctx, &previous, `SELECT * FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout') ORDER BY created_at DESC LIMIT 1`)
if err == nil { if err == nil {
if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && previous.Status != "timeout" { if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && (previous.Status != "timeout" || s.gpuRecoveryPending(previous)) {
s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": previous.Status, "message": previous.Message}) status := previous.Status
if status == "timeout" {
status = "running"
}
s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": status, "message": previous.Message})
return return
} }
s.fail(c, 409, "gpu_switch_busy", "已有显卡切换任务或未确认结果,请稍后重试或联系管理员") s.fail(c, 409, "gpu_switch_busy", "已有显卡切换任务或未确认结果,请稍后重试或联系管理员")
@@ -343,7 +347,11 @@ func (s *Server) gpuPrepareStatus(c *gin.Context) {
s.dbError(c, err) s.dbError(c, err)
return return
} }
s.deviceJSON(c, 200, gin.H{"taskId": task.ID, "status": task.Status, "message": task.Message}) status := task.Status
if s.gpuRecoveryPending(task) {
status = "running"
}
s.deviceJSON(c, 200, gin.H{"taskId": task.ID, "status": status, "message": task.Message})
} }
func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepareState, message string) error { func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepareState, message string) error {
@@ -359,15 +367,11 @@ func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepar
} }
func sunshineReady(ctx context.Context, endpoint string) bool { func sunshineReady(ctx context.Context, endpoint string) bool {
host := endpoint return sunshineReadyAt(ctx, endpoint, "47989")
if parsed, _, err := net.SplitHostPort(endpoint); err == nil { }
host = parsed
} func sunshineHTTPProbe(ctx context.Context, host, port string) bool {
// Platform endpoints are hostnames/IPs, never arbitrary URLs or paths. req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, port)+"/serverinfo", nil)
if strings.ContainsAny(host, "/\\@?#") || host == "" {
return false
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, "47989")+"/serverinfo", nil)
if err != nil { if err != nil {
return false return false
} }
@@ -532,6 +536,7 @@ func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) {
expected = "running" expected = "running"
} }
if e != nil || power != expected { if e != nil || power != expected {
uncertain = true
err = errors.New("final GPU ownership check failed") err = errors.New("final GPU ownership check failed")
break break
} }
@@ -546,9 +551,12 @@ func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) {
log.Printf("GPU prepare task=%s phase=%s error=%v", t.ID, state.Phase, err) log.Printf("GPU prepare task=%s phase=%s error=%v", t.ID, state.Phase, err)
status = "failed" status = "failed"
message = "桌面准备失败,请检查桌面授权、占用状态及 PVE" message = "桌面准备失败,请检查桌面授权、占用状态及 PVE"
if uncertain { if uncertain || (state.Phase == "service-waiting" && s.knownGPURecovery(state)) {
status = "timeout" status = "timeout"
message = "电源操作或服务状态尚未确认,已阻止再次切换;请管理员核对 PVE 原任务" message = "电源操作尚未确认,平台正在自动核对原任务;不会重复发送电源命令"
if state.UPID == "" {
message = "未收到 PVE 原任务编号,请管理员核查;不会重复发送电源命令"
}
} }
} }
finalCtx, finish := context.WithTimeout(context.Background(), 5*time.Second) finalCtx, finish := context.WithTimeout(context.Background(), 5*time.Second)
+23 -8
View File
@@ -17,11 +17,12 @@ import (
) )
type exclusiveTestPVE struct { type exclusiveTestPVE struct {
mu sync.Mutex mu sync.Mutex
power map[int]string power map[int]string
actions []string actions []string
mode string mode string
confirmed bool confirmed bool
readFailures int
} }
func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) { func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) {
@@ -49,7 +50,10 @@ func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) {
var id int var id int
switch { switch {
case strings.Contains(path, "/tasks/"): case strings.Contains(path, "/tasks/"):
if p.mode == "poll-failure" { if p.mode == "poll-failure" || p.readFailures > 0 {
if p.readFailures > 0 {
p.readFailures--
}
w.WriteHeader(503) w.WriteHeader(503)
return return
} }
@@ -62,7 +66,11 @@ func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) {
p.power[102] = "running" p.power[102] = "running"
} }
} }
data = map[string]string{"status": "stopped", "exitstatus": "OK"} exitStatus := "OK"
if p.mode == "failed-task" {
exitStatus = "ERROR"
}
data = map[string]string{"status": "stopped", "exitstatus": exitStatus}
case strings.HasSuffix(path, "/status/current"): case strings.HasSuffix(path, "/status/current"):
fmt.Sscanf(path, "/nodes/gpu-node/qemu/%d/status/current", &id) fmt.Sscanf(path, "/nodes/gpu-node/qemu/%d/status/current", &id)
data = map[string]string{"status": p.power[id]} data = map[string]string{"status": p.power[id]}
@@ -202,7 +210,14 @@ func TestExclusiveGPUUnknownSubmissionNeverReplayed(t *testing.T) {
t.Fatal("replayed uncertain power action", p.actions) t.Fatal("replayed uncertain power action", p.actions)
} }
code, out := f.request("POST", "/terminal/desktop-prepare", "gpu-device", map[string]any{"desktopId": "desk_demo_ubuntu"}, "") code, out := f.request("POST", "/terminal/desktop-prepare", "gpu-device", map[string]any{"desktopId": "desk_demo_ubuntu"}, "")
expect(t, 409, code, out) if mode == "poll-failure" {
expect(t, 202, code, out)
if out["taskId"] != task.ID || out["status"] != "running" {
t.Fatal("recovering task was not reused", out)
}
} else {
expect(t, 409, code, out)
}
code, out = f.request("POST", "/tasks/"+task.ID+"/gpu-reconcile", f.login("admin"), nil, "") code, out = f.request("POST", "/tasks/"+task.ID+"/gpu-reconcile", f.login("admin"), nil, "")
if mode == "ambiguous" { if mode == "ambiguous" {
expect(t, 409, code, out) expect(t, 409, code, out)
+29 -2
View File
@@ -103,6 +103,11 @@ func (s *Server) gpuInventory(c *gin.Context) {
s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限") s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限")
return return
} }
healthSamples, err := s.latestGPUHealth(ctx, node)
if err != nil {
s.dbError(c, err)
return
}
items := []gpuItem{} items := []gpuItem{}
observedAt := time.Now().UTC() observedAt := time.Now().UTC()
for _, d := range devices { for _, d := range devices {
@@ -122,7 +127,23 @@ func (s *Server) gpuInventory(c *gin.Context) {
if strings.TrimSpace(d.Driver) != "" { if strings.TrimSpace(d.Driver) != "" {
driverBinding = "bound" 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{}}) health := gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}
if sample, ok := healthSamples[address]; ok {
health.Status = "collected"
health.Source = sample.Source
health.ObservedAt = sample.ObservedAt
health.TemperatureC = sample.TemperatureC
health.UtilizationPercent = sample.UtilizationPercent
health.MemoryUsedBytes = sample.MemoryUsedBytes
health.MemoryTotalBytes = sample.MemoryTotalBytes
if sample.Driver != "" {
health.Driver = sample.Driver
}
if sample.DriverBinding != "" {
health.DriverBinding = sample.DriverBinding
}
}
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: health, References: []gpuReference{}})
} }
fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit") fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit")
if err != nil || !fullVisibility { if err != nil || !fullVisibility {
@@ -244,7 +265,13 @@ func (s *Server) gpuInventory(c *gin.Context) {
} }
} }
sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address }) sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address })
c.JSON(200, gin.H{"node": node, "observedAt": 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"}}) healthStatus := "not_collected"
healthSource := "pve-pci"
if len(healthSamples) > 0 {
healthStatus = "collected"
healthSource = "node-agent"
}
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": healthStatus, "source": healthSource, "temperature": healthStatus, "utilization": healthStatus, "memory": healthStatus, "driverBinding": "reported_when_collected"}})
} }
func rawOptionText(value json.RawMessage) string { func rawOptionText(value json.RawMessage) string {
+141
View File
@@ -0,0 +1,141 @@
package server
import (
"context"
"crypto/subtle"
"strings"
"time"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/pkg/proxmox"
)
// gpuAgentSample is the small, vendor-neutral contract implemented by a node
// collector. The collector may use nvidia-smi, rocm-smi, intel_gpu_top or a
// vendor API, while the platform keeps the same health model for every GPU.
type gpuAgentSample struct {
Address string `json:"address" binding:"required"`
ObservedAt string `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 gpuAgentReport struct {
Node string `json:"node" binding:"required,max=100"`
Samples []gpuAgentSample `json:"samples" binding:"required,min=1,max=256"`
}
func (s *Server) gpuAgentAuth(c *gin.Context) {
if strings.TrimSpace(s.Config.GPUAgentToken) == "" {
s.fail(c, 503, "gpu_agent_unconfigured", "尚未配置 GPU 节点采集令牌")
return
}
provided := c.GetHeader("X-GPU-Agent-Token")
if len(provided) != len(s.Config.GPUAgentToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.Config.GPUAgentToken)) != 1 {
s.fail(c, 401, "gpu_agent_unauthorized", "GPU 节点采集令牌无效")
return
}
c.Next()
}
func validGPUHealth(sample gpuAgentSample) bool {
if sample.TemperatureC != nil && (*sample.TemperatureC < -40 || *sample.TemperatureC > 150) {
return false
}
if sample.UtilizationPercent != nil && (*sample.UtilizationPercent < 0 || *sample.UtilizationPercent > 100) {
return false
}
if sample.MemoryUsedBytes != nil && *sample.MemoryUsedBytes < 0 {
return false
}
if sample.MemoryTotalBytes != nil && *sample.MemoryTotalBytes < 0 {
return false
}
if sample.MemoryUsedBytes != nil && sample.MemoryTotalBytes != nil && *sample.MemoryUsedBytes > *sample.MemoryTotalBytes {
return false
}
_, ok := proxmox.NormalizePCIAddress(sample.Address)
return ok
}
func (s *Server) ingestGPUHealth(c *gin.Context) {
var in gpuAgentReport
if !s.decode(c, &in) {
return
}
in.Node = strings.TrimSpace(in.Node)
if in.Node == "" || strings.ContainsAny(in.Node, "/\\?# ") {
s.fail(c, 400, "invalid_node", "节点名称无效")
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
for _, sample := range in.Samples {
if !validGPUHealth(sample) {
s.fail(c, 400, "invalid_gpu_health", "GPU 健康数据超出范围或 PCI 地址无效")
return
}
address, _ := proxmox.NormalizePCIAddress(sample.Address)
observedAt := time.Now().UTC()
if strings.TrimSpace(sample.ObservedAt) != "" {
observedAt, err = time.Parse(time.RFC3339Nano, sample.ObservedAt)
if err != nil || observedAt.After(time.Now().Add(2*time.Minute)) || observedAt.Before(time.Now().Add(-10*time.Minute)) {
s.fail(c, 400, "invalid_gpu_health", "采集时间必须是最近 10 分钟内的 RFC3339 时间")
return
}
observedAt = observedAt.UTC()
}
_, err = tx.ExecContext(ctx, `INSERT INTO gpu_health_samples(node,address,observed_at,temperature_c,utilization_percent,memory_used_bytes,memory_total_bytes,driver,driver_binding,source)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'node-agent') ON CONFLICT DO NOTHING`, in.Node, address, observedAt, sample.TemperatureC, sample.UtilizationPercent, sample.MemoryUsedBytes, sample.MemoryTotalBytes, strings.TrimSpace(sample.Driver), strings.TrimSpace(sample.DriverBinding))
if err != nil {
s.dbError(c, err)
return
}
}
if _, err = tx.ExecContext(ctx, "DELETE FROM gpu_health_samples WHERE observed_at < now() - interval '7 days'"); err != nil {
s.dbError(c, err)
return
}
if err = tx.Commit(); err != nil {
s.dbError(c, err)
return
}
c.JSON(202, gin.H{"accepted": len(in.Samples), "node": in.Node})
}
type latestGPUHealth struct {
Address string `db:"address"`
ObservedAt time.Time `db:"observed_at"`
TemperatureC *float64 `db:"temperature_c"`
UtilizationPercent *float64 `db:"utilization_percent"`
MemoryUsedBytes *int64 `db:"memory_used_bytes"`
MemoryTotalBytes *int64 `db:"memory_total_bytes"`
Driver string `db:"driver"`
DriverBinding string `db:"driver_binding"`
Source string `db:"source"`
}
func (s *Server) latestGPUHealth(ctx context.Context, node string) (map[string]latestGPUHealth, error) {
// Kept separate from inventory assembly so a missing collector is an
// ordinary empty result and remains visibly "not_collected" in the API.
items := []latestGPUHealth{}
query := `SELECT DISTINCT ON(address) address,observed_at,temperature_c,utilization_percent,memory_used_bytes,memory_total_bytes,driver,driver_binding,source
FROM gpu_health_samples WHERE node=$1 AND observed_at > now()-interval '10 minutes' ORDER BY address,observed_at DESC`
if err := s.DB.SelectContext(ctx, &items, query, node); err != nil {
return nil, err
}
result := make(map[string]latestGPUHealth, len(items))
for _, item := range items {
result[item.Address] = item
}
return result, nil
}
@@ -0,0 +1,50 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGPUAgentHealthIsAuthenticatedAndVisibleInInventory(t *testing.T) {
f := newFixture(t)
f.server.Config.GPUAgentToken = "agent-token"
fakeGPUPVE(t, f, "normal")
status, out := f.request("POST", "/gpu-agent/health", "", map[string]any{
"node": "node-a",
"samples": []map[string]any{{
"address": "0000:01:00.0", "temperatureC": 61.5, "utilizationPercent": 42.0,
"memoryUsedBytes": 1024, "memoryTotalBytes": 4096, "driver": "nvidia", "driverBinding": "bound",
}},
}, "")
// f.request cannot attach the collector header, so the route must reject it.
expect(t, 401, status, out)
body, _ := json.Marshal(map[string]any{
"node": "node-a", "samples": []map[string]any{{"address": "0000:01:00.0", "temperatureC": 61.5, "driver": "nvidia", "driverBinding": "bound"}},
})
r := httptest.NewRequest(http.MethodPost, "/api/v1/gpu-agent/health", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-GPU-Agent-Token", "agent-token")
w := httptest.NewRecorder()
f.server.Router.ServeHTTP(w, r)
expect(t, 202, w.Code, w.Body.String())
status, out = f.request("GET", "/resources/gpus?node=node-a", f.login("admin"), nil, "")
expect(t, 200, status, out)
items := out["items"].([]any)
var found bool
for _, raw := range items {
item := raw.(map[string]any)
if item["address"] == "0000:01:00.0" {
health := item["health"].(map[string]any)
found = health["status"] == "collected" && health["driverBinding"] == "bound" && health["temperatureC"] == 61.5 && health["driver"] == "nvidia"
}
}
if !found {
t.Fatal("ingested GPU health was not visible in inventory", out)
}
}
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"context"
"encoding/json"
"fmt"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) knownGPURecovery(state gpuPrepareState) bool {
return state.UPID != "" && state.Node == s.Config.ExclusiveGPUNode &&
fmt.Sprint(state.Members) == fmt.Sprint(s.Config.ExclusiveGPUVMIDs) &&
(state.Phase == "start-waiting" || state.Phase == "shutdown-waiting" || state.Phase == "service-waiting")
}
func (s *Server) gpuRecoveryPending(task model.Task) bool {
var state gpuPrepareState
return task.Status == "timeout" && time.Since(task.CreatedAt) < 5*time.Minute &&
json.Unmarshal([]byte(task.ProviderRef), &state) == nil && s.knownGPURecovery(state)
}
// Recovery observes the original UPID and every member under the same group
// lock as preparation. It never sends a power command, even after an API
// restart or a lost submission response.
func (s *Server) recoverGPUPrepare(parent context.Context, task model.Task) {
ctx, cancel := context.WithTimeout(parent, 60*time.Second)
defer cancel()
if s.RefreshCompute(ctx) != nil {
return
}
pve := s.currentPVE()
if pve == nil {
return
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return
}
defer tx.Rollback()
locked, err := tryGPULock(ctx, tx)
if err != nil || !locked {
return
}
if err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND kind='gpu-prepare' AND status IN ('running','timeout') FOR UPDATE", task.ID); err != nil {
return
}
finish := func(status, message string, power map[string]string) {
for id, value := range power {
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state=CASE WHEN $2=$3 AND $4='success' THEN 'ready' ELSE 'unknown' END,updated_at=now() WHERE id=$2", value, id, task.TargetID, status); err != nil {
return
}
}
if _, err = tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3", status, message, task.ID); err != nil {
return
}
if status != "timeout" {
if _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,'system','auto-reconcile-gpu-switch',$2,$3,$4,now())", store.ID("audit_"), task.TargetID, status, task.ID); err != nil {
return
}
}
_ = tx.Commit()
}
var state gpuPrepareState
if json.Unmarshal([]byte(task.ProviderRef), &state) == nil && state.Phase == "queued" && state.UPID == "" {
finish("failed", "服务重启前尚未提交电源操作,可重新选择桌面", nil)
return
}
if json.Unmarshal([]byte(task.ProviderRef), &state) != nil || !s.knownGPURecovery(state) {
finish("timeout", "未记录可核对的原任务或独占组配置已变化,请管理员核查;不会重复发送电源命令", nil)
return
}
original, err := pve.Task(ctx, state.Node, state.UPID)
if err != nil || original.Status != "stopped" || original.ExitStatus == "" {
finish("timeout", "平台正在自动核对原 PVE 任务,暂不重复切换;计算节点恢复后将继续检查", nil)
return
}
members, err := s.gpuMembers(ctx, tx)
if err != nil {
finish("timeout", "独占显卡组成员不完整,请管理员核查", nil)
return
}
powers := map[string]string{}
running, matches, submittedMember := 0, true, false
var target model.Desktop
for _, d := range members {
power, readErr := pve.Power(ctx, d.Node, d.VMID)
if readErr != nil || (power != "running" && power != "stopped") {
finish("timeout", "平台正在自动核对虚拟机实际电源状态,暂不重复切换", nil)
return
}
powers[d.ID] = power
if d.VMID == state.VMID {
submittedMember = true
}
if power == "running" {
running++
}
if d.ID == task.TargetID {
target = d
matches = matches && power == "running"
} else {
matches = matches && power == "stopped"
}
}
if running > 1 || target.ID == "" || !submittedMember {
finish("timeout", "实际电源或显卡归属存在冲突,请管理员核查;平台不会自动关机", powers)
return
}
if original.ExitStatus != "OK" || !matches {
finish("failed", "已自动核对原任务和电源状态,本次准备未完成;可重新选择桌面", powers)
return
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id JOIN terminals t ON t.id=u.terminal_id WHERE b.desktop_id=$1 AND t.id=$2 AND u.enabled AND t.enabled)`, target.ID, state.Terminal)
if err != nil {
return
}
if !allowed || target.SetupRequired || target.Endpoint == "" {
finish("failed", "原任务已核对,但桌面授权或连接配置已变化,请检查配置后重新选择", powers)
return
}
if !sunshineReady(ctx, target.Endpoint) {
if time.Since(task.CreatedAt) < 5*time.Minute {
finish("timeout", "原任务已确认成功,正在等待桌面连接服务恢复", powers)
} else {
finish("failed", "原任务及显卡归属已自动确认,但 Sunshine 仍未响应,请检查桌面服务", powers)
}
return
}
finish("success", "已自动核对原任务、独占显卡归属和连接服务,桌面可连接", powers)
}
// Only called by the worker while no GPU/provisioning operation is running.
// A restart resumes observations, never the command sequence.
func (s *Server) nextGPURecovery(ctx context.Context) (model.Task, error) {
var task model.Task
err := s.DB.GetContext(ctx, &task, `SELECT * FROM tasks WHERE kind='gpu-prepare'
AND ((status='running')
OR (status='timeout' AND provider_ref::jsonb->>'upid' IS NOT NULL AND provider_ref::jsonb->>'upid' != '' AND created_at>now()-interval '24 hours'))
ORDER BY updated_at,id LIMIT 1`)
return task, err
}
@@ -0,0 +1,161 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"testing"
"time"
"vdi-cloud/platform/internal/model"
)
func serveRecoverySunshine(t *testing.T) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:47989")
if err != nil {
t.Fatal(err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<root status_code="200"><hostname>test-sunshine</hostname></root>`)
})}
go srv.Serve(listener)
t.Cleanup(func() { srv.Close() })
}
func seedGPURecovery(t *testing.T, f *fixture, status string) model.Task {
t.Helper()
task := prepareExclusive(t, f)
state := gpuPrepareState{Phase: "start-waiting", Node: "gpu-node", Members: []int{100, 101, 102}, Terminal: "gpu-box", VMID: 102, UPID: "UPID:gpu-test"}
data, _ := json.Marshal(state)
if err := f.db.Get(&task, "UPDATE tasks SET status=$1,provider_ref=$2 WHERE id=$3 RETURNING *", status, string(data), task.ID); err != nil {
t.Fatal(err)
}
return task
}
func TestGPURecoveryRequiresActualOwnershipAndSunshine(t *testing.T) {
for _, scenario := range []string{"success", "original-failed", "pending-shutdown", "multiple-running", "pve-unreachable", "sunshine-unreachable", "authorization-revoked", "config-changed", "unknown-submission"} {
t.Run(scenario, func(t *testing.T) {
f, p := exclusiveFixture(t, "normal")
task := seedGPURecovery(t, f, "timeout")
p.power[100], p.power[102] = "stopped", "running"
want := "timeout"
switch scenario {
case "success":
serveRecoverySunshine(t)
want = "success"
case "original-failed":
p.mode = "failed-task"
want = "failed"
case "pending-shutdown":
p.power[102] = "stopped"
want = "failed"
case "multiple-running":
p.power[100] = "running"
case "pve-unreachable":
p.mode = "poll-failure"
case "authorization-revoked":
if _, err := f.db.Exec("UPDATE users SET enabled=false"); err != nil {
t.Fatal(err)
}
want = "failed"
case "config-changed":
f.server.Config.ExclusiveGPUVMIDs = []int{100, 102}
case "unknown-submission":
if _, err := f.db.Exec("UPDATE tasks SET provider_ref=$1 WHERE id=$2", `{"phase":"start-submitting","node":"gpu-node","members":[100,101,102],"terminal":"gpu-box","vmid":102}`, task.ID); err != nil {
t.Fatal(err)
}
}
f.server.recoverGPUPrepare(context.Background(), task)
var result model.Task
if err := f.db.Get(&result, "SELECT * FROM tasks WHERE id=$1", task.ID); err != nil {
t.Fatal(err)
}
if result.Status != want || len(p.actions) != 0 {
t.Fatal("incorrect read-only recovery", result.Status, want, p.actions)
}
if want == "success" {
var power, service string
f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id=$1", task.TargetID).Scan(&power, &service)
if power != "running" || service != "ready" {
t.Fatal("desktop cache did not converge", power, service)
}
f.server.recoverGPUPrepare(context.Background(), task)
var n int
f.db.Get(&n, "SELECT count(*) FROM audit_logs WHERE action='auto-reconcile-gpu-switch' AND request_id=$1", task.ID)
if n != 1 {
t.Fatal("recovery audit was duplicated", n)
}
}
})
}
}
func TestGPUWorkerRecoversOriginalTaskAfterRestartWithoutCommands(t *testing.T) {
f, p := exclusiveFixture(t, "normal")
task := seedGPURecovery(t, f, "running")
p.power[100], p.power[102] = "stopped", "running"
serveRecoverySunshine(t)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { f.server.RunWorker(ctx); close(done) }()
defer func() { cancel(); <-done }()
deadline := time.Now().Add(5 * time.Second)
for {
var status string
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil {
t.Fatal(err)
}
if status == "success" {
break
}
if time.Now().After(deadline) {
t.Fatal("worker did not recover orphaned task", status)
}
time.Sleep(20 * time.Millisecond)
}
p.mu.Lock()
defer p.mu.Unlock()
if len(p.actions) != 0 {
t.Fatal("restart replayed a power command", p.actions)
}
}
func TestGPUTransientReadFailureKeepsSingleCommandSequence(t *testing.T) {
f, p := exclusiveFixture(t, "normal")
p.readFailures = 1
serveRecoverySunshine(t)
task := prepareExclusive(t, f)
f.server.executeGPUPrepare(context.Background(), task)
var status string
f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID)
if status != "success" || fmt.Sprint(p.actions) != "[100:shutdown 102:start]" {
t.Fatal("transient poll failure interrupted or replayed commands", status, p.actions)
}
}
func TestGPURecoveryPollingRetainsTaskAndDeviceIsolation(t *testing.T) {
f, _ := exclusiveFixture(t, "normal")
task := seedGPURecovery(t, f, "timeout")
code, out := f.request("GET", "/terminal/desktop-prepare/"+task.ID, "gpu-device", nil, "")
expect(t, 200, code, out)
if out["status"] != "running" {
t.Fatal("terminal stopped polling recoverable task", out)
}
code, out = f.request("POST", "/terminal/desktop-prepare", "gpu-device", map[string]any{"desktopId": task.TargetID}, "")
expect(t, 202, code, out)
if out["taskId"] != task.ID {
t.Fatal("new task submitted during recovery", out)
}
if _, err := f.db.Exec("UPDATE tasks SET created_at=now()-interval '6 minutes' WHERE id=$1", task.ID); err != nil {
t.Fatal(err)
}
code, out = f.request("GET", "/terminal/desktop-prepare/"+task.ID, "gpu-device", nil, "")
expect(t, 200, code, out)
if out["status"] != "timeout" {
t.Fatal("terminal waiting exceeded recovery window", out)
}
}
+1 -1
View File
@@ -299,7 +299,7 @@ func (s *Server) observe(ctx context.Context) error {
} }
} }
// Bounded retention. Current incidents and active session records are preserved. // Bounded retention. Current incidents and active session records are preserved.
for _, query := range []string{"DELETE FROM login_limits WHERE window_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM 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'"} { 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 terminal_log_entries WHERE captured_at<now()-interval '30 days'", "DELETE FROM session_history WHERE ended_at<now()-interval '90 days'", "DELETE FROM alerts WHERE resolved_at<now()-interval '90 days'"} {
if _, err = tx.ExecContext(ctx, query); err != nil { if _, err = tx.ExecContext(ctx, query); err != nil {
return err return err
} }
+8 -1
View File
@@ -74,7 +74,14 @@ func (s *Server) validateTemplate(c *gin.Context) {
s.fail(c, 502, "invalid_template", "模板配置摘要缺失") s.fail(c, 502, "invalid_template", "模板配置摘要缺失")
return return
} }
if err = s.DB.GetContext(ctx, &template, "UPDATE image_templates SET digest=$1,validated_at=now() WHERE id=$2 RETURNING *", digest, template.ID); err != nil { // A source template change invalidates the previous hardware-encoding
// acceptance. The administrator must verify the updated guest image again
// before it can be used for a new desktop, regardless of its OS.
hardwareVerified := template.HardwareEncodingVerified
if template.Digest != digest {
hardwareVerified = false
}
if err = s.DB.GetContext(ctx, &template, "UPDATE image_templates SET digest=$1,validated_at=now(),hardware_encoding_verified=$2 WHERE id=$3 RETURNING *", digest, hardwareVerified, template.ID); err != nil {
s.dbError(c, err) s.dbError(c, err)
return return
} }
+6
View File
@@ -59,8 +59,10 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
}) })
r.POST("/api/v1/auth/login", s.login) r.POST("/api/v1/auth/login", s.login)
r.POST("/api/v1/terminal/register", s.register) r.POST("/api/v1/terminal/register", s.register)
r.POST("/api/v1/gpu-agent/health", s.gpuAgentAuth, s.ingestGPUHealth)
t := r.Group("/api/v1/terminal", s.deviceAuth) t := r.Group("/api/v1/terminal", s.deviceAuth)
t.POST("/heartbeat", s.heartbeat) t.POST("/heartbeat", s.heartbeat)
t.POST("/logs", s.appendTerminalLogs)
t.GET("/config", s.deviceConfig) t.GET("/config", s.deviceConfig)
t.POST("/config/ack", s.policyAck) t.POST("/config/ack", s.policyAck)
t.GET("/desktops", s.deviceDesktops) t.GET("/desktops", s.deviceDesktops)
@@ -87,6 +89,9 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
a.GET("/administrators/:id/sessions", s.allow("super_admin"), s.administratorSessions) a.GET("/administrators/:id/sessions", s.allow("super_admin"), s.administratorSessions)
a.DELETE("/administrators/:id/sessions/:sessionKey", s.allow("super_admin"), s.revokeAdministratorSession) a.DELETE("/administrators/:id/sessions/:sessionKey", s.allow("super_admin"), s.revokeAdministratorSession)
a.GET("/overview", s.overview) a.GET("/overview", s.overview)
a.POST("/assistant/plan", s.assistantPlanHandler)
a.POST("/assistant/execute", s.allow("super_admin"), s.assistantExecute)
a.GET("/assistant/runs/:id", s.assistantRun)
a.GET("/sessions/history", s.sessionHistory) a.GET("/sessions/history", s.sessionHistory)
a.GET("/monitor/series", s.resourceSeries) a.GET("/monitor/series", s.resourceSeries)
a.GET("/monitor/rules", s.alertRules) a.GET("/monitor/rules", s.alertRules)
@@ -134,6 +139,7 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop) a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
a.GET("/desktops/:id", s.desktop) a.GET("/desktops/:id", s.desktop)
a.PUT("/desktops/:id/connection", s.allow("super_admin"), s.configureDesktop) a.PUT("/desktops/:id/connection", s.allow("super_admin"), s.configureDesktop)
a.POST("/desktops/:id/readiness/check", s.allow("super_admin", "operator"), s.checkDesktopReadiness)
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction) a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
a.GET("/terminals", s.terminals) a.GET("/terminals", s.terminals)
a.GET("/terminals/:id/metrics", s.terminalMetrics) a.GET("/terminals/:id/metrics", s.terminalMetrics)
+8 -1
View File
@@ -36,7 +36,7 @@ func newFixtureWorker(t *testing.T, worker bool) *fixture {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err = db.Exec(`TRUNCATE terminal_metric_samples,terminal_metrics_current,power_batch_items,power_batches,snapshot_jobs,hardware_jobs,pve_connection,login_limits,alert_states,alerts,alert_rules,resource_samples,session_history,import_batches,terminal_policy_deliveries,terminal_policy_deployments,terminal_policy_templates,provisioning_jobs,desktop_pools,image_templates,connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil { if _, err = db.Exec(`TRUNCATE assistant_runs,gpu_health_samples,terminal_log_entries,terminal_log_archives,terminal_metric_samples,terminal_metrics_current,power_batch_items,power_batches,snapshot_jobs,hardware_jobs,pve_connection,login_limits,alert_states,alerts,alert_rules,resource_samples,session_history,import_batches,terminal_policy_deliveries,terminal_policy_deployments,terminal_policy_templates,provisioning_jobs,desktop_pools,image_templates,connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil { if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
@@ -258,6 +258,13 @@ func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
if out["total"] != float64(1) || len(out["items"].([]any)) != 1 || out["items"].([]any)[0].(map[string]any)["logText"] != "private diagnostic" { 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) t.Fatalf("terminal log archive not searchable: %#v", out)
} }
status, out = f.request("POST", "/terminal/logs", device, map[string]any{"bootId": "test-boot", "entries": []map[string]any{{"level": "error", "message": "stream error password=hidden"}}}, "")
expect(t, 202, status, out)
status, out = f.request("GET", "/terminal-logs?terminalId="+id+"&keyword=stream", admin, nil, "")
expect(t, 200, status, out)
if out["total"] != float64(1) || out["items"].([]any)[0].(map[string]any)["source"] != "stream" || strings.Contains(out["items"].([]any)[0].(map[string]any)["logText"].(string), "hidden") {
t.Fatalf("continuous terminal log was not safely archived: %#v", out)
}
_ = otherID _ = otherID
} }
+52
View File
@@ -140,6 +140,58 @@ func (s *Server) heartbeat(c *gin.Context) {
} }
s.deviceJSON(c, 200, gin.H{"accepted": true, "serverTime": time.Now().UTC()}) s.deviceJSON(c, 200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
} }
// appendTerminalLogs accepts small log batches from an online terminal. It is
// deliberately separate from collect-logs: agents can stream useful events
// during normal operation without creating a management task for every batch.
func (s *Server) appendTerminalLogs(c *gin.Context) {
var in struct {
BootID string `json:"bootId" binding:"max=120"`
Entries []struct {
CapturedAt string `json:"capturedAt"`
Level string `json:"level" binding:"omitempty,oneof=debug info warn error"`
Message string `json:"message" binding:"required,max=16000"`
} `json:"entries" binding:"required,min=1,max=100"`
}
if !s.decode(c, &in) {
return
}
if len(in.Entries) == 0 {
s.fail(c, 400, "invalid_logs", "日志批次不能为空")
return
}
if len(in.BootID) > 120 || strings.IndexByte(in.BootID, 0) >= 0 {
s.fail(c, 400, "invalid_logs", "日志启动标识无效")
return
}
tx := deviceTx(c)
for _, entry := range in.Entries {
message := redactDiagnostic(strings.TrimSpace(entry.Message))
if message == "" || len(message) > 16000 {
s.fail(c, 400, "invalid_logs", "日志内容不能为空或超出长度限制")
return
}
capturedAt := time.Now().UTC()
if strings.TrimSpace(entry.CapturedAt) != "" {
parsed, err := time.Parse(time.RFC3339Nano, entry.CapturedAt)
if err != nil || parsed.After(time.Now().Add(2*time.Minute)) || parsed.Before(time.Now().Add(-10*time.Minute)) {
s.fail(c, 400, "invalid_logs", "日志时间必须是最近 10 分钟内的 RFC3339 时间")
return
}
capturedAt = parsed.UTC()
}
level := strings.TrimSpace(entry.Level)
if level == "" {
level = "info"
}
if _, err := tx.ExecContext(c.Request.Context(), `INSERT INTO terminal_log_entries(id,terminal_id,captured_at,boot_id,level,message) VALUES($1,$2,$3,$4,$5,$6)`, store.ID("log_"), c.GetString("terminalId"), capturedAt, redactDiagnostic(in.BootID), level, message); err != nil {
s.dbError(c, err)
return
}
}
_, _ = tx.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online' WHERE id=$1", c.GetString("terminalId"))
s.deviceJSON(c, 202, gin.H{"accepted": len(in.Entries), "serverTime": time.Now().UTC()})
}
func (s *Server) deviceConfig(c *gin.Context) { func (s *Server) deviceConfig(c *gin.Context) {
var version int var version int
if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil { if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
+26
View File
@@ -82,12 +82,27 @@ CREATE TABLE IF NOT EXISTS terminal_log_archives (
); );
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_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 INDEX IF NOT EXISTS terminal_log_archives_time ON terminal_log_archives(captured_at DESC);
CREATE TABLE IF NOT EXISTS terminal_log_entries (
id text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id) ON DELETE CASCADE,
captured_at timestamptz NOT NULL DEFAULT now(), boot_id text NOT NULL DEFAULT '', level text NOT NULL DEFAULT 'info',
message text NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS terminal_log_entries_terminal_time ON terminal_log_entries(terminal_id,captured_at DESC,id);
CREATE INDEX IF NOT EXISTS terminal_log_entries_time ON terminal_log_entries(captured_at DESC);
CREATE TABLE IF NOT EXISTS assistant_runs (
id text PRIMARY KEY, actor text NOT NULL REFERENCES administrators(username), input_text text NOT NULL,
action text NOT NULL, status text NOT NULL CHECK(status IN ('planned','blocked','executed','expired','failed')),
plan jsonb NOT NULL, fingerprint text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, executed_at timestamptz, result jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS assistant_runs_actor_time ON assistant_runs(actor,created_at DESC);
CREATE TABLE IF NOT EXISTS image_templates ( CREATE TABLE IF NOT EXISTS image_templates (
id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')), id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')),
node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL, node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL,
enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(), enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(node,vmid), UNIQUE(name,version) UNIQUE(node,vmid), UNIQUE(name,version)
); );
ALTER TABLE image_templates ADD COLUMN IF NOT EXISTS hardware_encoding_verified boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS desktop_pools ( CREATE TABLE IF NOT EXISTS desktop_pools (
id text PRIMARY KEY, name text NOT NULL UNIQUE, type text NOT NULL CHECK(type IN ('dedicated','shared')), id text PRIMARY KEY, name text NOT NULL UNIQUE, type text NOT NULL CHECK(type IN ('dedicated','shared')),
max_count integer NOT NULL CHECK(max_count BETWEEN 1 AND 10000), max_count integer NOT NULL CHECK(max_count BETWEEN 1 AND 10000),
@@ -124,6 +139,17 @@ CREATE TABLE IF NOT EXISTS resource_samples (
memory double precision, storage double precision, PRIMARY KEY(resource_id,sampled_at) memory double precision, storage double precision, PRIMARY KEY(resource_id,sampled_at)
); );
CREATE INDEX IF NOT EXISTS resource_samples_time ON resource_samples(sampled_at); CREATE INDEX IF NOT EXISTS resource_samples_time ON resource_samples(sampled_at);
-- GPU health is reported by an explicitly configured node collector. PVE's
-- PCI inventory does not expose live temperature, utilization or VRAM usage.
CREATE TABLE IF NOT EXISTS gpu_health_samples (
node text NOT NULL, address text NOT NULL, observed_at timestamptz NOT NULL DEFAULT now(),
temperature_c double precision, utilization_percent double precision,
memory_used_bytes bigint, memory_total_bytes bigint,
driver text NOT NULL DEFAULT '', driver_binding text NOT NULL DEFAULT '', source text NOT NULL,
PRIMARY KEY(node,address,observed_at)
);
CREATE INDEX IF NOT EXISTS gpu_health_latest ON gpu_health_samples(node,address,observed_at DESC);
DELETE FROM gpu_health_samples WHERE observed_at < now() - interval '7 days';
CREATE TABLE IF NOT EXISTS alert_rules ( CREATE TABLE IF NOT EXISTS alert_rules (
id text PRIMARY KEY, name text NOT NULL, metric text NOT NULL CHECK(metric IN ('node_cpu','node_memory','node_storage','node_unavailable','terminal_offline')), id text PRIMARY KEY, name text NOT NULL, metric text NOT NULL CHECK(metric IN ('node_cpu','node_memory','node_storage','node_unavailable','terminal_offline')),
threshold double precision NOT NULL, hold_seconds integer NOT NULL DEFAULT 0, enabled boolean NOT NULL DEFAULT true threshold double precision NOT NULL, hold_seconds integer NOT NULL DEFAULT 0, enabled boolean NOT NULL DEFAULT true
+35 -2
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -123,16 +124,48 @@ func (c *Client) Action(ctx context.Context, node string, vmid int, action strin
} }
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) { func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
var status TaskStatus var status TaskStatus
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status) err := c.readWithRetry(ctx, "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", &status)
return status, err return status, err
} }
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) { func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
var status struct { var status struct {
Status string `json:"status"` Status string `json:"status"`
} }
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status) err := c.readWithRetry(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), &status)
return status.Status, err return status.Status, err
} }
// Retry only observations. A lost POST response may already have changed the
// VM, so commands must never use this path.
func (c *Client) readWithRetry(ctx context.Context, path string, out any) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if err = ctx.Err(); err != nil {
return err
}
err = c.request(ctx, "GET", path, nil, out)
if err == nil || !retryableRead(err) || attempt == 2 {
return err
}
timer := time.NewTimer(time.Duration(attempt+1) * 250 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return err
}
func retryableRead(err error) bool {
var response *HTTPError
if errors.As(err, &response) {
return response.Status == 408 || response.Status == 429 || response.Status == 500 || response.Status == 502 || response.Status == 503 || response.Status == 504
}
var network net.Error
return errors.As(err, &network) && (network.Timeout() || network.Temporary()) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
}
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error { func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
+65
View File
@@ -2,6 +2,7 @@ package proxmox
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -58,6 +59,70 @@ func TestTLSAndTaskLifecycle(t *testing.T) {
t.Fatal("restore must never map to hard reset") t.Fatal("restore must never map to hard reset")
} }
} }
func TestObservationRetriesDoNotReplayPowerCommands(t *testing.T) {
for _, endpoint := range []string{"task", "power"} {
t.Run(endpoint, func(t *testing.T) {
reads, writes := 0, 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
writes++
w.WriteHeader(http.StatusServiceUnavailable)
return
}
reads++
if reads < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
fmtBody := `{"data":{"status":"running"}}`
if endpoint == "task" {
fmtBody = `{"data":{"status":"stopped","exitstatus":"OK"}}`
}
w.Write([]byte(fmtBody))
}))
defer ts.Close()
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
var err error
if endpoint == "task" {
err = c.Wait(context.Background(), "node", "UPID:one", time.Millisecond)
} else {
_, err = c.Power(context.Background(), "node", 100)
}
if err != nil || reads != 3 {
t.Fatal("observation did not recover", reads, err)
}
if _, err = c.Action(context.Background(), "node", 100, "start"); err == nil || writes != 1 {
t.Fatal("command response loss must not replay POST", writes, err)
}
})
}
}
func TestObservationRetriesAreBoundedAndRespectCancellation(t *testing.T) {
for _, status := range []int{http.StatusForbidden, http.StatusServiceUnavailable} {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(status)
}))
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
_, err := c.Power(context.Background(), "node", 100)
want := 1
if status == http.StatusServiceUnavailable {
want = 3
}
if err == nil || calls != want {
t.Fatal("incorrect retry limit", calls, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = c.Power(ctx, "node", 100); !errors.Is(err, context.Canceled) || calls != want {
t.Fatal("cancellation ignored", calls, err)
}
ts.Close()
}
}
func TestFailedPVEJobIsNotSuccess(t *testing.T) { func TestFailedPVEJobIsNotSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`)) w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
+25 -5
View File
@@ -17,6 +17,8 @@ const schemas = {
MFARecovery: object({ recoveryCodes: array(string) }), MFARecovery: object({ recoveryCodes: array(string) }),
LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }), LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }),
Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }), Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }),
DesktopReadinessCheck: object({ name: string, status: { type: 'string', enum: ['pass', 'fail', 'unknown'] }, message: string }),
DesktopReadiness: object({ desktopId: string, observedAt: date, ready: boolean, powerState: string, serviceState: string, setupRequired: boolean, checks: array(ref('DesktopReadinessCheck')) }),
Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }), Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }),
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }), User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }), Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
@@ -25,6 +27,9 @@ const schemas = {
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')) }), GPUItem: object({ id: string, address: string, name: string, vendor: string, vendorId: string, deviceId: string, class: string, iommuGroup: { ...integer, nullable: true }, state: { type: 'string', enum: ['unreferenced', 'unknown', 'referenced', 'multiple'] }, health: ref('GPUHealth'), references: array(ref('GPUReference')) }),
GPUIssue: object({ code: string, vmid: integer, message: string }), GPUIssue: object({ code: string, vmid: integer, message: string }),
GPUInventory: object({ node: string, observedAt: date, complete: boolean, visibleVmCount: integer, scannedVmCount: integer, items: array(ref('GPUItem')), issues: array(ref('GPUIssue')), health: object({ status: string, source: string, temperature: string, utilization: string, memory: string, driverBinding: string }) }), 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 }) }),
GPUAgentSample: object({ address: string, observedAt: { ...date, nullable: true }, temperatureC: { ...number, nullable: true }, utilizationPercent: { ...number, nullable: true }, memoryUsedBytes: { ...integer, nullable: true }, memoryTotalBytes: { ...integer, nullable: true }, driver: string, driverBinding: string }, ['address']),
GPUAgentReport: object({ node: string, samples: { ...array(ref('GPUAgentSample')), minItems: 1, maxItems: 256 } }, ['node', 'samples']),
GPUAgentAccepted: object({ accepted: integer, node: string }),
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }), Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }), Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }), Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
@@ -58,6 +63,13 @@ const schemas = {
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }), DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }), SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']), AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']),
AssistantInput: object({ text: { ...string, maxLength: 500 } }, ['text']),
AssistantCandidate: object({ id: string, label: string, kind: string }),
AssistantPlan: object({ action: string, kind: { type: 'string', enum: ['action', 'route', 'blocked'] }, title: string, summary: string, userId: string, userName: string, desktopId: string, desktopName: string, desktopVmid: integer, missing: array(string), candidates: array(ref('AssistantCandidate')), risk: string, confirmationRequired: boolean, path: string, reason: string }),
AssistantPlanResult: object({ runId: string, status: string, input: string, plan: ref('AssistantPlan'), createdAt: date, expiresAt: date }),
AssistantExecuteInput: object({ runId: string }, ['runId']),
AssistantExecuteResult: object({ runId: string, status: string, result: object({ userId: string, desktopId: string, action: string, message: string }) }),
AssistantRun: object({ runId: string, input: string, action: string, status: string, plan: ref('AssistantPlan'), createdAt: date, expiresAt: date, executedAt: { ...date, nullable: true }, result: object({}, []) }),
} }
Object.assign(schemas, { Object.assign(schemas, {
PveConnectionInput: object({ url: string, tokenId: string, tokenSecret: { ...string, format: 'password', writeOnly: true }, caPem: string, revision: string }, ['url', 'tokenId', 'revision']), PveConnectionInput: object({ url: string, tokenId: string, tokenSecret: { ...string, format: 'password', writeOnly: true }, caPem: string, revision: string }, ['url', 'tokenId', 'revision']),
@@ -98,9 +110,9 @@ Object.assign(schemas, {
UpdateAdministrator: object({ displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }), UpdateAdministrator: object({ displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }),
ResetAdministratorPassword: object({ password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }), ResetAdministratorPassword: object({ password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }),
ChangePassword: object({ currentPassword: { type: 'string', format: 'password' }, newPassword: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }), ChangePassword: object({ currentPassword: { type: 'string', format: 'password' }, newPassword: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }),
ImageTemplate: object({ id: string, name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, enabled: boolean, validatedAt: date }), ImageTemplate: object({ id: string, name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, enabled: boolean, hardwareEncodingVerified: boolean, validatedAt: date }),
TemplateInput: object({ name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer }), TemplateInput: object({ name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, hardwareEncodingVerified: boolean }, ['name', 'version', 'os', 'node', 'vmid']),
TemplateUpdate: object({ enabled: boolean }), TemplateUpdate: object({ enabled: boolean, hardwareEncodingVerified: boolean }, []),
DesktopPool: object({ id: string, name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean, createdAt: date, desktopCount: integer, reservedCount: integer, assignedCount: integer, activeCount: integer }), DesktopPool: object({ id: string, name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean, createdAt: date, desktopCount: integer, reservedCount: integer, assignedCount: integer, activeCount: integer }),
PoolInput: object({ name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean }), PoolInput: object({ name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean }),
PoolMembers: object({ desktopIds: array(string) }), PoolMembers: object({ desktopIds: array(string) }),
@@ -119,8 +131,10 @@ schemas.GPUReconcileResult = object({ reconciled: boolean, vmid: string })
schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } }) schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } })
schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string }) schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string })
schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) }) schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) })
schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string }) schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string, source: { type: 'string', enum: ['snapshot', 'stream'] } })
schemas.TerminalLogArchivePage = object({ items: array(ref('TerminalLogArchive')), page: integer, pageSize: integer, total: integer, observedAt: date, retentionDays: integer }) schemas.TerminalLogArchivePage = object({ items: array(ref('TerminalLogArchive')), page: integer, pageSize: integer, total: integer, observedAt: date, retentionDays: integer })
schemas.TerminalLogEntry = object({ capturedAt: { ...date, nullable: true }, level: { type: 'string', enum: ['debug', 'info', 'warn', 'error'] }, message: { ...string, maxLength: 16000 } }, ['message'])
schemas.TerminalLogBatch = object({ bootId: { ...string, maxLength: 120 }, entries: { ...array(ref('TerminalLogEntry')), minItems: 1, maxItems: 100 } }, ['entries'])
schemas.GPUExclusiveMember = object({ desktopId: string, name: string, vmid: integer, powerState: string, powerObserved: boolean, activeSessions: integer, activeTasks: integer, conflicts: array(string), eligible: boolean }) 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.GPUExclusiveStatus = object({ enabled: boolean, node: string, vmids: array(integer), observedAt: date, lockAvailable: boolean, groupComplete: boolean, members: array(ref('GPUExclusiveMember')), conflicts: array(string), allocationReady: boolean, releaseReady: boolean, releasePolicy: string, activeTasks: array(ref('Task')) })
schemas.Administrator.properties.enabled = boolean schemas.Administrator.properties.enabled = boolean
@@ -195,10 +209,14 @@ const paths = {
'/administrators/{id}/sessions': { get: operation('administratorSessions', 'AdministratorSessionList', { list: true, param: true }) }, '/administrators/{id}/sessions': { get: operation('administratorSessions', 'AdministratorSessionList', { list: true, param: true }) },
'/administrators/{id}/sessions/{sessionKey}': { delete: { ...operation('revokeAdministratorSession', null, { param: true, status: 204 }), parameters: [{ name: 'id', in: 'path', required: true, schema: string }, { name: 'sessionKey', in: 'path', required: true, schema: { ...string, minLength: 64, maxLength: 64 } }] } }, '/administrators/{id}/sessions/{sessionKey}': { delete: { ...operation('revokeAdministratorSession', null, { param: true, status: 204 }), parameters: [{ name: 'id', in: 'path', required: true, schema: string }, { name: 'sessionKey', in: 'path', required: true, schema: { ...string, minLength: 64, maxLength: 64 } }] } },
'/overview': { get: operation('overview', 'Overview') }, '/overview': { get: operation('overview', 'Overview') },
'/assistant/plan': { post: operation('assistantPlan', 'AssistantPlanResult', { input: 'AssistantInput' }) },
'/assistant/execute': { post: operation('assistantExecute', 'AssistantExecuteResult', { input: 'AssistantExecuteInput', command: true }) },
'/assistant/runs/{id}': { get: operation('assistantRun', 'AssistantRun', { param: true }) },
'/settings': { get: operation('settings', 'Settings') }, '/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) }, '/resources': { get: operation('resources', 'ResourceList', { list: true }) },
'/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) }, '/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) },
'/resources/gpu-exclusive': { get: operation('gpuExclusiveStatus', 'GPUExclusiveStatus', { node: true }) }, '/resources/gpu-exclusive': { get: operation('gpuExclusiveStatus', 'GPUExclusiveStatus', { node: true }) },
'/gpu-agent/health': { post: operation('ingestGPUHealth', 'GPUAgentAccepted', { input: 'GPUAgentReport', security: 'gpuAgent', status: 202 }) },
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) }, '/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
'/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) }, '/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) },
'/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) }, '/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) },
@@ -211,6 +229,7 @@ const paths = {
'/desktops/clone/check': { post: operation('checkClone', 'CloneCheck', { input: 'CloneInput' }) }, '/desktops/clone/check': { post: operation('checkClone', 'CloneCheck', { input: 'CloneInput' }) },
'/desktops/clone': { post: operation('cloneDesktop', 'Task', { input: 'CloneInput', command: true, status: 202 }) }, '/desktops/clone': { post: operation('cloneDesktop', 'Task', { input: 'CloneInput', command: true, status: 202 }) },
'/desktops/{id}/connection': { put: operation('configureDesktop', 'Desktop', { param: true, input: 'ConnectionInput' }) }, '/desktops/{id}/connection': { put: operation('configureDesktop', 'Desktop', { param: true, input: 'ConnectionInput' }) },
'/desktops/{id}/readiness/check': { post: operation('checkDesktopReadiness', 'DesktopReadiness', { param: true }) },
'/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) }, '/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) },
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) }, '/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) }, '/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
@@ -234,6 +253,7 @@ const paths = {
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') }, '/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
'/diagnostics/export': { get: operation('exportDiagnostics', null) }, '/diagnostics/export': { get: operation('exportDiagnostics', null) },
'/terminal-logs': { get: operation('terminalLogArchives', 'TerminalLogArchivePage') }, '/terminal-logs': { get: operation('terminalLogArchives', 'TerminalLogArchivePage') },
'/terminal/logs': { post: operation('appendTerminalLogs', 'Accepted', { input: 'TerminalLogBatch', security: 'deviceBearer', status: 202 }) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) }, '/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) },
'/audit-logs/export': { get: operation('exportAudits', null, { period: true }) }, '/audit-logs/export': { get: operation('exportAudits', null, { period: true }) },
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) }, '/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
@@ -274,7 +294,7 @@ paths['/audit-logs/export'].get.parameters.push(...auditFilters, { name: 'keywor
paths['/audit-logs/export'].get.description = 'Authenticated administrators of all roles can export matching audit fields (maximum 10000 rows). No pagination or silent truncation. CSV uses UTF-8 BOM and formula-safe cells. Successful preparation is audited.' paths['/audit-logs/export'].get.description = 'Authenticated administrators of all roles can export matching audit fields (maximum 10000 rows). No pagination or silent truncation. CSV uses UTF-8 BOM and formula-safe cells. Successful preparation is audited.'
paths['/audit-logs/export'].get.responses[200] = { description: 'Complete filtered CSV, with UTC timestamps and raw action/result codes', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } } paths['/audit-logs/export'].get.responses[200] = { description: 'Complete filtered CSV, with UTC timestamps and raw action/result codes', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } }
paths['/audit-logs/export'].get.responses[413] = { description: 'Over 10000 matching records; narrow the filter', content: json(ref('Error')) } paths['/audit-logs/export'].get.responses[413] = { description: 'Over 10000 matching records; narrow the filter', content: json(ref('Error')) }
const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } } const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' }, gpuAgent: { type: 'apiKey', in: 'header', name: 'X-GPU-Agent-Token', description: '仅供受信任计算节点采集器使用的独立令牌' } } } }
const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url)) const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
mkdirSync(directory, { recursive: true }) mkdirSync(directory, { recursive: true })
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`) writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)