功能:完善通用云管工作台、审计与诊断能力

This commit is contained in:
songyiming committed 2026-11-11 18:46:16 +08:00
1 parent 5ac692724d
commit 9f05aa20ea
225 files changed
+14266 -141

No files matched your search

+60 -8
View File
@@ -8,7 +8,13 @@ const object = (properties, required = Object.keys(properties)) => ({ type: 'obj
const schemas = {
Error: object({ code: string, message: string, requestId: string }),
Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }),
LoginInput: object({ username: string, password: string }),
LoginInput: object({ username: string, password: string, code: { ...string, maxLength: 80 } }, ['username', 'password']),
MFAStatus: object({ enabled: boolean, recoveryRemaining: integer }),
MFASetupInput: object({ password: { ...string, format: 'password', maxLength: 200 } }),
MFASetup: object({ secret: string, provisioningUri: string, expiresAt: date }),
MFAConfirm: object({ code: { ...string, minLength: 6, maxLength: 6 } }),
MFACredential: object({ password: { ...string, format: 'password', maxLength: 200 }, code: { ...string, maxLength: 80 } }),
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 }),
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 } }),
@@ -74,9 +80,9 @@ Object.assign(schemas, {
CsvImportPreview: object({ kind: string, totalRows: integer, validRows: integer, errorRows: integer, previewToken: string, rows: array(ref('CsvImportRow')) }),
CsvImportResult: object({ id: string, kind: string, count: integer, items: array(object({ line: integer, key: string, id: string })), activationSecrets: array(object({ sn: string, activationSecret: string })), replayed: boolean }),
SessionRecord: object({ id: string, sessionId: string, terminalId: string, terminalName: string, desktopId: string, desktopName: string, username: string, displayName: string, state: { type: 'string', enum: ['connecting', 'connected', 'ended', 'interrupted'] }, source: { type: 'string', enum: ['ticket', 'observed'] }, reservedAt: date, connectedAt: { ...date, nullable: true }, lastReportedAt: date, endedAt: { ...date, nullable: true }, endReason: string }),
AlertRuleInput: object({ name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRule: object({ id: string, name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRecord: object({ id: string, ruleId: string, ruleName: string, metric: string, targetId: string, targetName: string, value: number, threshold: number, status: { type: 'string', enum: ['firing', 'resolved'] }, startedAt: date, openedAt: date, resolvedAt: { ...date, nullable: true }, resolveReason: string, acknowledgedAt: { ...date, nullable: true }, acknowledgedBy: string }),
AlertRuleInput: object({ name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline', 'terminal_cpu', 'terminal_memory', 'terminal_temperature'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRule: object({ id: string, name: string, metric: { type: 'string', enum: ['node_cpu', 'node_memory', 'node_storage', 'node_unavailable', 'terminal_offline', 'terminal_cpu', 'terminal_memory', 'terminal_temperature'] }, threshold: number, holdSeconds: integer, enabled: boolean }),
AlertRecord: object({ id: string, ruleId: string, ruleName: string, metric: string, targetId: string, targetName: string, value: number, threshold: number, status: { type: 'string', enum: ['firing', 'resolved'] }, startedAt: date, openedAt: date, resolvedAt: { ...date, nullable: true }, resolveReason: string, acknowledgedAt: { ...date, nullable: true }, acknowledgedBy: string, dataFresh: { ...boolean, nullable: true } }),
ResourcePoint: object({ time: date, cpu: { ...number, nullable: true }, memory: { ...number, nullable: true }, storage: { ...number, nullable: true }, samples: integer }),
ResourceSeries: object({ items: array(ref('ResourcePoint')), bucketSeconds: integer, retentionDays: integer }),
TerminalPolicyInput: object({ name: string, version: string, heartbeatInterval: { ...integer, minimum: 15, maximum: 60 }, commandPollInterval: { ...integer, minimum: 3, maximum: 30 }, desktopRefreshInterval: { ...integer, minimum: 5, maximum: 120 } }),
@@ -86,6 +92,7 @@ Object.assign(schemas, {
PolicyResult: object({ id: string, terminalId: string, terminalName: string, group: string, protocol: integer, deploymentId: string, templateName: string, templateVersion: string, configVersion: integer, status: { type: 'string', enum: ['unconfigured', 'pending', 'applied', 'failed', 'superseded'] }, errorCode: string, acknowledgedAt: { ...date, nullable: true }, online: boolean, enabled: boolean, registered: boolean }),
PolicyAck: object({ deploymentId: string, configVersion: integer, status: { type: 'string', enum: ['applied', 'failed'] }, errorCode: { type: 'string', enum: ['invalid_policy', 'persist_failed', 'runtime_unavailable'] } }, ['deploymentId', 'configVersion', 'status']),
AdministratorAccount: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }),
AdministratorSession: object({ key: string, createdAt: { ...date, nullable: true }, expiresAt: date, clientIp: string, userAgent: string, current: boolean }),
CreateAdministrator: object({ username: { type: 'string', minLength: 3, maxLength: 80 }, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }),
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 } }),
@@ -103,6 +110,14 @@ Object.assign(schemas, {
})
schemas.Desktop.properties.poolId = { ...string, nullable: true }
schemas.Desktop.properties.setupRequired = boolean
schemas.DeviceDesktop = object({ ...schemas.Desktop.properties, powerOnConnect: boolean })
schemas.DeviceDesktopList = object({ items: array(ref('DeviceDesktop')), total: integer, page: integer, pageSize: integer })
schemas.GPUPrepareInput = object({ desktopId: { ...string, maxLength: 100 } })
schemas.GPUPrepareResult = object({ taskId: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string })
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.Administrator.properties.enabled = boolean
schemas.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 }
schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true }
@@ -111,7 +126,7 @@ schemas.DeviceConfig.required = Object.keys(schemas.DeviceConfig.properties)
// Older frontends can omit this display field; the API always supplies it.
schemas.Terminal.properties.sessionState = string
schemas.Terminal.properties.enabled = boolean
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM', 'ImageTemplate', 'DesktopPool', 'Storage', 'AdministratorAccount', 'TerminalPolicy', 'PolicyDeployment', 'PolicyResult', 'SessionRecord', 'AlertRule', 'AlertRecord', 'PowerBatch']) {
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM', 'ImageTemplate', 'DesktopPool', 'Storage', 'AdministratorAccount', 'AdministratorSession', 'TerminalPolicy', 'PolicyDeployment', 'PolicyResult', 'SessionRecord', 'AlertRule', 'AlertRecord', 'PowerBatch']) {
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
}
Object.assign(schemas.AlertRecordList.properties, { firingCount: integer, unacknowledgedCount: integer })
@@ -165,9 +180,15 @@ const paths = {
'/auth/me': { get: operation('me', 'Administrator') },
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
'/auth/password': { post: operation('changeOwnPassword', null, { input: 'ChangePassword', status: 204 }) },
'/auth/mfa': { get: operation('mfaStatus', 'MFAStatus'), delete: operation('disableMFA', null, { input: 'MFACredential', status: 204 }) },
'/auth/mfa/setup': { post: operation('beginMFA', 'MFASetup', { input: 'MFASetupInput' }) },
'/auth/mfa/confirm': { post: operation('confirmMFA', 'MFARecovery', { input: 'MFAConfirm' }) },
'/auth/mfa/recovery': { post: operation('rotateMFARecovery', 'MFARecovery', { input: 'MFACredential' }) },
'/administrators': { get: operation('administrators', 'AdministratorAccountList', { list: true }), post: operation('createAdministrator', 'AdministratorAccount', { input: 'CreateAdministrator', status: 201 }) },
'/administrators/{id}': { put: operation('updateAdministrator', 'AdministratorAccount', { input: 'UpdateAdministrator', param: true }) },
'/administrators/{id}/password': { post: operation('resetAdministratorPassword', null, { input: 'ResetAdministratorPassword', param: true, status: 204 }) },
'/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') },
'/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
@@ -199,21 +220,52 @@ const paths = {
'/users/{id}': { put: operation('updateUser', 'User', { input: 'UserUpdate', param: true }) },
'/users/batch-bindings': { post: operation('batchBindings', 'BatchBindingsResult', { input: 'BatchBindings' }) },
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true, period: true }) },
'/tasks/export': { get: operation('exportTasks', null, { period: true }) },
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
'/tasks/{id}/reconcile': { post: operation('reconcileProvision', 'Task', { param: true, status: 202 }) },
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true }) },
'/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') },
'/diagnostics/export': { get: operation('exportDiagnostics', null) },
'/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 }) },
'/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) },
'/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DeviceDesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/desktop-prepare': { post: operation('prepareGPUDesktop', 'GPUPrepareResult', { input: 'GPUPrepareInput', status: 202, security: 'deviceBearer' }) },
'/terminal/desktop-prepare/{id}': { get: operation('gpuPrepareStatus', 'GPUPrepareResult', { param: true, security: 'deviceBearer' }) },
'/tasks/{id}/gpu-reconcile': { post: operation('reconcileGPUPrepare', 'GPUReconcileResult', { param: true }) },
'/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) },
'/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) },
'/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) },
'/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) },
'/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) },
}
const taskFilters = ['actor', 'action', 'kind', 'targetId'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } }))
const diagnosticFilters = [
{ name: 'targetType', in: 'query', required: true, schema: { type: 'string', enum: ['terminal', 'desktop'] } },
{ name: 'targetId', in: 'query', required: true, schema: { ...string, minLength: 1, maxLength: 100 } },
...['from', 'to'].map(name => ({ name, in: 'query', schema: date })),
{ name: 'source', in: 'query', schema: { type: 'string', enum: ['', 'tasks', 'audits', 'sessions', 'alerts', 'logs'] } },
]
paths['/diagnostics'].get.parameters.push(...diagnosticFilters, { name: 'page', in: 'query', schema: { ...integer, minimum: 1 } }, { name: 'pageSize', in: 'query', schema: { ...integer, minimum: 1, maximum: 100 } })
paths['/diagnostics/export'].get.parameters.push(...diagnosticFilters)
paths['/diagnostics'].get.description = 'Super administrators and operators only. Exact ID correlation, merged lifecycle events, maximum seven days. Redacts common credential formats. No collection or VM changes.'
paths['/diagnostics/export'].get.description = 'Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit.'
paths['/diagnostics/export'].get.responses[200] = { description: 'Scoped diagnostic archive', content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }
paths['/diagnostics/export'].get.responses[413] = { description: 'More than 2000 matching events; narrow time range or source', content: json(ref('Error')) }
taskFilters.push({ name: 'active', in: 'query', schema: boolean, description: 'true includes queued, running and unknown outcomes (timeout)' })
paths['/tasks'].get.parameters.push(...taskFilters)
paths['/tasks/export'].get.parameters.push(...taskFilters, ...['keyword', 'status'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } })))
paths['/tasks/export'].get.responses[200] = { description: 'Complete filtered CSV; maximum 10000 rows. UTC times, raw action/status codes, no provider payload or idempotency keys.', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } }
paths['/tasks/export'].get.responses[413] = { description: 'Over 10000 matching tasks', content: json(ref('Error')) }
const auditFilters = ['actor', 'action', 'result'].map(name => ({ name, in: 'query', schema: { ...string, maxLength: 200 } }))
paths['/audit-logs'].get.parameters.push(...auditFilters)
paths['/audit-logs/export'].get.parameters.push(...auditFilters, { name: 'keyword', in: 'query', schema: { ...string, maxLength: 200 } })
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 directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
mkdirSync(directory, { recursive: true })