功能:完善云管助手与GPU故障恢复核对
This commit is contained in:
1 parent
b6026f9759
commit
9f7b01e4db
42 files changed
+3576
-120
No files matched your search
@@ -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': '核对显卡切换' }
|
||||
@@ -37,10 +37,12 @@ export type AdministratorSession = components['schemas']['AdministratorSession']
|
||||
export type CreateAdministrator = components['schemas']['CreateAdministrator']
|
||||
export type UpdateAdministrator = components['schemas']['UpdateAdministrator']
|
||||
export type Desktop = components['schemas']['Desktop']
|
||||
export type DesktopReadiness = components['schemas']['DesktopReadiness']
|
||||
export type ImageTemplate = components['schemas']['ImageTemplate']
|
||||
export type DesktopPool = components['schemas']['DesktopPool']
|
||||
export type PoolInput = components['schemas']['PoolInput']
|
||||
export type TemplateInput = components['schemas']['TemplateInput']
|
||||
export type TemplateUpdate = components['schemas']['TemplateUpdate']
|
||||
export type CloneInput = components['schemas']['CloneInput']
|
||||
export type Storage = components['schemas']['Storage']
|
||||
export type ConnectionInput = components['schemas']['ConnectionInput']
|
||||
@@ -64,6 +66,8 @@ export type BindingInput = components['schemas']['BindingInput']
|
||||
export type ImportInput = components['schemas']['ImportInput']
|
||||
export type EnrollmentInput = components['schemas']['EnrollmentInput']
|
||||
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 TerminalAction = components['schemas']['TerminalAction']['action']
|
||||
let accessToken = ''
|
||||
@@ -91,6 +95,9 @@ const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() })
|
||||
const query = { page: 1, pageSize: 100 }
|
||||
export const api = {
|
||||
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' })),
|
||||
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 } } })),
|
||||
@@ -161,7 +168,7 @@ export const api = {
|
||||
templates: (page = 1) => result(client.GET('/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 })),
|
||||
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 } } })),
|
||||
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 })),
|
||||
@@ -170,6 +177,7 @@ export const api = {
|
||||
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 })),
|
||||
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() } })),
|
||||
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 } })),
|
||||
|
||||
Vendored
+839
-1
@@ -644,6 +644,54 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -708,6 +756,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -900,6 +964,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1270,6 +1350,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1569,6 +1665,22 @@ export interface components {
|
||||
poolId?: string | null;
|
||||
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: {
|
||||
id: string;
|
||||
sn: string;
|
||||
@@ -1670,6 +1782,25 @@ export interface components {
|
||||
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: {
|
||||
id: string;
|
||||
kind: string;
|
||||
@@ -1887,6 +2018,69 @@ export interface components {
|
||||
message?: string;
|
||||
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: {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
@@ -2242,6 +2436,7 @@ export interface components {
|
||||
node: string;
|
||||
vmid: number;
|
||||
enabled: boolean;
|
||||
hardwareEncodingVerified: boolean;
|
||||
/** Format: date-time */
|
||||
validatedAt: string;
|
||||
};
|
||||
@@ -2252,9 +2447,11 @@ export interface components {
|
||||
os: "Windows" | "Ubuntu" | "麒麟";
|
||||
node: string;
|
||||
vmid: number;
|
||||
hardwareEncodingVerified?: boolean;
|
||||
};
|
||||
TemplateUpdate: {
|
||||
enabled: boolean;
|
||||
enabled?: boolean;
|
||||
hardwareEncodingVerified?: boolean;
|
||||
};
|
||||
DesktopPool: {
|
||||
id: string;
|
||||
@@ -2409,6 +2606,8 @@ export interface components {
|
||||
agentVersion: string;
|
||||
kioskState: string;
|
||||
logText: string;
|
||||
/** @enum {string} */
|
||||
source: "snapshot" | "stream";
|
||||
};
|
||||
TerminalLogArchivePage: {
|
||||
items: components["schemas"]["TerminalLogArchive"][];
|
||||
@@ -2419,6 +2618,17 @@ export interface components {
|
||||
observedAt: string;
|
||||
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: {
|
||||
desktopId: 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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -30,7 +30,7 @@ const items = [
|
||||
{ path: '/monitor', title: '监控告警', icon: DataAnalysis, group: 'manage' },
|
||||
{ path: '/templates', title: '镜像模板', icon: Files, 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: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' },
|
||||
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
|
||||
|
||||
@@ -21,7 +21,7 @@ export const router = createRouter({ history: createWebHistory(), routes: [
|
||||
{ path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } },
|
||||
{ path: 'terminals', component: () => import('../views/Terminals.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: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } },
|
||||
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAction } from '../composables/useAction'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
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 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 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))
|
||||
@@ -28,6 +30,18 @@ async function create() {
|
||||
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 = '' }
|
||||
}
|
||||
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() {
|
||||
if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return
|
||||
const userId = person.value.id, desktopIds = [...selected.value], operation = action.value
|
||||
@@ -36,9 +50,14 @@ async function save() {
|
||||
</script>
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row-actions{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
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 { useAction } from '../composables/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 offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length)
|
||||
const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length)
|
||||
const request = ref(''), plan = ref<{ kind: 'grant' | 'route' | 'blocked'; title: string; summary: string; user?: User; desktop?: Desktop; path?: string; reason?: string } | null>(null), executed = ref(false)
|
||||
const unassignedDesktops = computed(() => data.desktops.filter(d => !data.users.some(u => u.desktopIds.includes(d.id))))
|
||||
function generatePlan() {
|
||||
executed.value = false
|
||||
const request = ref(''), plan = ref<AssistantPlanResult | null>(null), runningPlan = ref(false), executed = ref(false), assistantError = ref('')
|
||||
async function generatePlan() {
|
||||
const text = request.value.trim()
|
||||
if (!text) { plan.value = null; return }
|
||||
if (/分配|授权|安排/.test(text)) {
|
||||
const userMatches = data.users.filter(u => text.includes(u.displayName) || text.includes(u.username))
|
||||
const desktopMatches = data.desktops.filter(d => text.includes(d.name) || text.includes(String(d.vmid)))
|
||||
if (userMatches.length !== 1) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号;同名员工需要到“员工与桌面”页面选择准确对象。', reason: userMatches.length ? '匹配到多个员工' : '没有匹配到员工' }; return }
|
||||
const user = userMatches[0]
|
||||
if (!user) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号。', reason: '没有匹配到员工' }; return }
|
||||
if (desktopMatches.length > 1) { plan.value = { kind: 'blocked', title: '还需要确认桌面', summary: '匹配到多个同名桌面,请写出 VMID,或到“员工与桌面”页面选择准确对象。', reason: '匹配到多个桌面' }; return }
|
||||
const desktop = (desktopMatches.length === 1 ? desktopMatches[0] : unassignedDesktops.value[0])
|
||||
if (!desktop) { plan.value = { kind: 'blocked', title: '暂时无法生成授权方案', summary: '没有找到可分配的已有桌面。请先由运维纳管桌面,或到高级版检查模板和桌面池。', reason: '没有可用桌面' }; return }
|
||||
if (user.desktopIds.includes(desktop.id)) { plan.value = { kind: 'blocked', title: '员工已经有这台桌面', summary: `${user.displayName} 已拥有“${desktop.name}”的使用权限,不需要重复授权。`, user, desktop, reason: '授权已存在' }; return }
|
||||
plan.value = { kind: 'grant', title: '分配已有桌面', summary: `为 ${user.displayName} 追加“${desktop.name}”的使用权限。不会新建 VM,也不会自动开机。`, user, desktop }
|
||||
return
|
||||
}
|
||||
if (/打不开|无法连接|连不上|离线/.test(text)) { plan.value = { kind: 'route', title: '进入问题处理', summary: '先检查设备在线、员工关联、桌面授权、电源和连接服务状态。', path: '/help' }; return }
|
||||
if (/异常|故障|告警|问题/.test(text)) { plan.value = { kind: 'route', title: '查看需要处理的记录', summary: '打开操作记录,查看失败任务、结果未确认项和最近操作。', path: '/records' }; return }
|
||||
plan.value = { kind: 'blocked', title: '暂不支持这个场景', summary: '当前无模型接入,只支持“给某员工分配一台已有桌面”、问题处理和操作记录。请改用明确的员工姓名与桌面名称。', reason: '固定场景未匹配' }
|
||||
if (!text || runningPlan.value) return
|
||||
runningPlan.value = true; assistantError.value = ''; executed.value = false
|
||||
try { plan.value = await api.assistantPlan(text) } catch (e) { assistantError.value = errorMessage(e) }
|
||||
finally { runningPlan.value = false }
|
||||
}
|
||||
function chooseCandidate(candidate: { label: string }) { request.value = `${request.value.trim()} ${candidate.label}`.trim(); void generatePlan() }
|
||||
async function executePlan() {
|
||||
if (!plan.value || plan.value.kind !== 'grant' || !plan.value.user || !plan.value.desktop || !auth.canAdmin || busy.value) return
|
||||
if (!await confirm('确认分配桌面', `将为 ${plan.value.user.displayName} 追加“${plan.value.desktop.name}”的使用权限。其他授权保持不变。`)) return
|
||||
try {
|
||||
await api.batchBindings({ userIds: [plan.value.user.id], desktopIds: [plan.value.desktop.id], action: 'grant' })
|
||||
await data.refresh(); executed.value = true; ElMessage.success('方案已执行,授权已保存')
|
||||
} catch (e) { ElMessage.error(errorMessage(e)) }
|
||||
if (!plan.value || plan.value.plan.kind !== 'action' || !auth.canAdmin || busy.value || executed.value) return
|
||||
if (!await confirm('确认执行方案', plan.value.plan.summary)) return
|
||||
try { await api.assistantExecute(plan.value.runId); await data.refresh(); executed.value = true; ElMessage.success('方案已执行,操作已保存') }
|
||||
catch (e) { ElMessage.error(errorMessage(e)); await generatePlan() }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div>
|
||||
<div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div>
|
||||
<section class="panel simple-section 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>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from '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 { usePlatform } from '../stores/platform'
|
||||
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)) })
|
||||
const hardwareDesktop = ref<Desktop>(), snapshotDesktop = 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 })
|
||||
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 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 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))
|
||||
@@ -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-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" />
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
<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}
|
||||
@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))}}
|
||||
|
||||
@@ -27,6 +27,9 @@ async function load() {
|
||||
finally { if (version === generation) busy.value = false }
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert>
|
||||
<template v-if="inventory">
|
||||
<section v-if="exclusive?.enabled" class="panel gpu-exclusive"><div class="gpu-exclusive-heading"><div><h2>独占资源状态</h2><p class="subtext">分配前检查与占用释放条件</p></div><el-tag :type="exclusive.allocationReady ? 'success' : 'warning'">{{ exclusive.allocationReady ? '可分配' : '需处理冲突' }}</el-tag></div><div class="gpu-exclusive-summary"><span>锁 {{ exclusive.lockAvailable ? '可用' : '占用' }}</span><span>成员 {{ exclusive.members.length }}/{{ exclusive.vmids.length }}</span><span>释放 {{ exclusive.releaseReady ? '可检查' : '不可释放' }}</span></div><el-alert v-if="exclusive.conflicts.length" type="warning" :closable="false" title="当前冲突"><ul class="gpu-conflicts"><li v-for="(item, index) in exclusive.conflicts" :key="index">{{ item }}</li></ul></el-alert><el-table :data="exclusive.members" size="small" style="margin-top:14px"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column prop="vmid" label="VMID" width="80" /><el-table-column label="实际电源" width="110"><template #default="scope"><el-tag :type="scope.row.powerObserved ? (scope.row.powerState === 'stopped' ? 'success' : 'warning') : 'info'">{{ scope.row.powerObserved ? scope.row.powerState : '未采集' }}</el-tag></template></el-table-column><el-table-column label="会话 / 任务" width="110"><template #default="scope">{{ scope.row.activeSessions }} / {{ scope.row.activeTasks }}</template></el-table-column><el-table-column label="分配" width="90"><template #default="scope"><el-tag :type="scope.row.eligible ? 'success' : 'warning'">{{ scope.row.eligible ? '可用' : '冲突' }}</el-tag></template></el-table-column></el-table><p class="secondary gpu-release-note">释放仅表示当前无会话和未完成任务,平台不会自动关机或重放切换操作;需从终端准备或任务核对流程完成实际变更。</p></section>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import SystemIcon from '../components/SystemIcon.vue'
|
||||
import CloneDesktopDialog from '../components/CloneDesktopDialog.vue'
|
||||
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 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 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 } }
|
||||
@@ -17,7 +17,7 @@ onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
async function openRegister() {
|
||||
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 }
|
||||
}
|
||||
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) {
|
||||
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 }
|
||||
}
|
||||
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-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 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 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>
|
||||
<p class="catalog-note">模板登记会校验 PVE 模板标记、磁盘与直通配置。模板修改后需重新验证;停用会阻止新的创建请求。现有业务虚拟机不会自动转换为模板。</p>
|
||||
</section>
|
||||
<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-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>
|
||||
</el-dialog>
|
||||
<CloneDesktopDialog v-model="cloneVisible" :template-id="templateId" />
|
||||
|
||||
+12
-11
@@ -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 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 }
|
||||
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 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 }
|
||||
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 }
|
||||
async function saveProfile() {
|
||||
if (!profile.displayName.trim() || busy.value) return
|
||||
const existing = data.users.find(u => u.id === profileId.value)
|
||||
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用用户' : '停用用户', '状态变更将作废该用户未消费的连接票据。停用阻止后续连接,保留授权关系和已有串流。')) return
|
||||
if (await run(() => api.updateUser(profileId.value, profile), '用户资料已保存')) profileVisible.value = false
|
||||
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更将作废该员工未消费的连接票据。停用阻止后续连接,保留授权关系和已有串流。')) return
|
||||
if (await run(() => api.updateUser(profileId.value, profile), '员工资料已保存')) profileVisible.value = false
|
||||
}
|
||||
function selectUsers(users: User[]) { checked.value = users }
|
||||
function openBatch() { batchUsers.value = [...checked.value]; batch.action = 'grant'; batch.desktopIds = []; batchVisible.value = true }
|
||||
async function saveBatch() {
|
||||
if (!batchUsers.value.length || !batch.desktopIds.length || busy.value) return
|
||||
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
|
||||
}
|
||||
</script>
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
<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="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="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="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" />
|
||||
</template>
|
||||
|
||||
<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}
|
||||
</style>
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
PVE_CA_FILE: /run/secrets/pve-ca.pem
|
||||
VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-}
|
||||
VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-}
|
||||
VDI_GPU_AGENT_TOKEN: ${VDI_GPU_AGENT_TOKEN:-}
|
||||
volumes:
|
||||
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
|
||||
depends_on:
|
||||
|
||||
@@ -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`。接口只读,不会关机、切换显卡或重放任务。
|
||||
- 终端成功回传 `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 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。
|
||||
|
||||
## 真实边界
|
||||
|
||||
@@ -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 保持关机。临时测试数据库容器已清理。
|
||||
+1211
-4
File diff suppressed because it is too large.
Load diff
@@ -14,10 +14,11 @@ type Config struct {
|
||||
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
|
||||
ExclusiveGPUNode string
|
||||
ExclusiveGPUVMIDs []int
|
||||
GPUAgentToken string
|
||||
}
|
||||
|
||||
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"))
|
||||
ids := strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_VMIDS"))
|
||||
if (c.ExclusiveGPUNode == "") != (ids == "") {
|
||||
|
||||
@@ -31,15 +31,16 @@ type Desktop struct {
|
||||
}
|
||||
|
||||
type ImageTemplate struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Version string `json:"version" db:"version"`
|
||||
OS string `json:"os" db:"os"`
|
||||
Node string `json:"node" db:"node"`
|
||||
VMID int `json:"vmid" db:"vmid"`
|
||||
Digest string `json:"-" db:"digest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
ValidatedAt time.Time `json:"validatedAt" db:"validated_at"`
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Version string `json:"version" db:"version"`
|
||||
OS string `json:"os" db:"os"`
|
||||
Node string `json:"node" db:"node"`
|
||||
VMID int `json:"vmid" db:"vmid"`
|
||||
Digest string `json:"-" db:"digest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
HardwareEncodingVerified bool `json:"hardwareEncodingVerified" db:"hardware_encoding_verified"`
|
||||
ValidatedAt time.Time `json:"validatedAt" db:"validated_at"`
|
||||
}
|
||||
type DesktopPool struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *Server) settings(c *gin.Context) {
|
||||
if pve != nil {
|
||||
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) {
|
||||
items := []model.Resource{}
|
||||
@@ -514,6 +514,7 @@ func (s *Server) RunWorker(ctx context.Context) {
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
provisionDone := make(chan struct{}, 1)
|
||||
provisionBusy := false
|
||||
var nextGPUCheck time.Time
|
||||
defer func() {
|
||||
stop()
|
||||
if provisionBusy {
|
||||
@@ -532,7 +533,8 @@ func (s *Server) RunWorker(ctx context.Context) {
|
||||
if err = conn.PingContext(ctx); err != nil {
|
||||
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
|
||||
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 {
|
||||
@@ -563,6 +565,14 @@ func (s *Server) RunWorker(ctx context.Context) {
|
||||
_ = 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
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,12 @@ func (s *Server) templateList(c *gin.Context) {
|
||||
func (s *Server) registerTemplate(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
var in struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
Version string `json:"version" binding:"required,max=40"`
|
||||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||
Node string `json:"node" binding:"required,max=100"`
|
||||
VMID int `json:"vmid" binding:"min=100,max=999999999"`
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
Version string `json:"version" binding:"required,max=40"`
|
||||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||
Node string `json:"node" binding:"required,max=100"`
|
||||
VMID int `json:"vmid" binding:"min=100,max=999999999"`
|
||||
HardwareEncodingVerified bool `json:"hardwareEncodingVerified"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
@@ -82,7 +83,7 @@ func (s *Server) registerTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记")
|
||||
return
|
||||
@@ -96,13 +97,18 @@ func (s *Server) registerTemplate(c *gin.Context) {
|
||||
}
|
||||
func (s *Server) updateTemplate(c *gin.Context) {
|
||||
var in struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
HardwareEncodingVerified *bool `json:"hardwareEncodingVerified"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if in.Enabled == nil && in.HardwareEncodingVerified == nil {
|
||||
s.fail(c, 400, "invalid_request", "至少提供一个模板状态字段")
|
||||
return
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
return template, errors.New("模板不存在或已停用")
|
||||
}
|
||||
if !template.HardwareEncodingVerified {
|
||||
return template, errors.New("模板尚未确认硬件编码,请先在镜像模板中完成硬件编码验收")
|
||||
}
|
||||
config, err := pve.VMConfig(ctx, template.Node, template.VMID)
|
||||
if err != nil {
|
||||
return template, errors.New("无法读取源模板配置")
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
tpl := out["id"].(string)
|
||||
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")
|
||||
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) {
|
||||
for _, outcome := range []string{"ambiguous", "failed"} {
|
||||
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 != ""
|
||||
}
|
||||
@@ -63,6 +63,7 @@ type terminalLogArchive struct {
|
||||
AgentVersion string `json:"agentVersion" db:"agent_version"`
|
||||
KioskState string `json:"kioskState" db:"kiosk_state"`
|
||||
LogText string `json:"logText" db:"log_text"`
|
||||
Source string `json:"source" db:"source"`
|
||||
}
|
||||
type terminalLogArchivePage struct {
|
||||
Items []terminalLogArchive `json:"items"`
|
||||
@@ -87,13 +88,19 @@ func (s *Server) terminalLogArchives(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
args := []any{from, to, terminalID, keyword}
|
||||
var total int
|
||||
if err := s.DB.GetContext(c.Request.Context(), &total, `SELECT count(*) FROM terminal_log_archives WHERE captured_at>=$1 AND captured_at<=$2 AND ($3='' OR terminal_id=$3) AND ($4='' OR log_text ILIKE '%'||$4||'%')`, args...); err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
items := []terminalLogArchive{}
|
||||
args = append(args, size, (page-1)*size)
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT a.id,a.task_id,a.terminal_id,t.name terminal_name,a.captured_at,a.boot_id,a.agent_version,a.kiosk_state,a.log_text FROM terminal_log_archives a JOIN terminals t ON t.id=a.terminal_id WHERE a.captured_at>=$1 AND a.captured_at<=$2 AND ($3='' OR a.terminal_id=$3) AND ($4='' OR a.log_text ILIKE '%'||$4||'%') ORDER BY a.captured_at DESC,a.id DESC LIMIT $5 OFFSET $6`, args...); err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,8 +291,12 @@ func (s *Server) prepareGPUDesktop(c *gin.Context) {
|
||||
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`)
|
||||
if err == nil {
|
||||
if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && previous.Status != "timeout" {
|
||||
s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": previous.Status, "message": previous.Message})
|
||||
if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && (previous.Status != "timeout" || s.gpuRecoveryPending(previous)) {
|
||||
status := previous.Status
|
||||
if status == "timeout" {
|
||||
status = "running"
|
||||
}
|
||||
s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": status, "message": previous.Message})
|
||||
return
|
||||
}
|
||||
s.fail(c, 409, "gpu_switch_busy", "已有显卡切换任务或未确认结果,请稍后重试或联系管理员")
|
||||
@@ -343,7 +347,11 @@ func (s *Server) gpuPrepareStatus(c *gin.Context) {
|
||||
s.dbError(c, err)
|
||||
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 {
|
||||
@@ -359,15 +367,11 @@ func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepar
|
||||
}
|
||||
|
||||
func sunshineReady(ctx context.Context, endpoint string) bool {
|
||||
host := endpoint
|
||||
if parsed, _, err := net.SplitHostPort(endpoint); err == nil {
|
||||
host = parsed
|
||||
}
|
||||
// Platform endpoints are hostnames/IPs, never arbitrary URLs or paths.
|
||||
if strings.ContainsAny(host, "/\\@?#") || host == "" {
|
||||
return false
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, "47989")+"/serverinfo", nil)
|
||||
return sunshineReadyAt(ctx, endpoint, "47989")
|
||||
}
|
||||
|
||||
func sunshineHTTPProbe(ctx context.Context, host, port string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, port)+"/serverinfo", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -532,6 +536,7 @@ func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) {
|
||||
expected = "running"
|
||||
}
|
||||
if e != nil || power != expected {
|
||||
uncertain = true
|
||||
err = errors.New("final GPU ownership check failed")
|
||||
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)
|
||||
status = "failed"
|
||||
message = "桌面准备失败,请检查桌面授权、占用状态及 PVE"
|
||||
if uncertain {
|
||||
if uncertain || (state.Phase == "service-waiting" && s.knownGPURecovery(state)) {
|
||||
status = "timeout"
|
||||
message = "电源操作或服务状态尚未确认,已阻止再次切换;请管理员核对 PVE 原任务"
|
||||
message = "电源操作尚未确认,平台正在自动核对原任务;不会重复发送电源命令"
|
||||
if state.UPID == "" {
|
||||
message = "未收到 PVE 原任务编号,请管理员核查;不会重复发送电源命令"
|
||||
}
|
||||
}
|
||||
}
|
||||
finalCtx, finish := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
@@ -17,11 +17,12 @@ import (
|
||||
)
|
||||
|
||||
type exclusiveTestPVE struct {
|
||||
mu sync.Mutex
|
||||
power map[int]string
|
||||
actions []string
|
||||
mode string
|
||||
confirmed bool
|
||||
mu sync.Mutex
|
||||
power map[int]string
|
||||
actions []string
|
||||
mode string
|
||||
confirmed bool
|
||||
readFailures int
|
||||
}
|
||||
|
||||
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
|
||||
switch {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -62,7 +66,11 @@ func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) {
|
||||
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"):
|
||||
fmt.Sscanf(path, "/nodes/gpu-node/qemu/%d/status/current", &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)
|
||||
}
|
||||
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, "")
|
||||
if mode == "ambiguous" {
|
||||
expect(t, 409, code, out)
|
||||
|
||||
@@ -103,6 +103,11 @@ func (s *Server) gpuInventory(c *gin.Context) {
|
||||
s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限")
|
||||
return
|
||||
}
|
||||
healthSamples, err := s.latestGPUHealth(ctx, node)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
items := []gpuItem{}
|
||||
observedAt := time.Now().UTC()
|
||||
for _, d := range devices {
|
||||
@@ -122,7 +127,23 @@ func (s *Server) gpuInventory(c *gin.Context) {
|
||||
if strings.TrimSpace(d.Driver) != "" {
|
||||
driverBinding = "bound"
|
||||
}
|
||||
items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, Health: gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}, References: []gpuReference{}})
|
||||
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")
|
||||
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 })
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (s *Server) observe(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
// Bounded retention. Current incidents and active session records are preserved.
|
||||
for _, query := range []string{"DELETE FROM login_limits WHERE window_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -74,7 +74,14 @@ func (s *Server) validateTemplate(c *gin.Context) {
|
||||
s.fail(c, 502, "invalid_template", "模板配置摘要缺失")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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/terminal/register", s.register)
|
||||
r.POST("/api/v1/gpu-agent/health", s.gpuAgentAuth, s.ingestGPUHealth)
|
||||
t := r.Group("/api/v1/terminal", s.deviceAuth)
|
||||
t.POST("/heartbeat", s.heartbeat)
|
||||
t.POST("/logs", s.appendTerminalLogs)
|
||||
t.GET("/config", s.deviceConfig)
|
||||
t.POST("/config/ack", s.policyAck)
|
||||
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.DELETE("/administrators/:id/sessions/:sessionKey", s.allow("super_admin"), s.revokeAdministratorSession)
|
||||
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("/monitor/series", s.resourceSeries)
|
||||
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.GET("/desktops/:id", s.desktop)
|
||||
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.GET("/terminals", s.terminals)
|
||||
a.GET("/terminals/:id/metrics", s.terminalMetrics)
|
||||
|
||||
@@ -36,7 +36,7 @@ func newFixtureWorker(t *testing.T, worker bool) *fixture {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,58 @@ func (s *Server) heartbeat(c *gin.Context) {
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
|
||||
@@ -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_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 (
|
||||
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,
|
||||
enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(),
|
||||
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 (
|
||||
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),
|
||||
@@ -124,6 +139,17 @@ CREATE TABLE IF NOT EXISTS resource_samples (
|
||||
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);
|
||||
-- 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 (
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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) {
|
||||
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
|
||||
}
|
||||
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
|
||||
var status struct {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -2,6 +2,7 @@ package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -58,6 +59,70 @@ func TestTLSAndTaskLifecycle(t *testing.T) {
|
||||
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) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
|
||||
|
||||
@@ -17,6 +17,8 @@ const schemas = {
|
||||
MFARecovery: object({ recoveryCodes: array(string) }),
|
||||
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 }),
|
||||
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 } }),
|
||||
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 }),
|
||||
@@ -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')) }),
|
||||
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 }) }),
|
||||
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 }),
|
||||
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 }),
|
||||
@@ -58,6 +63,13 @@ const schemas = {
|
||||
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
|
||||
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
|
||||
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, {
|
||||
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 }),
|
||||
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 } }),
|
||||
ImageTemplate: object({ id: string, name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, enabled: boolean, validatedAt: date }),
|
||||
TemplateInput: object({ name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer }),
|
||||
TemplateUpdate: object({ enabled: boolean }),
|
||||
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, hardwareEncodingVerified: boolean }, ['name', 'version', 'os', 'node', 'vmid']),
|
||||
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 }),
|
||||
PoolInput: object({ name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean }),
|
||||
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.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string })
|
||||
schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) })
|
||||
schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string })
|
||||
schemas.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.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.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
|
||||
@@ -195,10 +209,14 @@ const paths = {
|
||||
'/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 } }] } },
|
||||
'/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') },
|
||||
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
|
||||
'/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { 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/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: 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': { post: operation('cloneDesktop', 'Task', { input: 'CloneInput', command: true, status: 202 }) },
|
||||
'/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 }) },
|
||||
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
|
||||
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
|
||||
@@ -234,6 +253,7 @@ const paths = {
|
||||
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
|
||||
'/diagnostics/export': { get: operation('exportDiagnostics', null) },
|
||||
'/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/export': { get: operation('exportAudits', null, { period: true }) },
|
||||
'/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.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')) }
|
||||
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))
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)
|
||||
|
||||
Reference in new issue
Block a user