From 6134d49c1675bba1e39851108ad5de0cc43dfdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Tue, 10 Nov 2026 17:52:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=BA=91=E7=AE=A1=E8=B5=84=E6=BA=90=E8=BF=90=E7=BB=B4=E3=80=81?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=8E=88=E6=9D=83=E5=8F=8A=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/e2e/administrators.spec.ts | 112 + console/e2e/batch-operations.spec.ts | 98 + console/e2e/catalog.spec.ts | 106 + console/e2e/configuration.spec.ts | 127 + console/e2e/gpus.spec.ts | 85 + console/e2e/lifecycle.spec.ts | 142 + console/e2e/operations.spec.ts | 102 + console/e2e/policies.spec.ts | 99 + console/e2e/snapshots.spec.ts | 115 + console/e2e/terminal-metrics.spec.ts | 98 + console/src/api/index.ts | 95 + console/src/api/schema.d.ts | 7775 ++++++++++++ .../src/components/AdministratorAccounts.vue | 54 + .../src/components/ChangePasswordDialog.vue | 32 + console/src/components/CloneDesktopDialog.vue | 65 + console/src/components/CsvImportDialog.vue | 72 + console/src/components/DesktopBatchDialog.vue | 86 + .../src/components/DesktopHardwareDialog.vue | 46 + .../src/components/DesktopSnapshotsDialog.vue | 80 + .../src/components/PveConnectionDialog.vue | 38 + console/src/components/ResourceTrend.vue | 29 + console/src/components/SessionHistory.vue | 42 + .../src/components/TerminalMetricTrend.vue | 32 + console/src/components/TerminalMetrics.vue | 55 + console/src/layouts/ConsoleLayout.vue | 14 +- console/src/router/index.ts | 5 + console/src/views/Desktops.vue | 31 +- console/src/views/GPUs.vue | 50 + console/src/views/Monitor.vue | 79 + console/src/views/Pools.vue | 39 + console/src/views/Sessions.vue | 6 + console/src/views/Settings.vue | 13 +- console/src/views/Tasks.vue | 6 +- console/src/views/Templates.vue | 59 + console/src/views/TerminalPolicies.vue | 88 + console/src/views/Terminals.vue | 36 +- console/src/views/Users.vue | 38 +- deploy/compose.live.yml | 1 + deploy/nginx.conf | 2 +- deploy/nginx.live.conf | 2 +- docs/openapi/platform.v1.json | 10338 +++++++++++++++- platform/cmd/api-gateway/main.go | 8 +- platform/internal/config/config.go | 11 + platform/internal/model/model.go | 59 +- platform/internal/server/admin.go | 179 +- platform/internal/server/administrators.go | 261 + .../internal/server/administrators_test.go | 195 + platform/internal/server/catalog.go | 597 + platform/internal/server/catalog_test.go | 345 + .../internal/server/compute_connection.go | 270 + .../internal/server/configuration_test.go | 471 + platform/internal/server/gpu.go | 266 + platform/internal/server/gpu_test.go | 181 + platform/internal/server/hardware.go | 469 + platform/internal/server/imports.go | 388 + platform/internal/server/lifecycle.go | 263 + platform/internal/server/lifecycle_test.go | 220 + platform/internal/server/login_limits.go | 59 + platform/internal/server/maintenance.go | 17 + platform/internal/server/observability.go | 299 + platform/internal/server/operations_test.go | 355 + platform/internal/server/policies.go | 322 + platform/internal/server/policies_test.go | 173 + .../internal/server/power_batch_worker.go | 237 + platform/internal/server/power_batches.go | 330 + .../internal/server/power_batches_test.go | 430 + platform/internal/server/provision_worker.go | 203 + platform/internal/server/server.go | 94 +- platform/internal/server/server_test.go | 19 +- platform/internal/server/session_history.go | 110 + platform/internal/server/snapshots.go | 554 + platform/internal/server/snapshots_test.go | 452 + platform/internal/server/terminal.go | 178 +- platform/internal/server/terminal_metrics.go | 97 + .../internal/server/terminal_metrics_test.go | 144 + platform/internal/store/schema.sql | 141 + platform/internal/store/store.go | 2 +- platform/pkg/proxmox/client.go | 24 +- platform/pkg/proxmox/gpu.go | 128 + platform/pkg/proxmox/gpu_test.go | 78 + platform/pkg/proxmox/hardware.go | 147 + platform/pkg/proxmox/hardware_test.go | 35 + platform/pkg/proxmox/provision.go | 123 + platform/pkg/proxmox/provision_test.go | 28 + platform/pkg/proxmox/snapshots.go | 114 + platform/pkg/proxmox/snapshots_test.go | 27 + tools/generate-platform-openapi.mjs | 129 +- 87 files changed, 29498 insertions(+), 126 deletions(-) create mode 100644 console/e2e/administrators.spec.ts create mode 100644 console/e2e/batch-operations.spec.ts create mode 100644 console/e2e/catalog.spec.ts create mode 100644 console/e2e/configuration.spec.ts create mode 100644 console/e2e/gpus.spec.ts create mode 100644 console/e2e/lifecycle.spec.ts create mode 100644 console/e2e/operations.spec.ts create mode 100644 console/e2e/policies.spec.ts create mode 100644 console/e2e/snapshots.spec.ts create mode 100644 console/e2e/terminal-metrics.spec.ts create mode 100644 console/src/components/AdministratorAccounts.vue create mode 100644 console/src/components/ChangePasswordDialog.vue create mode 100644 console/src/components/CloneDesktopDialog.vue create mode 100644 console/src/components/CsvImportDialog.vue create mode 100644 console/src/components/DesktopBatchDialog.vue create mode 100644 console/src/components/DesktopHardwareDialog.vue create mode 100644 console/src/components/DesktopSnapshotsDialog.vue create mode 100644 console/src/components/PveConnectionDialog.vue create mode 100644 console/src/components/ResourceTrend.vue create mode 100644 console/src/components/SessionHistory.vue create mode 100644 console/src/components/TerminalMetricTrend.vue create mode 100644 console/src/components/TerminalMetrics.vue create mode 100644 console/src/views/GPUs.vue create mode 100644 console/src/views/Monitor.vue create mode 100644 console/src/views/Pools.vue create mode 100644 console/src/views/Templates.vue create mode 100644 console/src/views/TerminalPolicies.vue create mode 100644 platform/internal/server/administrators.go create mode 100644 platform/internal/server/administrators_test.go create mode 100644 platform/internal/server/catalog.go create mode 100644 platform/internal/server/catalog_test.go create mode 100644 platform/internal/server/compute_connection.go create mode 100644 platform/internal/server/configuration_test.go create mode 100644 platform/internal/server/gpu.go create mode 100644 platform/internal/server/gpu_test.go create mode 100644 platform/internal/server/hardware.go create mode 100644 platform/internal/server/imports.go create mode 100644 platform/internal/server/lifecycle.go create mode 100644 platform/internal/server/lifecycle_test.go create mode 100644 platform/internal/server/login_limits.go create mode 100644 platform/internal/server/maintenance.go create mode 100644 platform/internal/server/observability.go create mode 100644 platform/internal/server/operations_test.go create mode 100644 platform/internal/server/policies.go create mode 100644 platform/internal/server/policies_test.go create mode 100644 platform/internal/server/power_batch_worker.go create mode 100644 platform/internal/server/power_batches.go create mode 100644 platform/internal/server/power_batches_test.go create mode 100644 platform/internal/server/provision_worker.go create mode 100644 platform/internal/server/session_history.go create mode 100644 platform/internal/server/snapshots.go create mode 100644 platform/internal/server/snapshots_test.go create mode 100644 platform/internal/server/terminal_metrics.go create mode 100644 platform/internal/server/terminal_metrics_test.go create mode 100644 platform/pkg/proxmox/gpu.go create mode 100644 platform/pkg/proxmox/gpu_test.go create mode 100644 platform/pkg/proxmox/hardware.go create mode 100644 platform/pkg/proxmox/hardware_test.go create mode 100644 platform/pkg/proxmox/provision.go create mode 100644 platform/pkg/proxmox/provision_test.go create mode 100644 platform/pkg/proxmox/snapshots.go create mode 100644 platform/pkg/proxmox/snapshots_test.go diff --git a/console/e2e/administrators.spec.ts b/console/e2e/administrators.spec.ts new file mode 100644 index 0000000..ee27649 --- /dev/null +++ b/console/e2e/administrators.spec.ts @@ -0,0 +1,112 @@ +import { test, expect, type Page } from '@playwright/test' + +// Isolated browser fixtures; no live administrator passwords or roles are modified. +async function fixture(page: Page, role = 'super_admin') { + const calls: { method: string; path: string; body: Record }[] = [] + const me = { id: 'self-admin', username: 'root', displayName: '平台管理员', role, enabled: true } + const accounts = [me, { id: 'other-admin', username: 'operations', displayName: '运维人员', role: 'operator', enabled: true }] + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'admin-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', ''), method = r.method() + const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'admin-fixture' } }) + if (method !== 'GET') { + const body = r.postData() ? r.postDataJSON() as Record : {}; calls.push({ method, path, body }) + if (path === '/administrators') { + if (accounts.some(a => a.username === body.username)) { await route.fulfill({ status: 409, json: { code: 'account_conflict', message: '管理员账号已存在', requestId: 'fixture' } }); return } + const a = { id: 'new-admin', username: String(body.username), displayName: String(body.displayName), role: String(body.role), enabled: true };accounts.push(a) + await route.fulfill({ status: 201, json: a }); return + } + if (path.startsWith('/administrators/') && method === 'PUT') { const a = accounts.find(a => path.endsWith(a.id)); if (a) Object.assign(a, body);await route.fulfill({ json: a }); return } + if (path.endsWith('/password') && path.startsWith('/administrators/')) { await route.fulfill({ status: 204 }); return } + if (path === '/auth/password') { + if (body.currentPassword !== 'Correct-old-password') { await route.fulfill({ status: 400, json: { code: 'current_password_invalid', message: '原密码不正确', requestId: 'fixture' } }); return } + await route.fulfill({ status: 204 }); return + } + } + if (path === '/auth/me') { await route.fulfill({ json: me }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, computeProvider: 'proxmox', terminalTransport: 'https-poll', capabilities: ['administrators'] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/administrators') { await list(accounts); return } + if (['/desktops', '/terminals', '/users', '/resources', '/tasks', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'fixture' } }) + }) + return calls +} +async function openAccounts(page: Page) { await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click(); await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible() } + +test('新增管理员校验密码确认,账号持久化字段正确', async ({ page }, testInfo) => { + const calls = await fixture(page) + await page.setViewportSize({ width: 1440, height: 1000 }) + await openAccounts(page) + await page.screenshot({ path: testInfo.outputPath('administrators-dark.png'), fullPage: true }) + await page.getByRole('button', { name: '新增管理员', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '新增管理员' }) + await dialog.getByRole('textbox', { name: '管理员账号', exact: true }).fill('finance') + await dialog.getByRole('textbox', { name: '管理员显示名称' }).fill('财务只读人员') + await dialog.getByLabel('管理员初始密码', { exact: true }).fill('Finance-test-2026') + await expect(dialog.getByRole('button', { name: '保存管理员' })).toBeDisabled() + await dialog.getByLabel('确认管理员初始密码', { exact: true }).fill('Finance-test-2026') + await dialog.getByRole('button', { name: '保存管理员' }).click() + await expect(dialog).not.toBeVisible() + await expect(page.getByText('finance', { exact: true })).toBeVisible() + expect(calls.find(c => c.path === '/administrators')?.body).toEqual({ username: 'finance', displayName: '财务只读人员', role: 'viewer', password: 'Finance-test-2026' }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: testInfo.outputPath('administrators-light.png'), fullPage: true }) + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: testInfo.outputPath('administrators-mobile.png'), fullPage: true }) +}) + +test('编辑本人禁止角色和停用,取消其他账号停用不提交', async ({ page }) => { + const calls = await fixture(page) + await openAccounts(page) + await page.getByRole('row').filter({ hasText: 'root' }).getByRole('button', { name: '编辑', exact: true }).click() + let dialog = page.getByRole('dialog', { name: '编辑管理员' }) + await expect(dialog.getByRole('combobox')).toBeDisabled() + await expect(dialog.getByRole('switch')).toBeDisabled() + await dialog.getByRole('button', { name: '取消', exact: true }).click() + await page.getByRole('row').filter({ hasText: 'operations' }).getByRole('button', { name: '编辑', exact: true }).click() + dialog = page.getByRole('dialog', { name: '编辑管理员' }) + await dialog.locator('.el-switch').click() + await dialog.getByRole('button', { name: '保存管理员' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click() + expect(calls.filter(c => c.method === 'PUT')).toHaveLength(0) + await dialog.getByRole('button', { name: '保存管理员' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog).not.toBeVisible() + expect(calls.find(c => c.method === 'PUT')?.body.enabled).toBe(false) +}) + +test('重置其他账号密码需要确认,取消不提交', async ({ page }) => { + const calls = await fixture(page) + await openAccounts(page) + await page.getByRole('button', { name: '重置密码', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '重置密码 · operations' }) + await dialog.getByLabel('重置后的新密码', { exact: true }).fill('Reset-test-2026') + await dialog.getByLabel('确认重置后的新密码', { exact: true }).fill('Reset-test-2026') + await dialog.getByRole('button', { name: '重置密码', exact: true }).click() + await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click() + expect(calls.filter(c => c.path.endsWith('/password'))).toHaveLength(0) + await dialog.getByRole('button', { name: '重置密码', exact: true }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog).not.toBeVisible() + expect(calls.find(c => c.path === '/administrators/other-admin/password')?.body).toEqual({ password: 'Reset-test-2026' }) +}) + +test('只读人员可修改本人密码,错误原密码不退出,成功后清理登录', async ({ page }) => { + await fixture(page, 'viewer') + await page.goto('/settings') + await expect(page.getByText('无访问权限', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '修改密码', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '修改本人密码' }) + await dialog.getByLabel('原密码', { exact: true }).fill('Incorrect-old-password') + await dialog.getByLabel('新密码', { exact: true }).fill('Updated-test-2026') + await dialog.getByLabel('确认新密码', { exact: true }).fill('Updated-test-2026') + await dialog.getByRole('button', { name: '保存并重新登录' }).click() + await expect(dialog.getByText(/原密码不正确/)).toBeVisible() + await dialog.getByLabel('原密码', { exact: true }).fill('Correct-old-password') + await dialog.getByRole('button', { name: '保存并重新登录' }).click() + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByRole('heading', { name: '登录管理控制台' })).toBeVisible() + expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull() +}) diff --git a/console/e2e/batch-operations.spec.ts b/console/e2e/batch-operations.spec.ts new file mode 100644 index 0000000..140a5c6 --- /dev/null +++ b/console/e2e/batch-operations.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, type Page } from '@playwright/test' + +// Every API request is intercepted, including all power actions. +async function fixture(page: Page, options: { role?: string; count?: number; blocked?: boolean; retry?: boolean; expired?: boolean } = {}) { + const requests: { path: string; body: Record; key?: string }[] = [], now = new Date().toISOString() + const desktops = Array.from({ length: options.count || 3 }, (_, i) => ({ id: `desk-b${i + 1}`, name: `批量桌面 ${i + 1}`, vmid: 9000 + i, node: 'test', os: 'Ubuntu', pool: '', poolId: null, provider: 'proxmox', powerState: 'running', serviceState: 'unknown', setupRequired: false, endpoint: `192.0.2.${90 + i}`, app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now })) + let submitted = false, reconciled = false + const batch = () => ({ id: 'batch-test', action: 'shutdown', actor: 'operator', createdAt: now, totalCount: 3, submittedCount: 2, skippedCount: 1, activeCount: submitted ? 2 : 0, successCount: reconciled ? 1 : 0, failedCount: submitted ? 0 : 1, uncertainCount: submitted || reconciled ? 0 : 1 }) + const detail = () => ({ batch: batch(), items: desktops.slice(0, 3).map((d, i) => ({ desktopId: d.id, name: d.name, vmid: d.vmid, taskId: i < 2 ? `task-b${i}` : '', status: i === 2 ? 'skipped' : submitted ? 'queued' : i === 0 ? reconciled ? 'success' : 'timeout' : 'failed', code: i === 2 ? 'active_session' : 'ready', message: i === 2 ? '有活动会话,已跳过' : submitted ? '已提交,等待执行' : i === 0 ? '原任务结果未确认,不会自动重发' : '权限变更,未执行' })) }) + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'batch-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'batch-ui' } }) + if (r.method() !== 'GET') { + requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] }) + if (path === '/desktop-batches/check') { + const selected = (r.postDataJSON().desktopIds as string[]).map(id => desktops.find(d => d.id === id)!) + const items = selected.map(d => ({ desktopId: d.id, name: d.name, vmid: d.vmid, powerState: d.powerState, available: !options.blocked && d.id !== 'desk-b3', code: options.blocked || d.id === 'desk-b3' ? 'active_session' : 'ready', message: options.blocked || d.id === 'desk-b3' ? '有活动会话,已跳过' : '可执行,提交前再次检查' })) + const eligibleCount = items.filter(i => i.available).length + await route.fulfill({ json: { items, eligibleCount, skippedCount: items.length - eligibleCount, previewToken: 'signed-preview', expiresAt: new Date(Date.now() + (options.expired ? -1000 : 300000)).toISOString() } }); return + } + if (path === '/desktop-batches') { + if (options.retry && requests.filter(r => r.path === path).length === 1) { await route.fulfill({ status: 503, json: { code: 'network', message: '网络暂时不可用', requestId: 'batch-ui' } }); return } + submitted = true; await route.fulfill({ status: 202, json: detail() }); return + } + if (path.endsWith('/batch-power-reconcile')) { reconciled = true; await route.fulfill({ json: { accepted: true } }); return } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-batch', username: 'operator', displayName: '批量验收员', role: options.role || 'operator' } }); return } + if (path === '/desktop-batches') { await list([batch()]); return } + if (path === '/desktop-batches/batch-test') { await route.fulfill({ json: detail() }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: desktops.length, runningDesktops: desktops.length, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/desktops') { await list(desktops); return } + if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return } + if (path === '/tasks') { await list([{ id: 'task-b0', kind: 'batch-desktop', action: 'shutdown', targetId: 'desk-b1', targetName: '批量桌面 1', actor: 'operator', status: 'timeout', message: '批量电源结果未确认', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return } + await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'batch-ui' } }) + }) + return requests +} +async function selectCheckbox(page: Page, name: string) { + await page.locator('label.el-checkbox').filter({ has: page.getByRole('checkbox', { name, exact: true }) }).click() +} +async function prepare(page: Page) { + await page.goto('/desktops'); await selectCheckbox(page, '选择本页'); await page.getByRole('button', { name: '批量关机', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '批量正常关机', exact: true }); await dialog.getByRole('button', { name: '预检批量操作', exact: true }).click(); return dialog +} +test('批量关机逐台预检,取消不提交,确认后仅显示已提交', async ({ page }, info) => { + const requests = await fixture(page), dialog = await prepare(page) + await expect(dialog.getByText('有活动会话,已跳过', { exact: true })).toBeVisible(); await expect(dialog.getByText(/可执行/).first()).toBeVisible() + await page.screenshot({ path: info.outputPath('batch-preview-dark.png'), fullPage: true }) + await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '取消', exact: true }).click() + expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0) + await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '提交批量任务', exact: true }).click() + const result = page.getByRole('dialog', { name: '批量操作结果', exact: true }); await expect(result.getByText('已提交,等待执行', { exact: true })).toHaveCount(2) + const writes = requests.filter(r => r.path === '/desktop-batches'); expect(writes).toHaveLength(1); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.body).toEqual({ action: 'shutdown', desktopIds: ['desk-b1', 'desk-b2', 'desk-b3'], previewToken: 'signed-preview' }) + await page.screenshot({ path: info.outputPath('batch-submitted-dark.png'), fullPage: true }) +}) +test('卡片与列表共享选择,跨页保留选择并可清空', async ({ page }) => { + await fixture(page, { count: 13 }); await page.goto('/desktops') + await selectCheckbox(page, '选择桌面 批量桌面 1'); await page.getByRole('button', { name: '列表视图', exact: true }).click() + await expect(page.getByRole('checkbox', { name: '选择桌面 批量桌面 1', exact: true })).toBeChecked() + await page.locator('.el-pagination .btn-next').click(); await selectCheckbox(page, '选择桌面 批量桌面 11') + await expect(page.getByText('已选 2 / 50 台(可跨页)', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '批量重启', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '批量重启', exact: true }) + await expect(dialog.getByText('批量桌面 1', { exact: true })).toBeVisible(); await expect(dialog.getByText('批量桌面 11', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '清空选择', exact: true }).click(); await expect(page.getByRole('button', { name: '批量开机', exact: true })).toBeDisabled() +}) +test('浅色窄屏预检与结果操作区域保持可见', async ({ page }, info) => { + await fixture(page); await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')); await page.setViewportSize({ width: 390, height: 844 }) + const dialog = await prepare(page); await expect(dialog.locator('.el-dialog__header')).toBeInViewport(); await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeInViewport() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('batch-preview-light-mobile.png'), fullPage: true }) +}) +test('全部跳过或预检过期不能提交', async ({ page }) => { + let requests = await fixture(page, { blocked: true }); let dialog = await prepare(page) + await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeDisabled(); expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0) + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.unroute('**/api/v1/**'); requests = await fixture(page, { expired: true }); dialog = await prepare(page) + await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeDisabled(); expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0) +}) +test('提交网络失败保留相同幂等标识重试', async ({ page }) => { + const requests = await fixture(page, { retry: true }), dialog = await prepare(page) + for (let i = 0; i < 2; i++) { await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '提交批量任务', exact: true }).click(); if (!i) await expect(dialog.getByText(/网络暂时不可用/)).toBeVisible() } + await expect(page.getByRole('dialog', { name: '批量操作结果', exact: true })).toBeVisible(); const writes = requests.filter(r => r.path === '/desktop-batches'); expect(writes).toHaveLength(2); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.key).toBe(writes[1]?.key) +}) +test('批量记录保留失败和跳过原因,运维可只读核对原任务', async ({ page }) => { + const requests = await fixture(page); await page.goto('/desktops'); await page.getByRole('button', { name: '批量记录', exact: true }).click() + await page.getByRole('button', { name: '正常关机 · operator', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '批量操作结果', exact: true }) + await expect(dialog.getByText('权限变更,未执行', { exact: true })).toBeVisible(); await dialog.getByRole('button', { name: '核对电源结果', exact: true }).click() + await expect(dialog.getByRole('button', { name: '核对电源结果', exact: true })).toHaveCount(0); expect(requests).toHaveLength(1); expect(requests[0]?.path).toBe('/tasks/task-b0/batch-power-reconcile') +}) +test('只读人员无批量选择与提交入口,可查看历史', async ({ page }) => { + await fixture(page, { role: 'viewer' }); await page.goto('/desktops') + await expect(page.getByRole('button', { name: '批量关机', exact: true })).toHaveCount(0); await expect(page.getByRole('checkbox', { name: '选择本页', exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '批量记录', exact: true }).click(); await page.getByRole('button', { name: '正常关机 · operator', exact: true }).click(); await expect(page.getByRole('button', { name: '核对电源结果', exact: true })).toHaveCount(0) +}) +test('任务页支持运维只读核对批量电源结果', async ({ page }) => { + const requests = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '批量桌面 1', exact: true }).click(); await page.getByRole('button', { name: '核对电源结果', exact: true }).click(); await expect.poll(() => requests.length).toBe(1); expect(requests[0]?.path).toBe('/tasks/task-b0/batch-power-reconcile') +}) diff --git a/console/e2e/catalog.spec.ts b/console/e2e/catalog.spec.ts new file mode 100644 index 0000000..5abcdba --- /dev/null +++ b/console/e2e/catalog.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from '@playwright/test' + +// All calls are intercepted: these tests cannot create a real VM or change live assignments. +async function catalogFixture(page: Page, options: { role?: string; empty?: boolean; fail?: boolean } = {}) { + const now = new Date().toISOString(), requests: { endpoint: string; body: Record; key?: string }[] = [] + const templates = options.empty ? [] : [{ id: 'tpl-ui', name: 'Ubuntu 标准镜像', version: '1.0', os: 'Ubuntu', node: 'node-a', vmid: 8000, enabled: true, validatedAt: now }] + const pools = [{ id: 'pool-ui', name: '研发专属池', type: 'dedicated', maxCount: 5, templateId: 'tpl-ui', enabled: true, createdAt: now, desktopCount: 0, reservedCount: 0, assignedCount: 0, activeCount: 0 }] + const desktop = { id: 'desk-ui', name: '待初始化桌面', os: 'Ubuntu', node: 'node-a', vmid: 9000, pool: '', poolId: null, setupRequired: true, powerState: 'stopped', serviceState: 'unknown', provider: 'proxmox', endpoint: '', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now } + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'catalog-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), endpoint = new URL(r.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'catalog-ui' } }) + if (r.method() !== 'GET') { + const body = r.postData() ? r.postDataJSON() as Record : {} + requests.push({ endpoint, body, key: r.headers()['idempotency-key'] }) + if (endpoint === '/templates') { const item = { ...templates[0], ...body, id: 'tpl-new', enabled: true, validatedAt: now } as typeof templates[number]; templates.push(item); await route.fulfill({ status: 201, json: item }); return } + if (endpoint === '/pools') { const item = { ...pools[0], ...body, id: 'pool-new' } as typeof pools[number]; pools.push(item); await route.fulfill({ json: item }); return } + if (endpoint === '/desktops/clone/check') { await route.fulfill({ json: { available: true, message: '预检通过;创建后需初始化' } }); return } + if (endpoint === '/desktops/clone') { await route.fulfill({ status: 202, json: { id: 'task-ui-clone', kind: 'provision', action: 'clone', targetId: desktop.id, targetName: '测试克隆桌面', actor: 'root', status: 'queued', message: '等待 PVE', providerRef: '', createdAt: now, updatedAt: now } }); return } + if (endpoint === '/desktops/desk-ui/connection') { Object.assign(desktop, body, { setupRequired: false }); await route.fulfill({ json: desktop }); return } + if (endpoint.endsWith('/members')) { await route.fulfill({ json: { accepted: true } }); return } + } + if (endpoint === '/auth/me') { await route.fulfill({ json: { id: 'admin-ui', username: 'root', displayName: '测试管理员', role: options.role || 'super_admin' } }); return } + if (endpoint === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (endpoint === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return } + if (endpoint === '/templates') { if (options.fail) { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '模板读取失败,请重试', requestId: 'catalog-ui' } }); return }; await list(templates); return } + if (endpoint === '/pools') { await list(pools); return } + if (endpoint === '/resources/templates') { await list(options.empty ? [] : [{ name: 'Ubuntu 新模板', node: 'node-a', vmid: 8001, status: 'stopped' }]); return } + if (endpoint === '/resources/storage') { await list([{ storage: 'local', avail: 100 * 1073741824, enabled: 1, active: 1, content: 'images' }]); return } + if (endpoint === '/desktops') { await list([desktop]); return } + if (['/terminals', '/users', '/resources', '/tasks', '/audit-logs'].includes(endpoint)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'catalog-ui' } }) + }) + return requests +} + +test('模板克隆向导先预检,再提交带去重标识的任务', async ({ page }, testInfo) => { + const requests = await catalogFixture(page) + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto('/templates') + await expect(page.getByText('Ubuntu 标准镜像', { exact: true })).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('templates-dark.png'), fullPage: true }) + await page.getByRole('button', { name: '创建桌面', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '从模板创建桌面' }) + await expect(dialog.getByRole('button', { name: '提交创建任务' })).toBeDisabled() + await dialog.getByPlaceholder('例如:研发桌面 01').fill('测试克隆桌面') + await dialog.locator('.el-select__wrapper').last().click() + await page.getByRole('option', { name: /local · 可用/ }).click() + await dialog.getByRole('button', { name: '检查创建条件' }).click() + await expect(dialog.getByText('预检通过;创建后需初始化')).toBeVisible() + await dialog.getByRole('button', { name: '提交创建任务' }).click() + await expect(dialog.getByText('创建任务已提交', { exact: true })).toBeVisible() + expect(requests.filter(r => r.endpoint === '/desktops/clone')).toHaveLength(1) + expect(requests.find(r => r.endpoint === '/desktops/clone')?.body).toMatchObject({ templateId: 'tpl-ui', name: '测试克隆桌面', vmid: 9000, full: true, storage: 'local' }) + expect(requests.find(r => r.endpoint === '/desktops/clone')?.key).toBeTruthy() +}) + +test('桌面池创建保存业务字段,窄屏与浅色主题可用', async ({ page }, testInfo) => { + const requests = await catalogFixture(page) + await page.goto('/pools') + await expect(page.getByText('0 台桌面 + 0 台创建预留 / 5')).toBeVisible() + await page.getByRole('button', { name: '新建桌面池' }).click() + const dialog = page.getByRole('dialog', { name: '新建桌面池' }) + await dialog.getByRole('textbox').fill('财务专属池') + await dialog.getByRole('button', { name: '保存桌面池' }).click() + await expect(page.getByText('财务专属池', { exact: true })).toBeVisible() + expect(requests.find(r => r.endpoint === '/pools')?.body).toEqual({ name: '财务专属池', type: 'dedicated', maxCount: 10, templateId: null, enabled: true }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: testInfo.outputPath('pools-light.png'), fullPage: true }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.reload() + await expect(page.getByRole('heading', { name: '桌面池', exact: true })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: testInfo.outputPath('pools-mobile.png'), fullPage: true }) +}) + +test('待初始化桌面必须勾选确认才能保存连接', async ({ page }) => { + const requests = await catalogFixture(page) + await page.goto('/desktops') + await expect(page.getByText('待初始化', { exact: true }).first()).toBeVisible() + await page.getByRole('button', { name: '查看详情' }).click() + await page.getByRole('button', { name: '配置连接', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '配置桌面连接' }) + await dialog.getByPlaceholder('例如 192.168.1.200,不含协议和端口').fill('192.0.2.20') + await expect(dialog.getByRole('button', { name: '保存连接配置' })).toBeDisabled() + await dialog.getByText('已完成系统身份、IP、Sunshine 服务和终端配对', { exact: true }).click() + await dialog.getByRole('button', { name: '保存连接配置' }).click() + await expect(dialog).not.toBeVisible() + expect(requests.find(r => r.endpoint.endsWith('/connection'))?.body).toMatchObject({ endpoint: '192.0.2.20', initialized: true, width: 1920, height: 1080, fps: 30 }) +}) + +test('空模板和接口错误显示明确原因,只读角色没有管理按钮', async ({ page }) => { + await catalogFixture(page, { role: 'viewer', empty: true }) + await page.goto('/templates') + await expect(page.getByText(/尚未登记模板。请先在 PVE/)).toBeVisible() + await expect(page.getByRole('button', { name: '登记模板' })).toHaveCount(0) + await page.goto('/pools') + await expect(page.getByRole('button', { name: '新建桌面池' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '成员', exact: true })).toHaveCount(0) +}) + +test('模板读取失败提供错误反馈', async ({ page }) => { + await catalogFixture(page, { fail: true }) + await page.goto('/templates') + await expect(page.getByText(/模板读取失败,请重试/)).toBeVisible() +}) diff --git a/console/e2e/configuration.spec.ts b/console/e2e/configuration.spec.ts new file mode 100644 index 0000000..e25768c --- /dev/null +++ b/console/e2e/configuration.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, type Page } from '@playwright/test' + +// Every endpoint is intercepted; these tests cannot mutate a live PVE desktop. +async function fixture(page: Page, options: { running?: boolean; role?: string; fail?: boolean } = {}) { + const requests: { path: string; body: Record; key?: string }[] = [], now = new Date().toISOString() + const desktop = { id: 'desk-hw', name: '规格验收桌面', vmid: 9000, node: 'test', os: 'Windows', pool: '', poolId: null, provider: 'proxmox', powerState: options.running ? 'running' : 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.90', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now } + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'configuration-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'config-ui' } }) + if (r.method() !== 'GET') { + requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] }) + if (path === '/settings/pve/check') { await route.fulfill(options.fail ? { status: 502, json: { code: 'pve_connection_failed', message: '连接检查失败,请检查证书与权限', requestId: 'config-ui' } } : { json: { available: true, nodes: 1, vms: 3, message: 'TLS、身份与资源读取检查通过' } }); return } + if (path === '/desktops/desk-hw/hardware/check') { await route.fulfill({ json: { available: true, message: '规格预检通过' } }); return } + if (path === '/desktops/desk-hw/hardware') { await route.fulfill({ status: 202, json: { id: 'task-hw', kind: 'hardware', action: 'configure-hardware', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'queued', message: '等待执行', providerRef: '', createdAt: now, updatedAt: now } }); return } + if (path === '/settings/pve' || path.endsWith('/hardware-reconcile')) { await route.fulfill({ json: { accepted: true } }); return } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-hw', username: 'root', displayName: '验收管理员', role: options.role || 'super_admin' } }); return } + if (path === '/settings/pve') { await route.fulfill({ json: { url: 'https://pve.example:8006', tokenId: 'service@pve!vdi', tokenConfigured: true, caPem: '', revision: 'config-v1', source: 'environment' } }); return } + if (path === '/desktops/desk-hw/hardware') { await route.fulfill({ json: { cores: 2, sockets: 1, memoryMiB: 2048, digest: 'hardware-v1', disks: [{ key: 'scsi0', storage: 'local', sizeGiB: 60 }], powerState: desktop.powerState, blockers: options.running ? ['请先正常关闭桌面,再修改规格'] : [] } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/desktops') { await list([desktop]); return } + if (path === '/tasks') { await list([{ id: 'hw-timeout', kind: 'hardware', action: 'expand-disk', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'timeout', message: '规格变更结果未确认,不会自动重发', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return } + if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'config-ui' } }) + }) + return requests +} + +test('接入配置不回显密钥,取消不写入,检查后保留原密钥保存', async ({ page }, info) => { + const requests = await fixture(page) + await page.goto('/settings') + await page.getByRole('button', { name: '配置 Proxmox 接入' }).click() + const dialog = page.getByRole('dialog', { name: 'Proxmox 接入配置' }) + await expect(dialog.getByRole('textbox', { name: 'PVE Token 密钥' })).toHaveValue('') + await dialog.getByRole('button', { name: '检查连接' }).click() + await expect(dialog.getByText(/TLS、身份与资源读取检查通过/)).toBeVisible() + await page.screenshot({ path: info.outputPath('pve-connection-dark.png'), fullPage: true }) + await dialog.getByRole('button', { name: '保存并生效' }).click() + await page.getByRole('button', { name: '取消', exact: true }).last().click() + expect(requests.filter(r => r.path === '/settings/pve')).toHaveLength(0) + await dialog.getByRole('button', { name: '保存并生效' }).click() + await page.getByRole('button', { name: '确认保存', exact: true }).click() + await expect(dialog).not.toBeVisible() + expect(requests.find(r => r.path === '/settings/pve')?.body).toMatchObject({ tokenSecret: '', revision: 'config-v1' }) +}) + +test('接入检查失败保留输入并支持窄屏', async ({ page }, info) => { + const requests = await fixture(page, { fail: true }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/settings') + await page.getByRole('button', { name: '配置 Proxmox 接入' }).click() + const dialog = page.getByRole('dialog', { name: 'Proxmox 接入配置' }) + await dialog.getByRole('button', { name: '检查连接' }).click() + await expect(dialog.getByText(/连接检查失败,请检查证书与权限/)).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('pve-connection-mobile.png'), fullPage: true }) + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(requests.filter(r => r.path === '/settings/pve')).toHaveLength(0) +}) + +test('CPU 内存调整先预检,修改参数使预检失效,确认后提交一次', async ({ page }, info) => { + const requests = await fixture(page) + await page.goto('/desktops') + await page.getByRole('button', { name: '查看详情' }).click() + await page.getByRole('button', { name: '规格管理' }).click() + const dialog = page.getByRole('dialog', { name: '桌面规格管理' }) + await expect(dialog.getByText(/当前:1 插槽 × 2 核/)).toBeVisible() + await dialog.getByRole('spinbutton', { name: '每插槽核心数' }).fill('4') + await dialog.getByRole('button', { name: '预检规格' }).click() + await expect(dialog.getByRole('button', { name: '提交变更' })).toBeEnabled() + await dialog.getByRole('spinbutton', { name: '目标内存' }).fill('4096') + await expect(dialog.getByRole('button', { name: '提交变更' })).toBeDisabled() + await dialog.getByRole('button', { name: '预检规格' }).click() + await page.screenshot({ path: info.outputPath('hardware-compute-dark.png'), fullPage: true }) + await dialog.getByRole('button', { name: '提交变更' }).click() + await page.getByRole('button', { name: '提交规格任务', exact: true }).click() + await expect(dialog).not.toBeVisible() + const writes = requests.filter(r => r.path === '/desktops/desk-hw/hardware') + expect(writes).toHaveLength(1) + expect(writes[0]?.body).toEqual({ kind: 'compute', cores: 4, sockets: 1, memoryMiB: 4096, digest: 'hardware-v1' }) + expect(writes[0]?.key).toBeTruthy() +}) + +test('磁盘输入总容量并提示客体扩展,取消确认不提交', async ({ page }, info) => { + const requests = await fixture(page) + await page.goto('/desktops') + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.getByRole('button', { name: '查看详情' }).click() + await page.getByRole('button', { name: '规格管理' }).click() + const dialog = page.getByRole('dialog', { name: '桌面规格管理' }) + await dialog.getByRole('tab', { name: '磁盘扩容' }).click() + await dialog.getByRole('spinbutton', { name: '磁盘目标容量' }).fill('80') + await dialog.getByRole('button', { name: '预检规格' }).click() + await page.screenshot({ path: info.outputPath('hardware-disk-light.png'), fullPage: true }) + await dialog.getByRole('button', { name: '提交变更' }).click() + await expect(page.getByText(/scsi0 扩容至 80 GiB;完成后需在客体内扩展/)).toBeVisible() + await page.getByRole('button', { name: '取消', exact: true }).last().click() + expect(requests.find(r => r.path.endsWith('/hardware/check'))?.body).toMatchObject({ kind: 'disk', disk: 'scsi0', sizeGiB: 80 }) + expect(requests.filter(r => r.path === '/desktops/desk-hw/hardware')).toHaveLength(0) +}) + +test('运行中的桌面阻止修改,结果未知任务只读核对', async ({ page }) => { + const requests = await fixture(page, { running: true }) + await page.goto('/desktops') + await page.getByRole('button', { name: '查看详情' }).click() + await page.getByRole('button', { name: '规格管理' }).click() + const dialog = page.getByRole('dialog', { name: '桌面规格管理' }) + await expect(dialog.getByText('请先正常关闭桌面,再修改规格')).toBeVisible() + await expect(dialog.getByRole('button', { name: '预检规格' })).toBeDisabled() + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + await page.goto('/tasks') + await page.getByRole('button', { name: '规格验收桌面', exact: true }).click() + await page.getByRole('button', { name: '核对规格结果' }).click() + await expect.poll(() => requests.filter(r => r.path.endsWith('/hardware-reconcile')).length).toBe(1) + expect(requests.filter(r => r.path === '/desktops/desk-hw/hardware')).toHaveLength(0) +}) + +test('只读角色不显示接入与规格管理入口', async ({ page }) => { + await fixture(page, { role: 'viewer' }) + await page.goto('/settings') + await expect(page.getByRole('button', { name: '配置 Proxmox 接入' })).toHaveCount(0) + await page.goto('/desktops') + await page.getByRole('button', { name: '查看详情' }).click() + await expect(page.getByRole('button', { name: '规格管理' })).toHaveCount(0) +}) diff --git a/console/e2e/gpus.spec.ts b/console/e2e/gpus.spec.ts new file mode 100644 index 0000000..05744cf --- /dev/null +++ b/console/e2e/gpus.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { role?: string; partial?: boolean; fail?: boolean; empty?: boolean; configured?: boolean; stale?: boolean } = {}) { + const writes: string[] = [], reads: string[] = [], now = new Date().toISOString() + let fail = !!options.fail + const reference = { vmid: 9000, vmName: 'win-reference', vmStatus: 'stopped', template: false, desktopId: 'desk-gpu', desktopName: '测试 Windows', slot: 'hostpci0', phase: 'current', source: 'direct', mapping: '', mediated: false, pendingRemoval: true } + const inventory = () => ({ node: 'node-gpu', observedAt: new Date(Date.now() - (options.stale ? 120000 : 0)).toISOString(), complete: !options.partial, visibleVmCount: 2, scannedVmCount: options.partial ? 1 : 2, issues: options.partial ? [{ code: 'vm_config_unavailable', vmid: 9001, message: '无法读取虚拟机当前及待应用配置' }] : [], items: options.empty ? [] : [ + { id: 'node-gpu:0000:00:02.0', address: '0000:00:02.0', name: 'Intel Integrated Graphics', vendor: 'Intel', vendorId: '0x8086', deviceId: '0x0416', class: '0x030000', iommuGroup: 0, state: options.partial ? 'unknown' : 'unreferenced', references: [] }, + { id: 'node-gpu:0000:01:00.0', address: '0000:01:00.0', name: 'GeForce GTX Test', vendor: 'NVIDIA', vendorId: '0x10de', deviceId: '0x1234', class: '0x030200', iommuGroup: 2, state: 'multiple', references: [reference, { ...reference, vmid: 9001, vmName: 'ubuntu-unmanaged', desktopId: '', desktopName: '', phase: 'pending', source: 'mapping', mapping: 'gpu-shared-map', mediated: true, template: true, pendingRemoval: false }] }, + ] }) + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '') + if (r.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { code: 'readonly_fixture', message: 'GPU 查看不应发送写请求', requestId: 'gpu-ui' } }); return } + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'gpu-ui' } }) + if (path === '/auth/me') { await route.fulfill({ json: { id: 'gpu-admin', username: 'gpu-reader', displayName: 'GPU 查看人员', role: options.role || 'viewer' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: options.configured !== false, terminalTransport: 'https-poll', capabilities: ['gpu-inventory'] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/resources') { await list(options.configured === false ? [] : [{ id: 'node-gpu', name: 'node-gpu', status: 'online', cpu: 0.2, memory: 0.5, storage: 0.3, provider: 'proxmox', updatedAt: now }]); return } + if (path === '/resources/gpus') { + reads.push(new URL(r.url()).searchParams.get('node') || '') + if (fail) { fail = false; await route.fulfill({ status: 502, json: { code: 'pci_unavailable', message: '无法读取节点 PCI 清单,请检查 Sys.Audit 权限', requestId: 'gpu-ui' } }); return } + await route.fulfill({ json: inventory() }); return + } + if (['/desktops', '/terminals', '/users', '/tasks', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'gpu-ui' } }) + }) + return { writes, reads } +} + +test('只读角色可查显卡并筛选配置引用,IOMMU 0 保留', async ({ page }, info) => { + const requests = await fixture(page); await page.goto('/gpus') + await expect(page.getByRole('heading', { name: 'Intel Integrated Graphics', exact: true })).toBeVisible() + const intel = page.locator('.gpu-card').filter({ has: page.getByRole('heading', { name: 'Intel Integrated Graphics', exact: true }) }) + await expect(intel.getByText('未发现配置引用', { exact: true })).toBeVisible(); await expect(intel.locator('dd').filter({ hasText: /^0$/ })).toBeVisible() + await expect(page.locator('.gpu-card').getByText('多 VM 引用', { exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '搜索 GPU' }).fill('win-reference'); await expect(page.locator('.gpu-card')).toHaveCount(1) + await page.getByRole('textbox', { name: '搜索 GPU' }).clear() + await page.screenshot({ path: info.outputPath('gpu-inventory-dark.png'), fullPage: true, animations: 'disabled' }) + await expect(page.getByText('GPU 清单按需读取,请手动刷新', { exact: true })).toBeVisible() + expect(requests.reads).toEqual(['node-gpu']); expect(requests.writes).toEqual([]) + await expect(page.getByRole('button', { name: '绑定 GPU', exact: true })).toHaveCount(0) +}) +test('详情区分当前与待应用、待移除、模板及未纳管的映射引用', async ({ page }, info) => { + const requests = await fixture(page); await page.goto('/gpus') + await page.locator('.gpu-card').filter({ has: page.getByRole('heading', { name: 'GeForce GTX Test', exact: true }) }).getByRole('button', { name: '查看 GPU 详情' }).click() + const drawer = page.getByRole('dialog', { name: 'GPU 资源详情', exact: true }) + for (const text of ['当前配置', '待应用配置', '待移除', '模板', '介导设备', '尚未纳管为平台桌面']) await expect(drawer.getByText(text, { exact: true })).toBeVisible() + await expect(drawer.getByText('hostpci0 · PCI 映射 gpu-shared-map', { exact: true })).toBeVisible() + await expect(drawer.getByText('已纳管桌面:测试 Windows', { exact: true })).toBeVisible() + await page.screenshot({ path: info.outputPath('gpu-reference-dark.png'), fullPage: true, animations: 'disabled' }) + expect(requests.writes).toEqual([]) +}) +test('配置读取不完整显示待核查,不宣称显卡可用', async ({ page }) => { + const requests = await fixture(page, { partial: true }); await page.goto('/gpus') + await expect(page.getByRole('heading', { name: '检查未完整' })).toBeVisible() + await expect(page.getByText('VM 9001:无法读取虚拟机当前及待应用配置', { exact: true })).toBeVisible() + const intel = page.locator('.gpu-card').filter({ hasText: 'Intel Integrated Graphics' }); await expect(intel.getByText('待核查', { exact: true })).toBeVisible() + await intel.getByRole('button', { name: '查看 GPU 详情' }).click(); await expect(page.getByText('检查范围不完整,引用情况待核查', { exact: true })).toBeVisible() + expect(requests.writes).toEqual([]) +}) +test('PCI 权限错误提供重试,数据过期有明确提示', async ({ page }) => { + const requests = await fixture(page, { fail: true, stale: true }); await page.goto('/gpus') + await expect(page.getByText(/无法读取节点 PCI 清单,请检查 Sys.Audit 权限/)).toBeVisible() + await page.getByRole('button', { name: '重试读取', exact: true }).click() + await expect(page.getByText('数据已过期,请刷新', { exact: true })).toBeVisible() + await expect(page.locator('.gpu-card')).toHaveCount(2); expect(requests.reads).toHaveLength(2); expect(requests.writes).toEqual([]) +}) +test('浅色窄屏清单和详情可读且页面不横向溢出', async ({ page }, info) => { + await fixture(page); await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/gpus') + await expect(page.locator('.gpu-card')).toHaveCount(2) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('gpu-inventory-light-mobile.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '查看 GPU 详情' }).last().click() + const drawer = page.getByRole('dialog', { name: 'GPU 资源详情', exact: true }); await expect(drawer.getByRole('heading', { name: 'GeForce GTX Test', exact: true })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await expect.poll(async () => { const box = await drawer.boundingBox(); return box ? box.x + box.width : 999 }).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('gpu-detail-light-mobile.png'), animations: 'disabled' }) +}) +test('空节点清单与尚未接入显示真实空态', async ({ page }) => { + let requests = await fixture(page, { empty: true }); await page.goto('/gpus') + await expect(page.getByText('当前节点未发现显示控制器类 PCI 设备', { exact: true })).toBeVisible(); expect(requests.writes).toEqual([]) + await page.unroute('**/api/v1/**'); requests = await fixture(page, { configured: false }); await page.reload() + await expect(page.getByText('尚未接入 Proxmox', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '刷新 GPU 清单', exact: true })).toBeDisabled(); expect(requests.reads).toEqual([]) +}) diff --git a/console/e2e/lifecycle.spec.ts b/console/e2e/lifecycle.spec.ts new file mode 100644 index 0000000..ea0a2a8 --- /dev/null +++ b/console/e2e/lifecycle.spec.ts @@ -0,0 +1,142 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, role = 'super_admin', active = false) { + const calls: { path: string; method: string; body: Record }[] = [] + const terminals = [ + { id: 't1', sn: 'TEST-BOX-001', name: '设计工位', group: '设计部', enabled: true, status: 'online', agentVersion: 'qt-test', lastSeen: '2026-11-10T02:00:00Z', sessionDesktopId: active ? 'd1' : null, sessionState: active ? 'connected' : 'idle', configVersion: 1, fps: null, rtt: null }, + { id: 't2', sn: 'TEST-BOX-002', name: '备用终端', group: '备用组', enabled: false, status: 'disabled', agentVersion: '', lastSeen: null, sessionDesktopId: null, sessionState: 'idle', configVersion: 1, fps: null, rtt: null }, + ] + const users = [ + { id: 'u1', username: 'designer', displayName: '设计人员', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, + { id: 'u2', username: 'office', displayName: '办公人员', enabled: true, terminalId: null, desktopIds: ['d1'] }, + ] + const desktops = [{ id: 'd1', name: '设计桌面', os: 'Windows', powerState: 'running' }, { id: 'd2', name: '办公桌面', os: 'Ubuntu', powerState: 'running' }] + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'lifecycle-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', ''), method = r.method() + const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'lifecycle-fixture' } }) + if (method !== 'GET') { + const body = r.postData() ? r.postDataJSON() as Record : {}; calls.push({ path, method, body }) + if (path === '/terminals/t1' && method === 'PUT') { Object.assign(terminals[0]!, body); await route.fulfill({ json: { accepted: true } }); return } + if (path.endsWith('/disable')) { Object.assign(terminals[0]!, { enabled: false, status: 'disabled', lastSeen: null }); await route.fulfill({ json: { accepted: true } }); return } + if (path.endsWith('/reactivate')) { const t = terminals.find(t => path.includes(t.id))!; Object.assign(t, { enabled: true, status: 'unregistered' }); await route.fulfill({ json: { id: t.id, activationSecret: 'activate_fixture_one_time' } }); return } + if (path === '/users/u1') { Object.assign(users[0]!, body); await route.fulfill({ json: users[0] }); return } + if (path === '/users/batch-bindings') { + for (const u of users.filter(u => (body.userIds as string[]).includes(u.id))) u.desktopIds = body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds as string[]])] : u.desktopIds.filter(id => !(body.desktopIds as string[]).includes(id)) + await route.fulfill({ json: { accepted: true, userCount: (body.userIds as string[]).length } }); return + } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'root', displayName: '平台管理员', role } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 2, onlineTerminals: 1, desktopCount: 2, runningDesktops: 2, activeSessions: active ? 1 : 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/terminals') { await list(terminals); return } + if (path === '/users') { await list(users); return } + if (path === '/desktops') { await list(desktops); return } + if (['/tasks', '/audit-logs', '/resources'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'fixture' } }) + }) + return calls +} + +test('终端资料保存名称和分组,深浅主题与窄屏可用', async ({ page }, testInfo) => { + const calls = await fixture(page) + await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/terminals') + const row = page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }) + await row.getByRole('button', { name: '编辑资料' }).click() + const dialog = page.getByRole('dialog', { name: '编辑终端资料' }) + await dialog.getByLabel('编辑终端名称').fill('设计工位 A') + await dialog.getByLabel('编辑终端分组').fill('研发一组') + await dialog.getByRole('button', { name: '保存终端资料' }).click() + await expect(dialog).toBeHidden(); await expect(row).toContainText('研发一组') + expect(calls[0]).toEqual({ path: '/terminals/t1', method: 'PUT', body: { name: '设计工位 A', group: '研发一组' } }) + await page.screenshot({ path: testInfo.outputPath('terminals-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: testInfo.outputPath('terminals-light.png'), fullPage: true, animations: 'disabled' }) + await row.getByRole('button', { name: '详情', exact: true }).click() + await page.screenshot({ path: testInfo.outputPath('terminal-detail-light.png'), fullPage: true, animations: 'disabled' }) + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: testInfo.outputPath('terminal-detail-mobile.png'), fullPage: true, animations: 'disabled' }) +}) + +test('停用与重新激活需要确认,旧凭据撤销提示和一次性密钥可见', async ({ page }) => { + const calls = await fixture(page); await page.goto('/terminals') + await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click() + await page.getByRole('button', { name: '停用设备', exact: true }).click() + const box = page.locator('.el-message-box') + await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0) + await page.getByRole('button', { name: '停用设备', exact: true }).click() + await box.getByRole('button', { name: '确认提交' }).click() + await expect(page.getByRole('button', { name: '停用设备', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '收集日志', exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '重新激活', exact: true }).click() + await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(1) + await page.getByRole('button', { name: '重新激活', exact: true }).click() + await box.getByRole('button', { name: '确认提交' }).click() + const activation = page.getByRole('dialog', { name: '终端激活信息', exact: true }) + await expect(activation.getByTestId('activation-secret')).toHaveText('activate_fixture_one_time') + await activation.getByRole('button', { name: '已保存,关闭' }).click() + await expect(activation).toBeHidden(); await expect(page.getByText('activate_fixture_one_time')).toHaveCount(0) + expect(calls.map(c => c.path)).toEqual(['/terminals/t1/disable', '/terminals/t1/reactivate']) +}) + +test('活动会话保护设备身份,运维和只读人员无设备维护入口', async ({ page }) => { + const calls = await fixture(page, 'super_admin', true); await page.goto('/terminals') + await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click() + await expect(page.getByRole('button', { name: '停用设备', exact: true })).toBeDisabled() + await expect(page.getByRole('button', { name: '重新激活', exact: true })).toBeDisabled(); expect(calls).toHaveLength(0) + await page.unroute('**/api/v1/**'); await fixture(page, 'operator'); await page.reload() + await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click() + await expect(page.getByRole('button', { name: '停用设备', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '编辑资料', exact: true })).toHaveCount(0) + await page.unroute('**/api/v1/**'); await fixture(page, 'viewer'); await page.goto('/users') + await expect(page.getByRole('button', { name: '批量授权', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '编辑资料', exact: true })).toHaveCount(0) +}) + +test('用户改名与停用保持授权,取消状态变更不提交', async ({ page }) => { + const calls = await fixture(page); await page.goto('/users') + const row = page.getByRole('row').filter({ hasText: 'designer' }) + await row.getByRole('button', { name: '编辑资料', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '编辑用户资料', exact: true }) + await dialog.getByLabel('编辑用户姓名').fill('新设计人员') + await dialog.locator('.el-switch').click() + await dialog.getByRole('button', { name: '保存用户资料' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0) + await dialog.getByRole('button', { name: '保存用户资料' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog).toBeHidden(); await expect(row).toContainText('新设计人员'); await expect(row).toContainText('设计桌面') + expect(calls[0]).toEqual({ path: '/users/u1', method: 'PUT', body: { displayName: '新设计人员', enabled: false } }) +}) + +test('批量授权明确目标和追加撤销语义,取消不提交', async ({ page }, testInfo) => { + const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/users') + for (const username of ['designer', 'office']) await page.getByRole('row').filter({ hasText: username }).locator('label.el-checkbox').click() + await page.getByRole('button', { name: /批量授权/ }).click() + const dialog = page.getByRole('dialog', { name: '批量桌面授权', exact: true }) + await expect(dialog).toContainText('目标用户(2 位)') + await expect(dialog.getByRole('button', { name: '提交批量授权' })).toBeDisabled() + await dialog.locator('label.el-checkbox').filter({ hasText: '办公桌面 · Ubuntu' }).click() + await dialog.getByRole('button', { name: '提交批量授权' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0) + await dialog.getByRole('button', { name: '提交批量授权' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog).toBeHidden() + const row = page.getByRole('row').filter({ hasText: 'designer' }) + await expect(row).toContainText('设计桌面'); await expect(row).toContainText('办公桌面') + await page.screenshot({ path: testInfo.outputPath('users-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: testInfo.outputPath('users-light.png'), fullPage: true, animations: 'disabled' }) + // Refresh intentionally clears table selection; choose targets again if needed. + if (await page.getByRole('button', { name: /批量授权/ }).isDisabled()) for (const username of ['designer', 'office']) await page.getByRole('row').filter({ hasText: username }).locator('label.el-checkbox').click() + await page.getByRole('button', { name: /批量授权/ }).click() + await dialog.locator('label.el-radio-button').filter({ hasText: '撤销指定' }).click() + await dialog.locator('label.el-checkbox').filter({ hasText: '办公桌面 · Ubuntu' }).click() + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: testInfo.outputPath('batch-users-mobile.png'), fullPage: true, animations: 'disabled' }) + await dialog.getByRole('button', { name: '提交批量授权' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog).toBeHidden(); await expect(row).toContainText('设计桌面'); await expect(row).not.toContainText('办公桌面') + expect(calls.map(c => c.body)).toEqual([{ userIds: ['u1', 'u2'], desktopIds: ['d2'], action: 'grant' }, { userIds: ['u1', 'u2'], desktopIds: ['d2'], action: 'revoke' }]) +}) diff --git a/console/e2e/operations.spec.ts b/console/e2e/operations.spec.ts new file mode 100644 index 0000000..ef72b54 --- /dev/null +++ b/console/e2e/operations.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, role = 'super_admin') { + const calls: { path: string; body: Record }[] = [] + const now = new Date(), earlier = new Date(now.getTime() - 180000).toISOString() + let acknowledged = false + const rules = [{ id: 'r1', name: '节点 CPU 高水位', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true }] + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'operations-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20, ...extra } }) + if (request.method() !== 'GET') { + const body = request.postData() ? request.postDataJSON() as Record : {}; calls.push({ path, body }) + if (path === '/imports/preview') { + const invalid = String(body.csv).includes('bad'), terminal = body.kind === 'terminals' + await route.fulfill({ json: { kind: body.kind, totalRows: 1, validRows: invalid ? 0 : 1, errorRows: invalid ? 1 : 0, previewToken: invalid ? '' : 'preview-token', rows: [{ line: 2, key: terminal ? 'BULK-001' : 'imported', values: terminal ? { sn: 'BULK-001', name: '批量设备', group: '研发' } : { username: 'imported', displayName: '导入用户' }, errors: invalid ? ['该账号已存在'] : [] }] } }); return + } + if (path === '/imports/commit') { await route.fulfill({ json: { id: 'i1', kind: body.kind, count: 1, replayed: false, items: [{ line: 2, key: 'imported', id: 'new-id' }], activationSecrets: body.kind === 'terminals' ? [{ sn: 'BULK-001', activationSecret: 'activate_test_only' }] : [] } }); return } + if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return } + if (path === '/monitor/rules' || path === '/monitor/rules/r1') { Object.assign(rules[0]!, body); await route.fulfill({ json: rules[0] }); return } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'root', displayName: '平台管理员', role } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/resources') { await list([{ id: 'node1', name: '计算节点', provider: 'proxmox', status: 'online', cpu: .4, memory: .6, storage: .5 }]); return } + if (path === '/sessions/history') { await list([{ id: 'h1', sessionId: 's1', terminalId: 't1', terminalName: '历史终端', desktopId: 'd1', desktopName: 'Windows 桌面', username: 'imported', displayName: '研发人员', state: 'interrupted', source: 'ticket', reservedAt: earlier, connectedAt: earlier, lastReportedAt: earlier, endedAt: now.toISOString(), endReason: 'heartbeat_timeout' }]); return } + if (path === '/monitor/rules') { await list(rules); return } + if (path === '/monitor/series') { await route.fulfill({ json: { bucketSeconds: 300, retentionDays: 30, items: [{ time: earlier, cpu: 94, memory: 60, storage: null, samples: 1 }, { time: now.toISOString(), cpu: 95, memory: 61, storage: null, samples: 1 }] } }); return } + if (path === '/monitor/alerts') { await list([{ id: 'a1', ruleId: 'r1', ruleName: '节点 CPU 高水位', metric: 'node_cpu', targetId: 'node1', targetName: '计算节点', value: 95, threshold: 90, status: 'firing', startedAt: earlier, openedAt: earlier, resolvedAt: null, resolveReason: '', acknowledgedAt: acknowledged ? now.toISOString() : null, acknowledgedBy: acknowledged ? 'root' : '' }], { firingCount: 1, unacknowledgedCount: acknowledged ? 0 : 1 }); return } + if (['/tasks', '/audit-logs', '/terminals', '/users', '/desktops'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: path } }) + }) + return calls +} + +test('CSV 预检有错误不能提交,修改后重新预检并整批导入', async ({ page }) => { + const calls = await fixture(page); await page.goto('/users') + await page.getByRole('button', { name: '导入用户', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '批量新增用户' }) + await dialog.getByLabel('CSV 内容').fill('username,displayName\nbad,重复\n') + await dialog.getByRole('button', { name: '预检文件' }).click() + await expect(dialog.getByText('该账号已存在', { exact: true })).toBeVisible() + await expect(dialog.getByRole('button', { name: '提交导入' })).toBeDisabled() + const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载错误行' }).click(); expect((await download).suggestedFilename()).toBe('import-errors.csv') + await dialog.getByLabel('CSV 内容').fill('username,displayName\nimported,导入用户\n') + await dialog.getByRole('button', { name: '预检文件' }).click() + await expect(dialog.getByRole('button', { name: '提交导入' })).toBeEnabled() + await dialog.getByRole('button', { name: '提交导入' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认导入' }).click() + await expect(dialog.getByText('导入完成', { exact: true })).toBeVisible() + expect(calls.filter(c => c.path.endsWith('/commit'))).toHaveLength(1) +}) + +test('批量终端激活信息支持下载,未下载关闭需要确认且密钥不残留', async ({ page }) => { + await fixture(page); await page.goto('/terminals') + await page.getByRole('button', { name: '批量导入', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '批量登记终端' }) + await dialog.getByLabel('CSV 内容').fill('sn,name,group\nBULK-001,批量设备,研发\n') + await dialog.getByRole('button', { name: '预检文件' }).click(); await dialog.getByRole('button', { name: '提交导入' }).click() + await page.locator('.el-message-box').getByRole('button', { name: '确认导入' }).click() + await expect(dialog.getByText('导入完成', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: '关闭', exact: true }).last().click() + await page.locator('.el-message-box').getByRole('button', { name: '返回保存' }).click() + const downloaded = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载终端激活信息' }).click(); expect((await downloaded).suggestedFilename()).toBe('terminal-activation.csv') + await dialog.getByRole('button', { name: '关闭', exact: true }).last().click(); await expect(dialog).toBeHidden() + await page.getByRole('button', { name: '批量导入', exact: true }).click(); await expect(dialog.getByRole('button', { name: '下载终端激活信息' })).toHaveCount(0) +}) + +test('会话历史按需查询,展示心跳超时和实际连接时长', async ({ page }, testInfo) => { + await fixture(page); await page.goto('/sessions'); await page.getByRole('tab', { name: '历史记录' }).click() + await expect(page.getByText('历史终端', { exact: true })).toBeVisible(); await expect(page.getByText('心跳超时', { exact: true })).toBeVisible() + await expect(page.getByText('3 分 0 秒')).toBeVisible() + await page.getByLabel('搜索历史会话').fill('历史终端'); await page.getByRole('button', { name: '查询历史' }).click() + await page.screenshot({ path: testInfo.outputPath('session-history.png'), fullPage: true, animations: 'disabled' }) +}) + +test('告警确认保留正在告警状态,规则编辑和深浅窄屏可用', async ({ page }, testInfo) => { + const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1050 }); await page.goto('/monitor') + await expect(page.getByRole('img', { name: 'CPU 使用率历史趋势' })).toBeVisible() + await expect(page.getByText('此范围暂无有效采样')).toBeVisible() + await page.getByRole('button', { name: '确认告警' }).click(); await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0) + await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('monitor-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: testInfo.outputPath('monitor-light.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click() + const dialog = page.getByRole('dialog', { name: '编辑告警规则' }) + await dialog.getByLabel('规则名称', { exact: true }).fill('CPU 持续高水位') + await dialog.getByRole('button', { name: '保存规则' }).click(); await expect(dialog).toBeHidden() + expect(calls.find(c => c.path === '/monitor/rules/r1')?.body.name).toBe('CPU 持续高水位') + await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: testInfo.outputPath('monitor-mobile.png'), fullPage: true, animations: 'disabled' }) +}) + +test('只读人员可查监控及历史,无导入、确认或规则编辑入口', async ({ page }) => { + await fixture(page, 'viewer'); await page.goto('/monitor') + await expect(page.getByText('节点 CPU 高水位', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0) + await page.getByRole('tab', { name: '告警规则' }).click(); await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0) + await page.goto('/users'); await expect(page.getByRole('button', { name: '导入用户', exact: true })).toHaveCount(0) +}) diff --git a/console/e2e/policies.spec.ts b/console/e2e/policies.spec.ts new file mode 100644 index 0000000..eb04379 --- /dev/null +++ b/console/e2e/policies.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, role = 'super_admin') { + const calls: { path: string; body: Record; key: string | undefined }[] = [] + const templates = [{ id: 'p1', name: '标准办公', version: '1.0', heartbeatInterval: 30, commandPollInterval: 3, desktopRefreshInterval: 15, createdAt: '2026-11-10T03:00:00Z' }] + const states = [ + { id: '', terminalId: 't1', terminalName: '设计工位', group: '设计组', protocol: 1, deploymentId: '', templateName: '', templateVersion: '', configVersion: 1, status: 'unconfigured', errorCode: '', acknowledgedAt: null, online: true, enabled: true, registered: true }, + { id: 'r0', terminalId: 't2', terminalName: '备用终端', group: '设计组', protocol: 1, deploymentId: 'd0', templateName: '标准办公', templateVersion: '1.0', configVersion: 2, status: 'failed', errorCode: 'persist_failed', acknowledgedAt: '2026-11-10T03:10:00Z', online: false, enabled: true, registered: true }, + { id: '', terminalId: 't3', terminalName: '旧客户端', group: '旧设备组', protocol: 0, deploymentId: '', templateName: '', templateVersion: '', configVersion: 1, status: 'unconfigured', errorCode: '', acknowledgedAt: null, online: true, enabled: true, registered: true }, + ] + const deployments = [{ id: 'd0', templateId: 'p1', templateName: '标准办公', templateVersion: '1.0', actor: 'root', targetCount: 1, createdAt: '2026-11-10T03:00:00Z' }] + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'policies-fixture')) + await page.route('**/api/v1/**', async route => { + const req = route.request(), path = new URL(req.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'policy-fixture' } }) + if (req.method() === 'POST') { + const body = req.postDataJSON() as Record; calls.push({ path, body, key: req.headers()['idempotency-key'] }) + if (path === '/terminal-policy-templates') { const t = { ...templates[0]!, ...body, id: 'p2' }; templates.unshift(t); await route.fulfill({ status: 201, json: t }); return } + if (path === '/terminal-policy-deployments') { + const chosen = states.filter(s => body.targetMode === 'group' ? s.group === body.group : (body.terminalIds as string[]).includes(s.terminalId)) + const d = { ...deployments[0]!, id: 'd1', targetCount: chosen.length }; deployments.unshift(d) + for (const s of chosen) Object.assign(s, { status: 'pending', deploymentId: 'd1', templateName: '标准办公', templateVersion: '1.0', configVersion: s.configVersion + 1, errorCode: '', acknowledgedAt: null }) + await route.fulfill({ status: 202, json: d }); return + } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'a', username: 'root', displayName: '管理员', role } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 3, onlineTerminals: 2, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: ['terminal-policies'] } }); return } + if (path === '/terminal-policy-templates') { await list(templates); return } + if (path === '/terminal-policy-states') { await list(states); return } + if (path === '/terminal-policy-deployments') { await list(deployments); return } + if (/\/terminal-policy-deployments\/.*\/results/.test(path)) { await list(states.filter(s => s.deploymentId === path.split('/')[2])); return } + if (['/terminals', '/users', '/resources', '/tasks', '/desktops', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: path, requestId: 'fixture' } }) + }) + return calls +} + +test('策略版本可保存和复制,深浅主题及手机表单可用', async ({ page }, info) => { + const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/terminal-policies') + await expect(page.getByRole('heading', { name: '终端策略', exact: true })).toBeVisible() + await page.getByRole('button', { name: '新建策略', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '新建终端策略版本' }) + await dialog.getByLabel('策略名称', { exact: true }).fill('研发组') + await dialog.getByLabel('策略版本', { exact: true }).fill('2.0') + await dialog.getByLabel('命令检查间隔', { exact: true }).fill('5') + await dialog.getByRole('button', { name: '保存策略版本' }).click(); await expect(dialog).toBeHidden() + expect(calls[0]?.body).toEqual({ name: '研发组', version: '2.0', heartbeatInterval: 30, commandPollInterval: 5, desktopRefreshInterval: 15 }) + await page.screenshot({ path: info.outputPath('policies-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.screenshot({ path: info.outputPath('policies-light.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('row').filter({ hasText: '研发组' }).getByRole('button', { name: '复制新版本' }).click() + await expect(dialog.getByLabel('策略版本', { exact: true })).toHaveValue('') + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('policy-create-mobile.png'), fullPage: true, animations: 'disabled' }) + await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(1) +}) + +test('按分组下发先确认,提交后等待设备回执,不显示已应用', async ({ page }, info) => { + const calls = await fixture(page); await page.goto('/terminal-policies') + await page.getByRole('row').filter({ hasText: '标准办公' }).getByRole('button', { name: '下发', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '下发终端策略', exact: true }) + await dialog.getByText('指定分组', { exact: true }).click() + await dialog.locator('.el-select').filter({ has: page.getByLabel('选择策略分组') }).click(); await page.getByRole('option', { name: '设计组', exact: true }).click() + await expect(dialog).toContainText('本次 2 台') + await dialog.getByRole('button', { name: '提交下发' }).click() + const box = page.locator('.el-message-box'); await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0) + await dialog.getByRole('button', { name: '提交下发' }).click(); await box.getByRole('button', { name: '确认下发', exact: true }).click() + const drawer = page.getByRole('dialog', { name: '策略下发结果', exact: true }) + await expect(drawer.getByText('等待设备应用', { exact: true })).toHaveCount(2) + await expect(drawer.getByText('已应用', { exact: true })).toHaveCount(0) + await expect(drawer).toContainText('离线等待') + expect(calls[0]?.body).toEqual({ templateId: 'p1', targetMode: 'group', group: '设计组' }); expect(calls[0]?.key).toBeTruthy() + await page.screenshot({ path: info.outputPath('policy-results-dark.png'), fullPage: true, animations: 'disabled' }) +}) + +test('旧客户端分组禁止下发,失败原因和重试入口可查看', async ({ page }) => { + const calls = await fixture(page); await page.goto('/terminal-policies') + await page.getByRole('tab', { name: '设备结果', exact: true }).click() + await expect(page.getByRole('row').filter({ hasText: '旧客户端' })).toContainText('需更新客户端') + const failed = page.getByRole('row').filter({ hasText: '备用终端' }); await expect(failed).toContainText('设备无法安全保存配置') + await failed.getByRole('button', { name: '重新下发' }).click() + const dialog = page.getByRole('dialog', { name: '下发终端策略', exact: true }); await expect(dialog).toContainText('本次 1 台') + await dialog.getByText('指定分组', { exact: true }).click(); await dialog.locator('.el-select').filter({ has: page.getByLabel('选择策略分组') }).click(); await page.getByRole('option', { name: '旧设备组', exact: true }).click() + await expect(dialog.getByRole('button', { name: '提交下发' })).toBeDisabled(); await expect(dialog).toContainText('旧客户端设备') + await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0) +}) + +test('只读和运维角色只能查看策略与结果', async ({ page }) => { + for (const role of ['viewer', 'operator']) { + await page.unroute('**/api/v1/**'); const calls = await fixture(page, role); await page.goto('/terminal-policies'); await page.reload() + await expect(page.getByRole('heading', { name: '终端策略', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '新建策略', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '下发', exact: true })).toHaveCount(0) + await page.getByRole('tab', { name: '设备结果', exact: true }).click() + await expect(page.getByRole('button', { name: '重新下发', exact: true })).toHaveCount(0); expect(calls).toHaveLength(0) + } +}) diff --git a/console/e2e/snapshots.spec.ts b/console/e2e/snapshots.spec.ts new file mode 100644 index 0000000..5155530 --- /dev/null +++ b/console/e2e/snapshots.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page } from '@playwright/test' + +// All requests are intercepted. No live VM receives snapshot or power actions. +async function fixture(page: Page, options: { running?: boolean; role?: string; deny?: boolean; empty?: boolean; fail?: boolean; retry?: boolean } = {}) { + const requests: { path: string; body: Record; key?: string }[] = [], now = new Date().toISOString() + const desktop = { id: 'desk-snap', name: '快照验收桌面', vmid: 9000, node: 'test', os: 'Windows', pool: '', poolId: null, provider: 'proxmox', powerState: options.running ? 'running' : 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.90', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now } + const items = options.empty ? [] : [{ name: 'baseline', description: '安装完成', createdAt: 1794268800, parent: '', includesMemory: false, state: '', revision: 'snap-v1' }, { name: 'with_memory', description: '保存内存', createdAt: 1794268801, parent: '', includesMemory: true, state: '', revision: 'memory-v1' }, { name: 'child', description: '后续版本', createdAt: 1794268802, parent: 'with_memory', includesMemory: false, state: '', revision: 'child-v1' }] + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'snapshot-fixture')) + await page.route('**/api/v1/**', async route => { + const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '') + const list = (values: unknown[]) => route.fulfill({ json: { items: values, total: values.length, page: 1, pageSize: 100, requestId: 'snapshot-ui' } }) + if (r.method() !== 'GET') { + requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] }) + if (path.endsWith('/snapshots/check')) { await route.fulfill({ json: { available: true, message: '快照预检通过' } }); return } + if (path.endsWith('/snapshots/actions')) { + if (options.retry && requests.filter(x => x.path === path).length === 1) { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '网络暂时不可用', requestId: 'snapshot-ui' } }); return } + await route.fulfill({ status: 202, json: { id: 'task-snap', kind: 'snapshot', action: 'snapshot-create', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'queued', message: '等待执行', providerRef: '', createdAt: now, updatedAt: now } }); return + } + if (path.endsWith('/snapshot-reconcile')) { await route.fulfill({ json: { accepted: true } }); return } + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-snap', username: 'root', displayName: '验收管理员', role: options.role || 'super_admin' } }); return } + if (path.endsWith('/snapshots')) { await route.fulfill(options.fail ? { status: 502, json: { code: 'snapshots_unavailable', message: '无法读取 PVE 快照', requestId: 'snapshot-ui' } } : { json: { items, digest: 'd1', powerState: desktop.powerState, blockers: options.running ? ['请先正常关闭桌面,再管理快照'] : [], disks: ['scsi0', 'efidisk0'], canCreate: !options.deny, canDelete: !options.deny, canRestore: !options.deny } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/desktops') { await list([desktop]); return } + if (path === '/tasks') { await list([{ id: 'snap-timeout', kind: 'snapshot', action: 'snapshot-rollback', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'timeout', message: '快照结果未确认,不会自动重发', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return } + if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'snapshot-ui' } }) + }) + return requests +} +async function open(page: Page) { + await page.goto('/desktops'); await page.getByRole('button', { name: '查看详情', exact: true }).click(); await page.getByRole('button', { name: '快照与还原', exact: true }).click() + return page.getByRole('dialog', { name: '快照与还原', exact: true }) +} +test('创建快照先预检,参数改变失效,取消不写,确认提交', async ({ page }, info) => { + const requests = await fixture(page, { empty: true }), dialog = await open(page) + await expect(dialog.getByText('尚无快照,可在关机后创建恢复点')).toBeVisible() + await dialog.getByRole('textbox', { name: '快照名称', exact: true }).fill('before_update') + await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click() + await expect(dialog.getByRole('button', { name: '提交创建快照', exact: true })).toBeEnabled() + await dialog.getByRole('textbox', { name: '快照描述', exact: true }).fill('升级之前') + await expect(dialog.getByRole('button', { name: '提交创建快照', exact: true })).toBeDisabled() + await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click() + await page.screenshot({ path: info.outputPath('snapshot-create-dark.png'), fullPage: true }) + await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click() + await page.getByRole('button', { name: '取消', exact: true }).click() + expect(requests.filter(r => r.path.endsWith('/actions'))).toHaveLength(0) + await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click() + await page.getByRole('button', { name: '提交创建快照任务', exact: true }).click() + await expect(dialog).not.toBeVisible() + const writes = requests.filter(r => r.path.endsWith('/actions')) + expect(writes).toHaveLength(1); expect(writes[0]?.body).toEqual({ action: 'create', name: 'before_update', description: '升级之前', digest: 'd1' }); expect(writes[0]?.key).toBeTruthy() +}) +test('还原需输入当前 VMID,保留磁盘范围提示,支持浅色窄屏', async ({ page }, info) => { + const requests = await fixture(page) + await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')) + await page.setViewportSize({ width: 390, height: 844 }); const dialog = await open(page) + await dialog.locator('.snapshot-item').filter({ hasText: 'baseline' }).getByRole('button', { name: '还原', exact: true }).click() + await expect(dialog.getByText(/包括快照中的数据盘/)).toBeVisible() + await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled() + await dialog.getByRole('textbox', { name: '还原确认 VMID', exact: true }).fill('100') + await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled() + await dialog.getByRole('textbox', { name: '还原确认 VMID', exact: true }).fill('9000') + await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await expect(dialog.locator('.el-dialog__header')).toBeInViewport() + await expect(dialog.getByRole('button', { name: '提交还原快照', exact: true })).toBeInViewport() + await page.screenshot({ path: info.outputPath('snapshot-restore-light-mobile.png'), fullPage: true }) + await dialog.getByRole('button', { name: '提交还原快照', exact: true }).click() + await page.getByRole('button', { name: '提交还原快照任务', exact: true }).click() + await expect(dialog).not.toBeVisible() + expect(requests.find(r => r.path.endsWith('/actions'))?.body).toEqual({ action: 'rollback', name: 'baseline', revision: 'snap-v1', digest: 'd1', confirmVmid: 9000 }) +}) +test('删除叶快照先确认,内存还原和含子快照的删除禁用', async ({ page }) => { + const requests = await fixture(page), dialog = await open(page) + const memory = dialog.locator('.snapshot-item').filter({ hasText: 'with_memory' }) + await expect(memory.getByRole('button', { name: '还原', exact: true })).toBeDisabled(); await expect(memory.getByRole('button', { name: '删除', exact: true })).toBeDisabled() + await dialog.locator('.snapshot-item').filter({ hasText: 'baseline' }).getByRole('button', { name: '删除', exact: true }).click() + await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click(); await dialog.getByRole('button', { name: '提交删除快照', exact: true }).click() + await expect(page.getByText(/将永久删除此恢复点/)).toBeVisible(); await page.getByRole('button', { name: '提交删除快照任务', exact: true }).click() + await expect(dialog).not.toBeVisible(); expect(requests.find(r => r.path.endsWith('/actions'))?.body).toEqual({ action: 'delete', name: 'baseline', digest: 'd1', revision: 'snap-v1' }) +}) +test('运行中或权限不足阻止操作', async ({ page }) => { + const requests = await fixture(page, { running: true }), dialog = await open(page) + await expect(dialog.getByText('请先正常关闭桌面,再管理快照')).toBeVisible() + await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled(); await expect(dialog.getByRole('textbox', { name: '快照名称', exact: true })).toBeDisabled() + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); expect(requests).toHaveLength(0) +}) +test('只读角色仅可查看列表', async ({ page }) => { + const requests = await fixture(page, { role: 'viewer' }), dialog = await open(page) + await expect(dialog.getByText(/当前角色可查看快照/)).toBeVisible() + await expect(dialog.getByRole('button', { name: '新建快照' })).toHaveCount(0); await expect(dialog.getByRole('button', { name: '还原', exact: true })).toHaveCount(0); expect(requests).toHaveLength(0) +}) +test('PVE 权限不足与读取失败明确显示', async ({ page }) => { + await fixture(page, { deny: true }); const dialog = await open(page) + await expect(dialog.getByText(/PVE Token 缺少当前操作的快照权限/)).toBeVisible() + await expect(dialog.getByRole('button', { name: '新建快照' })).toBeDisabled() + await page.unroute('**/api/v1/**'); await fixture(page, { fail: true }); await dialog.getByRole('button', { name: '重新读取', exact: true }).click() + await expect(dialog.getByText(/无法读取 PVE 快照/)).toBeVisible(); await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled() +}) +test('网络失败重试保留相同幂等标识', async ({ page }) => { + const requests = await fixture(page, { retry: true }), dialog = await open(page) + await dialog.getByRole('textbox', { name: '快照名称', exact: true }).fill('retry_point'); await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click() + for (let i = 0; i < 2; i++) { + await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click(); await page.getByRole('button', { name: '提交创建快照任务', exact: true }).click() + if (i === 0) await expect(dialog.getByText(/网络暂时不可用/)).toBeVisible() + } + await expect(dialog).not.toBeVisible(); const writes = requests.filter(r => r.path.endsWith('/actions')); expect(writes).toHaveLength(2); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.key).toBe(writes[1]?.key) +}) +test('任务页只读核对快照结果', async ({ page }) => { + const requests = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '快照验收桌面', exact: true }).first().click() + await page.getByRole('button', { name: '核对快照结果', exact: true }).click() + await expect.poll(() => requests.length).toBe(1); expect(requests[0]?.path).toBe('/tasks/snap-timeout/snapshot-reconcile') +}) diff --git a/console/e2e/terminal-metrics.spec.ts b/console/e2e/terminal-metrics.spec.ts new file mode 100644 index 0000000..6191735 --- /dev/null +++ b/console/e2e/terminal-metrics.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { stale?: boolean; legacy?: boolean; missing?: boolean; fail?: boolean; delayFirst?: boolean } = {}) { + const now = new Date().toISOString(), writes: string[] = [], reads: URL[] = [] + let fail = options.fail, firstDelivered = false + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'metrics-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '只读页面出现写请求' } }); return } + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'metrics-fixture' } }) + if (path === '/auth/me') { await route.fulfill({ json: { id: 'metrics-viewer', username: 'viewer', displayName: '只读人员', role: 'viewer' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: [], pveConfigured: true, computeProvider: 'proxmox' } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/terminals') { await list([{ id: 'metrics-a', name: '指标测试盒子', sn: 'METRICS-001', group: '测试', status: options.stale ? 'offline' : 'online', agentVersion: 'qt-platform/0.4.0', lastSeen: now, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }]); return } + if (path === '/terminals/metrics-a/metrics') { + reads.push(url) + const requestNumber = reads.length + if (options.delayFirst && reads.length === 1) await new Promise(resolve => setTimeout(resolve, 2000)) + if (fail) { fail = false; await route.fulfill({ status: 503, json: { code: 'unavailable', message: '指标服务暂不可用', requestId: 'metrics-fixture' } }); return } + const from = url.searchParams.get('from')!, to = url.searchParams.get('to')!, start = Date.parse(from) + await route.fulfill({ json: { latest: { cpuPercent: options.missing ? null : options.delayFirst && requestNumber > 1 ? 25 : 0, memoryPercent: 30, socTemperatureC: 44.37, uptimeSeconds: 90120, sampledAt: now, supported: !options.legacy, fresh: !options.stale && !options.legacy, online: !options.stale }, from, to, bucketSeconds: 60, retentionDays: 30, items: [ + { time: new Date(start + 60000).toISOString(), cpuPercent: 10, memoryPercent: 30, socTemperatureC: 44, uptimeSeconds: 90100, samples: 2 }, + { time: new Date(start + 120000).toISOString(), cpuPercent: null, memoryPercent: null, socTemperatureC: null, uptimeSeconds: null, samples: 0 }, + { time: new Date(start + 180000).toISOString(), cpuPercent: 20, memoryPercent: 30, socTemperatureC: 44.37, uptimeSeconds: 90120, samples: 2 }, + ] } }); if (requestNumber === 1) firstDelivered = true; return + } + if (['/desktops', '/users', '/tasks', '/audit-logs', '/resources'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '测试接口未定义' } }) + }) + return { writes, reads, firstDelivered: () => firstDelivered } +} +async function open(page: Page) { + await page.goto('/terminals') + await page.getByRole('button', { name: '详情', exact: true }).click() + await page.getByRole('button', { name: '查看运行指标', exact: true }).click() + return page.getByRole('region', { name: '终端运行指标' }) +} +test('真实指标独立按需加载,零值、SoC 温度、运行时长与缺失断线显示', async ({ page }, info) => { + const requests = await fixture(page) + await page.goto('/terminals'); await page.getByRole('button', { name: '详情', exact: true }).click() + expect(requests.reads).toHaveLength(0) + await page.getByRole('button', { name: '查看运行指标', exact: true }).click() + const panel = page.getByRole('region', { name: '终端运行指标' }) + await expect(panel.getByText('0.0 %', { exact: true })).toBeVisible() + await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible() + await expect(panel.getByText('1 天 1 小时 2 分钟', { exact: true })).toBeVisible() + await expect(panel.getByRole('img', { name: /历史趋势/ })).toHaveCount(3) + const path = await panel.getByRole('img', { name: 'CPU 使用率历史趋势' }).locator('path').getAttribute('d') + expect(path?.match(/M/g)).toHaveLength(2); expect(path).not.toContain('L') + await expect(panel.getByText(/串流 FPS\/RTT 尚未采集/)).toBeVisible() + await page.screenshot({ path: info.outputPath('terminal-metrics-dark.png'), fullPage: true }) + expect(requests.writes).toEqual([]) +}) +test('离线隐藏当前数值并保留历史', async ({ page }) => { + const requests = await fixture(page, { stale: true }); const panel = await open(page) + await expect(panel.getByText(/设备离线或采样已过期/)).toBeVisible() + await expect(panel.locator('.metric-values strong')).toHaveText(['未采集', '未采集', '未采集', '未采集']) + await expect(panel.getByRole('img', { name: /历史趋势/ })).toHaveCount(3); expect(requests.writes).toEqual([]) +}) +test('旧 Agent 给出升级提示,不冒充零值', async ({ page }) => { + await fixture(page, { legacy: true }); const panel = await open(page) + await expect(panel.getByText('设备尚未上报系统指标,请更新 Agent', { exact: true })).toBeVisible() + await expect(panel.locator('.metric-values strong')).toHaveText(['未采集', '未采集', '未采集', '未采集']) +}) +test('单项缺失保持未采集,读取失败可重试', async ({ page }) => { + await fixture(page, { missing: true, fail: true }); const panel = await open(page) + await expect(panel.getByText('指标服务暂不可用', { exact: true })).toBeVisible() + await expect(panel.locator('.metric-values')).toHaveCount(0) + await panel.getByRole('button', { name: '刷新指标' }).click() + await expect(panel.locator('.metric-values strong').first()).toHaveText('未采集') + await expect(panel.getByText('30.0 %', { exact: true })).toBeVisible() +}) +test('时间范围切换与窄屏浅色展示', async ({ page }, info) => { + const requests = await fixture(page) + await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')) + await page.setViewportSize({ width: 390, height: 844 }) + const panel = await open(page) + await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible() + await panel.locator('.el-select').click() + await page.getByRole('option', { name: '最近 7 天' }).click() + await expect.poll(() => requests.reads.length).toBe(2) + const url = requests.reads[1]! + expect(Date.parse(url.searchParams.get('to')!) - Date.parse(url.searchParams.get('from')!)).toBe(7 * 86400000) + await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('terminal-metrics-mobile-light.png'), fullPage: true }) + expect(requests.writes).toEqual([]) +}) +test('切换时间范围时旧响应不能覆盖新响应', async ({ page }) => { + const requests = await fixture(page, { delayFirst: true }); const panel = await open(page) + await expect.poll(() => requests.reads.length).toBe(1) + await panel.locator('.el-select').click(); await page.getByRole('option', { name: '最近 24 小时' }).click() + await expect.poll(() => requests.reads.length).toBe(2) + await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible() + await expect.poll(requests.firstDelivered).toBe(true) + await expect(panel.getByText('25.0 %', { exact: true })).toBeVisible() + await expect(panel.locator('.el-select')).toHaveText('最近 24 小时') +}) diff --git a/console/src/api/index.ts b/console/src/api/index.ts index 4f3eafe..7dc414e 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -1,11 +1,51 @@ import createClient from 'openapi-fetch' import type { paths, components } from './schema' +export type PveConnectionInput = components['schemas']['PveConnectionInput'] +export type PowerBatchInput = components['schemas']['PowerBatchInput'] +export type PowerBatch = components['schemas']['PowerBatch'] +export type PowerBatchDetail = components['schemas']['PowerBatchDetail'] +export type PowerBatchPreview = components['schemas']['PowerBatchPreview'] +export type SnapshotInput = components['schemas']['SnapshotInput'] +export type SnapshotView = components['schemas']['SnapshotView'] +export type SnapshotItem = components['schemas']['SnapshotItem'] +export type HardwareInput = components['schemas']['HardwareInput'] +export type VMHardware = components['schemas']['VMHardware'] export type Admin = components['schemas']['Administrator'] +export type ImportPreview = components['schemas']['CsvImportPreview'] +export type ImportResult = components['schemas']['CsvImportResult'] +export type CsvImportInput = components['schemas']['CsvImportInput'] +export type SessionRecord = components['schemas']['SessionRecord'] +export type AlertRule = components['schemas']['AlertRule'] +export type AlertRuleInput = components['schemas']['AlertRuleInput'] +export type AlertRecord = components['schemas']['AlertRecord'] +export type ResourcePoint = components['schemas']['ResourcePoint'] +export type TerminalMetrics = components['schemas']['TerminalMetrics'] +export type TerminalMetricPoint = components['schemas']['TerminalMetricPoint'] +export type TerminalPolicy = components['schemas']['TerminalPolicy'] +export type TerminalPolicyInput = components['schemas']['TerminalPolicyInput'] +export type PolicyDeployment = components['schemas']['PolicyDeployment'] +export type PolicyDeploymentInput = components['schemas']['PolicyDeploymentInput'] +export type PolicyResult = components['schemas']['PolicyResult'] +export type AdministratorAccount = components['schemas']['AdministratorAccount'] +export type CreateAdministrator = components['schemas']['CreateAdministrator'] +export type UpdateAdministrator = components['schemas']['UpdateAdministrator'] export type Desktop = components['schemas']['Desktop'] +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 CloneInput = components['schemas']['CloneInput'] +export type Storage = components['schemas']['Storage'] +export type ConnectionInput = components['schemas']['ConnectionInput'] +export type TerminalUpdate = components['schemas']['TerminalUpdate'] +export type UserUpdate = components['schemas']['UserUpdate'] +export type BatchBindings = components['schemas']['BatchBindings'] export type Terminal = components['schemas']['Terminal'] export type User = components['schemas']['User'] export type Resource = components['schemas']['Resource'] +export type GPUInventory = components['schemas']['GPUInventory'] +export type GPUItem = components['schemas']['GPUItem'] export type Task = components['schemas']['Task'] export type Audit = components['schemas']['Audit'] export type Overview = components['schemas']['Overview'] @@ -40,9 +80,46 @@ async function result(promise: Promise>): Promise { const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() }) const query = { page: 1, pageSize: 100 } export const api = { + gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), + pveConnection: () => result(client.GET('/settings/pve')), + checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })), + savePveConnection: (body: PveConnectionInput) => result(client.PUT('/settings/pve', { body })), + desktopPowerBatches: (page = 1) => result(client.GET('/desktop-batches', { params: { query: { ...query, page } } })), + desktopPowerBatch: (id: string) => result(client.GET('/desktop-batches/{id}', { params: { path: { id } } })), + checkDesktopPowerBatch: (body: PowerBatchInput) => result(client.POST('/desktop-batches/check', { body })), + submitDesktopPowerBatch: (body: PowerBatchInput, key: string) => result(client.POST('/desktop-batches', { params: { header: { 'Idempotency-Key': key } }, body })), + reconcileBatchPower: (id: string) => result(client.POST('/tasks/{id}/batch-power-reconcile', { params: { path: { id } } })), + desktopSnapshots: (id: string) => result(client.GET('/desktops/{id}/snapshots', { params: { path: { id } } })), + checkDesktopSnapshot: (id: string, body: SnapshotInput) => result(client.POST('/desktops/{id}/snapshots/check', { params: { path: { id } }, body })), + changeDesktopSnapshot: (id: string, body: SnapshotInput, key: string) => result(client.POST('/desktops/{id}/snapshots/actions', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body })), + reconcileSnapshot: (id: string) => result(client.POST('/tasks/{id}/snapshot-reconcile', { params: { path: { id } } })), + desktopHardware: (id: string) => result(client.GET('/desktops/{id}/hardware', { params: { path: { id } } })), + checkDesktopHardware: (id: string, body: HardwareInput) => result(client.POST('/desktops/{id}/hardware/check', { params: { path: { id } }, body })), + changeDesktopHardware: (id: string, body: HardwareInput, key: string) => result(client.POST('/desktops/{id}/hardware', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body })), + reconcileHardware: (id: string) => result(client.POST('/tasks/{id}/hardware-reconcile', { params: { path: { id } } })), + previewImport: (body: CsvImportInput) => result(client.POST('/imports/preview', { body })), + commitImport: (body: CsvImportInput, key: string) => result(client.POST('/imports/commit', { params: { header: { 'Idempotency-Key': key } }, body })), + sessionHistory: (filters: { page?: number; keyword?: string; status?: string; from?: string; to?: string }) => result(client.GET('/sessions/history', { params: { query: { ...query, pageSize: 20, ...filters } } })), + terminalMetrics: (id: string, from: string, to: string) => result(client.GET('/terminals/{id}/metrics', { params: { path: { id }, query: { from, to } } })), + resourceSeries: (resourceId: string, from: string, to: string) => result(client.GET('/monitor/series', { params: { query: { resourceId, from, to } } })), + alertRules: (page = 1) => result(client.GET('/monitor/rules', { params: { query: { ...query, page } } })), + saveAlertRule: (id: string, body: AlertRuleInput) => id ? result(client.PUT('/monitor/rules/{id}', { params: { path: { id } }, body })) : result(client.POST('/monitor/rules', { body })), + alerts: (page = 1, status = '', keyword = '') => result(client.GET('/monitor/alerts', { params: { query: { ...query, page, pageSize: 20, status, keyword } } })), + acknowledgeAlert: (id: string) => result(client.POST('/monitor/alerts/{id}/acknowledge', { params: { path: { id } } })), + terminalPolicies: (page = 1) => result(client.GET('/terminal-policy-templates', { params: { query: { ...query, page } } })), + createTerminalPolicy: (body: TerminalPolicyInput) => result(client.POST('/terminal-policy-templates', { body })), + policyStates: (page = 1) => result(client.GET('/terminal-policy-states', { params: { query: { ...query, page } } })), + policyDeployments: (page = 1) => result(client.GET('/terminal-policy-deployments', { params: { query: { ...query, page } } })), + deployPolicy: (body: PolicyDeploymentInput, key: string) => result(client.POST('/terminal-policy-deployments', { params: { header: { 'Idempotency-Key': key } }, body })), + policyResults: (id: string, page = 1) => result(client.GET('/terminal-policy-deployments/{id}/results', { params: { path: { id }, query: { ...query, page } } })), login: (username: string, password: string) => result(client.POST('/auth/login', { body: { username, password } })), me: () => result(client.GET('/auth/me')), logout: () => result(client.POST('/auth/logout')), + changeOwnPassword: (currentPassword: string, newPassword: string) => result(client.POST('/auth/password', { body: { currentPassword, newPassword } })), + administrators: (page = 1) => result(client.GET('/administrators', { params: { query: { ...query, page } } })), + createAdministrator: (body: CreateAdministrator) => result(client.POST('/administrators', { body })), + updateAdministrator: (id: string, body: UpdateAdministrator) => result(client.PUT('/administrators/{id}', { params: { path: { id } }, body })), + resetAdministratorPassword: (id: string, password: string) => result(client.POST('/administrators/{id}/password', { params: { path: { id } }, body: { password } })), overview: () => result(client.GET('/overview')), settings: () => result(client.GET('/settings')), resources: () => result(client.GET('/resources', { params: { query } })), @@ -51,12 +128,30 @@ export const api = { users: (page = 1) => result(client.GET('/users', { params: { query: { ...query, page } } })), tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })), taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })), + reconcileProvision: (id: string) => result(client.POST('/tasks/{id}/reconcile', { params: { path: { id } } })), audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })), discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })), + 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 } })), + 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 })), + poolMembers: (id: string, desktopIds: string[]) => result(client.PUT('/pools/{id}/members', { params: { path: { id } }, body: { desktopIds } })), + storages: (node: string, page = 1) => result(client.GET('/resources/storage', { params: { query: { ...query, page, node } } })), + 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 })), 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 } })), enroll: (body: EnrollmentInput) => result(client.POST('/terminals', { body })), + updateTerminal: (id: string, body: TerminalUpdate) => result(client.PUT('/terminals/{id}', { params: { path: { id } }, body })), + disableTerminal: (id: string) => result(client.POST('/terminals/{id}/disable', { params: { path: { id } } })), + reactivateTerminal: (id: string) => result(client.POST('/terminals/{id}/reactivate', { params: { path: { id } } })), + updateUser: (id: string, body: UserUpdate) => result(client.PUT('/users/{id}', { params: { path: { id } }, body })), + batchBindings: (body: BatchBindings) => result(client.POST('/users/batch-bindings', { body })), terminalCommand: (id: string, action: TerminalAction) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: commandHeaders() }, body: { action } })), createUser: (body: { username: string; displayName: string }) => result(client.POST('/users', { body })), updateBindings: (id: string, body: BindingInput) => result(client.PUT('/users/{id}/bindings', { params: { path: { id } }, body })), diff --git a/console/src/api/schema.d.ts b/console/src/api/schema.d.ts index b0279ed..491346d 100644 --- a/console/src/api/schema.d.ts +++ b/console/src/api/schema.d.ts @@ -4,6 +4,422 @@ */ export interface paths { + "/settings/pve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["pveConnection"]; + put: operations["savePveConnection"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/settings/pve/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkPveConnection"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/hardware": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["desktopHardware"]; + put?: never; + post: operations["changeDesktopHardware"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/hardware/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkDesktopHardware"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktop-batches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["desktopPowerBatches"]; + put?: never; + post: operations["submitDesktopPowerBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktop-batches/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkDesktopPowerBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktop-batches/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["desktopPowerBatch"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{id}/batch-power-reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reconcileBatchPower"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["desktopSnapshots"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/snapshots/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkDesktopSnapshot"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/snapshots/actions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changeDesktopSnapshot"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{id}/snapshot-reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reconcileSnapshot"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{id}/hardware-reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reconcileHardware"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/imports/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["previewCsvImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/imports/commit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["commitCsvImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["sessionHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitor/series": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resourceSeries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitor/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["alertRules"]; + put?: never; + post: operations["createAlertRule"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitor/rules/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateAlertRule"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitor/alerts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["alerts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitor/alerts/{id}/acknowledge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["acknowledgeAlert"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal-policy-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["terminalPolicyTemplates"]; + put?: never; + post: operations["createTerminalPolicy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal-policy-states": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["terminalPolicyStates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal-policy-deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["policyDeployments"]; + put?: never; + post: operations["deployPolicy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal-policy-deployments/{id}/results": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["policyResults"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal/config/ack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["policyAck"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/login": { parameters: { query?: never; @@ -52,6 +468,70 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changeOwnPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/administrators": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["administrators"]; + put?: never; + post: operations["createAdministrator"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/administrators/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateAdministrator"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/administrators/{id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetAdministratorPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/overview": { parameters: { query?: never; @@ -100,6 +580,22 @@ export interface paths { patch?: never; trace?: never; }; + "/resources/gpus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["gpuInventory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/vms": { parameters: { query?: never; @@ -116,6 +612,182 @@ export interface paths { patch?: never; trace?: never; }; + "/resources/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["discoverTemplates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resources/storage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["storages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["templates"]; + put?: never; + post: operations["registerTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateTemplate"]; + trace?: never; + }; + "/templates/{id}/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["validateTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["pools"]; + put?: never; + post: operations["createPool"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updatePool"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools/{id}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["poolMembers"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/clone/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkClone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/clone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cloneDesktop"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/desktops/{id}/connection": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["configureDesktop"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/sync": { parameters: { query?: never; @@ -212,6 +884,70 @@ export interface paths { patch?: never; trace?: never; }; + "/terminals/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateTerminal"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminals/{id}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["terminalMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminals/{id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableTerminal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminals/{id}/reactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reactivateTerminal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/terminals/{id}/commands": { parameters: { query?: never; @@ -244,6 +980,38 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateUser"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/batch-bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchBindings"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/bindings": { parameters: { query?: never; @@ -292,6 +1060,22 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{id}/reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reconcileProvision"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{id}/diagnostics": { parameters: { query?: never; @@ -483,6 +1267,7 @@ export interface components { displayName: string; /** @enum {string} */ role: "super_admin" | "operator" | "viewer"; + enabled?: boolean; }; LoginInput: { username: string; @@ -512,6 +1297,8 @@ export interface components { fps: number; /** Format: date-time */ updatedAt: string; + poolId?: string | null; + setupRequired?: boolean; }; Terminal: { id: string; @@ -527,6 +1314,7 @@ export interface components { fps: number | null; rtt: number | null; sessionState?: string; + enabled?: boolean; }; User: { id: string; @@ -547,6 +1335,50 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + GPUReference: { + vmid: number; + vmName: string; + vmStatus: string; + template: boolean; + desktopId: string; + desktopName: string; + slot: string; + /** @enum {string} */ + phase: "current" | "pending"; + /** @enum {string} */ + source: "direct" | "mapping"; + mapping: string; + mediated: boolean; + pendingRemoval: boolean; + }; + GPUItem: { + id: string; + address: string; + name: string; + vendor: string; + vendorId: string; + deviceId: string; + class: string; + iommuGroup: number | null; + /** @enum {string} */ + state: "unreferenced" | "unknown" | "referenced" | "multiple"; + references: components["schemas"]["GPUReference"][]; + }; + GPUIssue: { + code: string; + vmid: number; + message: string; + }; + GPUInventory: { + node: string; + /** Format: date-time */ + observedAt: string; + complete: boolean; + visibleVmCount: number; + scannedVmCount: number; + items: components["schemas"]["GPUItem"][]; + issues: components["schemas"]["GPUIssue"][]; + }; Task: { id: string; kind: string; @@ -616,6 +1448,24 @@ export interface components { id: string; activationSecret: string; }; + TerminalUpdate: { + name: string; + group: string; + }; + UserUpdate: { + displayName: string; + enabled: boolean; + }; + BatchBindings: { + userIds: string[]; + desktopIds: string[]; + /** @enum {string} */ + action: "grant" | "revoke"; + }; + BatchBindingsResult: { + accepted: boolean; + userCount: number; + }; CreateUserInput: { username: string; displayName: string; @@ -647,6 +1497,44 @@ export interface components { agentVersion: string; fps?: number | null; rtt?: number | null; + policyProtocol?: number; + systemMetrics?: components["schemas"]["TerminalSystemMetrics"]; + }; + TerminalSystemMetrics: { + cpuPercent?: number | null; + memoryPercent?: number | null; + socTemperatureC?: number | null; + uptimeSeconds?: number | null; + }; + TerminalMetricLatest: { + cpuPercent: number | null; + memoryPercent: number | null; + socTemperatureC: number | null; + uptimeSeconds: number | null; + /** Format: date-time */ + sampledAt: string | null; + supported: boolean; + online: boolean; + fresh: boolean; + }; + TerminalMetricPoint: { + /** Format: date-time */ + time: string; + cpuPercent: number | null; + memoryPercent: number | null; + socTemperatureC: number | null; + uptimeSeconds: number | null; + samples: number; + }; + TerminalMetrics: { + latest: components["schemas"]["TerminalMetricLatest"]; + items: components["schemas"]["TerminalMetricPoint"][]; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + bucketSeconds: number; + retentionDays: number; }; Accepted: { accepted: boolean; @@ -661,6 +1549,10 @@ export interface components { identityMode: string; commandTransport: string; heartbeatInterval: number; + deploymentId: string; + policyProtocol: number; + commandPollInterval: number; + desktopRefreshInterval: number; }; TicketInput: { desktopId: string; @@ -704,6 +1596,416 @@ export interface components { message?: string; diagnostics?: components["schemas"]["DeviceDiagnostics"]; }; + PveConnectionInput: { + url: string; + tokenId: string; + /** Format: password */ + tokenSecret?: string; + caPem?: string; + revision: string; + }; + PveConnection: { + url: string; + tokenId: string; + caPem: string; + revision: string; + tokenConfigured: boolean; + /** @enum {string} */ + source: "environment" | "database"; + }; + PveConnectionCheck: { + available: boolean; + nodes: number; + vms: number; + message: string; + }; + VMDisk: { + key: string; + storage: string; + sizeGiB: number; + }; + VMHardware: { + cores: number; + sockets: number; + memoryMiB: number; + digest: string; + disks: components["schemas"]["VMDisk"][]; + powerState: string; + blockers: string[]; + }; + HardwareInput: { + /** @enum {string} */ + kind: "compute" | "disk"; + digest: string; + cores?: number; + sockets?: number; + memoryMiB?: number; + disk?: string; + sizeGiB?: number; + }; + PowerBatchInput: { + /** @enum {string} */ + action: "start" | "shutdown" | "reboot"; + desktopIds: string[]; + previewToken?: string; + }; + PowerBatchCheckItem: { + desktopId: string; + name: string; + vmid: number; + powerState: string; + available: boolean; + code: string; + message: string; + }; + PowerBatchPreview: { + items: components["schemas"]["PowerBatchCheckItem"][]; + eligibleCount: number; + skippedCount: number; + previewToken: string; + /** Format: date-time */ + expiresAt: string; + }; + PowerBatch: { + id: string; + /** @enum {string} */ + action: "start" | "shutdown" | "reboot"; + actor: string; + /** Format: date-time */ + createdAt: string; + totalCount: number; + submittedCount: number; + skippedCount: number; + activeCount: number; + successCount: number; + failedCount: number; + uncertainCount: number; + }; + PowerBatchItem: { + desktopId: string; + name: string; + vmid: number; + taskId: string; + status: string; + code: string; + message: string; + }; + PowerBatchDetail: { + batch: components["schemas"]["PowerBatch"]; + items: components["schemas"]["PowerBatchItem"][]; + }; + SnapshotItem: { + name: string; + description: string; + createdAt: number; + parent: string; + includesMemory: boolean; + state: string; + revision: string; + }; + SnapshotView: { + items: components["schemas"]["SnapshotItem"][]; + digest: string; + powerState: string; + blockers: string[]; + disks: string[]; + canCreate: boolean; + canDelete: boolean; + canRestore: boolean; + }; + SnapshotInput: { + /** @enum {string} */ + action: "create" | "delete" | "rollback"; + name: string; + description?: string; + digest: string; + revision?: string; + confirmVmid?: number; + }; + HardwareCheck: { + available: boolean; + message: string; + }; + CsvImportInput: { + /** @enum {string} */ + kind: "users" | "terminals" | "bindings"; + csv: string; + previewToken?: string; + }; + CsvImportRow: { + line: number; + key: string; + values: { + [key: string]: string; + }; + errors: string[]; + }; + CsvImportPreview: { + kind: string; + totalRows: number; + validRows: number; + errorRows: number; + previewToken: string; + rows: components["schemas"]["CsvImportRow"][]; + }; + CsvImportResult: { + id: string; + kind: string; + count: number; + items: { + line: number; + key: string; + id: string; + }[]; + activationSecrets: { + sn: string; + activationSecret: string; + }[]; + replayed: boolean; + }; + SessionRecord: { + id: string; + sessionId: string; + terminalId: string; + terminalName: string; + desktopId: string; + desktopName: string; + username: string; + displayName: string; + /** @enum {string} */ + state: "connecting" | "connected" | "ended" | "interrupted"; + /** @enum {string} */ + source: "ticket" | "observed"; + /** Format: date-time */ + reservedAt: string; + /** Format: date-time */ + connectedAt: string | null; + /** Format: date-time */ + lastReportedAt: string; + /** Format: date-time */ + endedAt: string | null; + endReason: string; + }; + AlertRuleInput: { + name: string; + /** @enum {string} */ + metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline"; + threshold: number; + holdSeconds: number; + enabled: boolean; + }; + AlertRule: { + id: string; + name: string; + /** @enum {string} */ + metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline"; + threshold: number; + holdSeconds: number; + enabled: boolean; + }; + AlertRecord: { + id: string; + ruleId: string; + ruleName: string; + metric: string; + targetId: string; + targetName: string; + value: number; + threshold: number; + /** @enum {string} */ + status: "firing" | "resolved"; + /** Format: date-time */ + startedAt: string; + /** Format: date-time */ + openedAt: string; + /** Format: date-time */ + resolvedAt: string | null; + resolveReason: string; + /** Format: date-time */ + acknowledgedAt: string | null; + acknowledgedBy: string; + }; + ResourcePoint: { + /** Format: date-time */ + time: string; + cpu: number | null; + memory: number | null; + storage: number | null; + samples: number; + }; + ResourceSeries: { + items: components["schemas"]["ResourcePoint"][]; + bucketSeconds: number; + retentionDays: number; + }; + TerminalPolicyInput: { + name: string; + version: string; + heartbeatInterval: number; + commandPollInterval: number; + desktopRefreshInterval: number; + }; + TerminalPolicy: { + id: string; + name: string; + version: string; + heartbeatInterval: number; + commandPollInterval: number; + desktopRefreshInterval: number; + /** Format: date-time */ + createdAt: string; + }; + PolicyDeployment: { + id: string; + templateId: string; + templateName: string; + templateVersion: string; + actor: string; + targetCount: number; + /** Format: date-time */ + createdAt: string; + }; + PolicyDeploymentInput: { + templateId: string; + /** @enum {string} */ + targetMode: "devices" | "group"; + terminalIds?: string[]; + group?: string; + }; + PolicyResult: { + id: string; + terminalId: string; + terminalName: string; + group: string; + protocol: number; + deploymentId: string; + templateName: string; + templateVersion: string; + configVersion: number; + /** @enum {string} */ + status: "unconfigured" | "pending" | "applied" | "failed" | "superseded"; + errorCode: string; + /** Format: date-time */ + acknowledgedAt: string | null; + online: boolean; + enabled: boolean; + registered: boolean; + }; + PolicyAck: { + deploymentId: string; + configVersion: number; + /** @enum {string} */ + status: "applied" | "failed"; + /** @enum {string} */ + errorCode?: "invalid_policy" | "persist_failed" | "runtime_unavailable"; + }; + AdministratorAccount: { + id: string; + username: string; + displayName: string; + /** @enum {string} */ + role: "super_admin" | "operator" | "viewer"; + enabled: boolean; + }; + CreateAdministrator: { + username: string; + displayName: string; + /** @enum {string} */ + role: "super_admin" | "operator" | "viewer"; + /** Format: password */ + password: string; + }; + UpdateAdministrator: { + displayName: string; + /** @enum {string} */ + role: "super_admin" | "operator" | "viewer"; + enabled: boolean; + }; + ResetAdministratorPassword: { + /** Format: password */ + password: string; + }; + ChangePassword: { + /** Format: password */ + currentPassword: string; + /** Format: password */ + newPassword: string; + }; + ImageTemplate: { + id: string; + name: string; + version: string; + /** @enum {string} */ + os: "Windows" | "Ubuntu" | "麒麟"; + node: string; + vmid: number; + enabled: boolean; + /** Format: date-time */ + validatedAt: string; + }; + TemplateInput: { + name: string; + version: string; + /** @enum {string} */ + os: "Windows" | "Ubuntu" | "麒麟"; + node: string; + vmid: number; + }; + TemplateUpdate: { + enabled: boolean; + }; + DesktopPool: { + id: string; + name: string; + /** @enum {string} */ + type: "dedicated" | "shared"; + maxCount: number; + templateId: string | null; + enabled: boolean; + /** Format: date-time */ + createdAt: string; + desktopCount: number; + reservedCount: number; + assignedCount: number; + activeCount: number; + }; + PoolInput: { + name: string; + /** @enum {string} */ + type: "dedicated" | "shared"; + maxCount: number; + templateId: string | null; + enabled: boolean; + }; + PoolMembers: { + desktopIds: string[]; + }; + CloneInput: { + templateId: string; + poolId: string | null; + name: string; + vmid: number; + full: boolean; + storage: string; + }; + CloneCheck: { + available: boolean; + message: string; + }; + Storage: { + storage: string; + avail: number; + active: number; + enabled: number; + content: string; + }; + ConnectionInput: { + endpoint: string; + width: number; + height: number; + fps: number; + initialized: boolean; + }; DesktopList: { items: components["schemas"]["Desktop"][]; page: number; @@ -753,6 +2055,85 @@ export interface components { total: number; requestId: string; }; + ImageTemplateList: { + items: components["schemas"]["ImageTemplate"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + DesktopPoolList: { + items: components["schemas"]["DesktopPool"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + StorageList: { + items: components["schemas"]["Storage"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + AdministratorAccountList: { + items: components["schemas"]["AdministratorAccount"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + TerminalPolicyList: { + items: components["schemas"]["TerminalPolicy"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + PolicyDeploymentList: { + items: components["schemas"]["PolicyDeployment"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + PolicyResultList: { + items: components["schemas"]["PolicyResult"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + SessionRecordList: { + items: components["schemas"]["SessionRecord"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + AlertRuleList: { + items: components["schemas"]["AlertRule"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; + AlertRecordList: { + items: components["schemas"]["AlertRecord"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + firingCount: number; + unacknowledgedCount: number; + }; + PowerBatchList: { + items: components["schemas"]["PowerBatch"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; }; responses: never; parameters: never; @@ -762,6 +2143,3380 @@ export interface components { } export type $defs = Record; export interface operations { + pveConnection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PveConnection"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + savePveConnection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PveConnectionInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + 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"]; + }; + }; + }; + }; + checkPveConnection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PveConnectionInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PveConnectionCheck"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + desktopHardware: { + 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"]["VMHardware"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + changeDesktopHardware: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["HardwareInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Task"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + checkDesktopHardware: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["HardwareInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HardwareCheck"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + desktopPowerBatches: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PowerBatchList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + submitDesktopPowerBatch: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PowerBatchInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PowerBatchDetail"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + checkDesktopPowerBatch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PowerBatchInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PowerBatchPreview"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + desktopPowerBatch: { + 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"]["PowerBatchDetail"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + reconcileBatchPower: { + 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"]["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"]; + }; + }; + }; + }; + desktopSnapshots: { + 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"]["SnapshotView"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + checkDesktopSnapshot: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SnapshotInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HardwareCheck"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + changeDesktopSnapshot: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SnapshotInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Task"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + reconcileSnapshot: { + 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"]["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"]; + }; + }; + }; + }; + reconcileHardware: { + 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"]["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"]; + }; + }; + }; + }; + previewCsvImport: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CsvImportInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CsvImportPreview"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + commitCsvImport: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CsvImportInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CsvImportResult"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + sessionHistory: { + parameters: { + query?: { + from?: string; + to?: string; + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionRecordList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + resourceSeries: { + parameters: { + query: { + from?: string; + to?: string; + resourceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResourceSeries"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + alertRules: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlertRuleList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + createAlertRule: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertRuleInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlertRule"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + updateAlertRule: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertRuleInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlertRule"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + alerts: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlertRecordList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + acknowledgeAlert: { + 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"]["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"]; + }; + }; + }; + }; + terminalPolicyTemplates: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TerminalPolicyList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + createTerminalPolicy: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TerminalPolicyInput"]; + }; + }; + responses: { + /** @description Successful response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TerminalPolicy"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + terminalPolicyStates: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PolicyResultList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + policyDeployments: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PolicyDeploymentList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + deployPolicy: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PolicyDeploymentInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PolicyDeployment"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + policyResults: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PolicyResultList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + policyAck: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PolicyAck"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + 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"]; + }; + }; + }; + }; login: { parameters: { query?: never; @@ -830,6 +5585,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -922,6 +5686,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1012,6 +5785,541 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + changeOwnPassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePassword"]; + }; + }; + responses: { + /** @description Successful response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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"]; + }; + }; + }; + }; + administrators: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdministratorAccountList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + createAdministrator: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateAdministrator"]; + }; + }; + responses: { + /** @description Successful response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdministratorAccount"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + updateAdministrator: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAdministrator"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdministratorAccount"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + resetAdministratorPassword: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResetAdministratorPassword"]; + }; + }; + responses: { + /** @description Successful response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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; @@ -1104,6 +6412,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1196,6 +6513,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1293,6 +6619,118 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + gpuInventory: { + parameters: { + query: { + node: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUInventory"]; + }; + }; + /** @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; @@ -1390,6 +6828,1393 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + discoverTemplates: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveredVMList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + storages: { + parameters: { + query: { + node: string; + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + templates: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageTemplateList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + registerTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateInput"]; + }; + }; + responses: { + /** @description Successful response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageTemplate"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + updateTemplate: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateUpdate"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageTemplate"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + validateTemplate: { + 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"]["ImageTemplate"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + pools: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DesktopPoolList"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + createPool: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PoolInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DesktopPool"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + updatePool: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PoolInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DesktopPool"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + poolMembers: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PoolMembers"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + 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"]; + }; + }; + }; + }; + checkClone: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloneInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CloneCheck"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + cloneDesktop: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloneInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Task"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + configureDesktop: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectionInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Desktop"]; + }; + }; + /** @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; @@ -1484,6 +8309,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1581,6 +8415,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1677,6 +8520,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1771,6 +8623,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1871,6 +8732,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -1968,6 +8838,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2064,6 +8943,434 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + updateTerminal: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TerminalUpdate"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + 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"]; + }; + }; + }; + }; + terminalMetrics: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TerminalMetrics"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + disableTerminal: { + 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"]["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"]; + }; + }; + }; + }; + reactivateTerminal: { + 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"]["Enrollment"]; + }; + }; + /** @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; @@ -2164,6 +9471,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2261,6 +9577,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2357,6 +9682,227 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + batchBindings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchBindings"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchBindingsResult"]; + }; + }; + /** @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; @@ -2455,6 +10001,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2552,6 +10107,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2646,6 +10210,118 @@ export interface operations { }; }; /** @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"]; + }; + }; + }; + }; + reconcileProvision: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Task"]; + }; + }; + /** @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; @@ -2740,6 +10416,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2837,6 +10522,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -2933,6 +10627,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3029,6 +10732,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3121,6 +10833,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3218,6 +10939,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3315,6 +11045,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3413,6 +11152,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3509,6 +11257,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3605,6 +11362,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; @@ -3701,6 +11467,15 @@ export interface operations { }; }; /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ 500: { headers: { [name: string]: unknown; diff --git a/console/src/components/AdministratorAccounts.vue b/console/src/components/AdministratorAccounts.vue new file mode 100644 index 0000000..893beaf --- /dev/null +++ b/console/src/components/AdministratorAccounts.vue @@ -0,0 +1,54 @@ + + + diff --git a/console/src/components/ChangePasswordDialog.vue b/console/src/components/ChangePasswordDialog.vue new file mode 100644 index 0000000..4d1dbbb --- /dev/null +++ b/console/src/components/ChangePasswordDialog.vue @@ -0,0 +1,32 @@ + + diff --git a/console/src/components/CloneDesktopDialog.vue b/console/src/components/CloneDesktopDialog.vue new file mode 100644 index 0000000..6a4806d --- /dev/null +++ b/console/src/components/CloneDesktopDialog.vue @@ -0,0 +1,65 @@ + + diff --git a/console/src/components/CsvImportDialog.vue b/console/src/components/CsvImportDialog.vue new file mode 100644 index 0000000..da256f9 --- /dev/null +++ b/console/src/components/CsvImportDialog.vue @@ -0,0 +1,72 @@ + + + diff --git a/console/src/components/DesktopBatchDialog.vue b/console/src/components/DesktopBatchDialog.vue new file mode 100644 index 0000000..86a29b7 --- /dev/null +++ b/console/src/components/DesktopBatchDialog.vue @@ -0,0 +1,86 @@ + + + + diff --git a/console/src/components/DesktopHardwareDialog.vue b/console/src/components/DesktopHardwareDialog.vue new file mode 100644 index 0000000..0a5f124 --- /dev/null +++ b/console/src/components/DesktopHardwareDialog.vue @@ -0,0 +1,46 @@ + + + diff --git a/console/src/components/DesktopSnapshotsDialog.vue b/console/src/components/DesktopSnapshotsDialog.vue new file mode 100644 index 0000000..95135ff --- /dev/null +++ b/console/src/components/DesktopSnapshotsDialog.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/console/src/components/PveConnectionDialog.vue b/console/src/components/PveConnectionDialog.vue new file mode 100644 index 0000000..ee55b22 --- /dev/null +++ b/console/src/components/PveConnectionDialog.vue @@ -0,0 +1,38 @@ + + + diff --git a/console/src/components/ResourceTrend.vue b/console/src/components/ResourceTrend.vue new file mode 100644 index 0000000..d54203c --- /dev/null +++ b/console/src/components/ResourceTrend.vue @@ -0,0 +1,29 @@ + + + diff --git a/console/src/components/SessionHistory.vue b/console/src/components/SessionHistory.vue new file mode 100644 index 0000000..165728d --- /dev/null +++ b/console/src/components/SessionHistory.vue @@ -0,0 +1,42 @@ + + + diff --git a/console/src/components/TerminalMetricTrend.vue b/console/src/components/TerminalMetricTrend.vue new file mode 100644 index 0000000..25ba799 --- /dev/null +++ b/console/src/components/TerminalMetricTrend.vue @@ -0,0 +1,32 @@ + + + diff --git a/console/src/components/TerminalMetrics.vue b/console/src/components/TerminalMetrics.vue new file mode 100644 index 0000000..4e2da04 --- /dev/null +++ b/console/src/components/TerminalMetrics.vue @@ -0,0 +1,55 @@ + + + diff --git a/console/src/layouts/ConsoleLayout.vue b/console/src/layouts/ConsoleLayout.vue index 37363bc..848f3bf 100644 --- a/console/src/layouts/ConsoleLayout.vue +++ b/console/src/layouts/ConsoleLayout.vue @@ -1,10 +1,11 @@ diff --git a/console/src/views/Monitor.vue b/console/src/views/Monitor.vue new file mode 100644 index 0000000..e8dc035 --- /dev/null +++ b/console/src/views/Monitor.vue @@ -0,0 +1,79 @@ + + + diff --git a/console/src/views/Pools.vue b/console/src/views/Pools.vue new file mode 100644 index 0000000..71d1415 --- /dev/null +++ b/console/src/views/Pools.vue @@ -0,0 +1,39 @@ + + + diff --git a/console/src/views/Sessions.vue b/console/src/views/Sessions.vue index a1c3a16..4689c53 100644 --- a/console/src/views/Sessions.vue +++ b/console/src/views/Sessions.vue @@ -5,8 +5,10 @@ import { usePlatform } from '../stores/platform' import { useAuth } from '../stores/auth' import { useAction } from '../composables/useAction' import { api, type Terminal } from '../api' +import SessionHistory from '../components/SessionHistory.vue' const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction() const selectedId = ref('') +const tab = ref('active') const sessions = computed(() => data.terminals.filter(t => t.status === 'online' && t.sessionDesktopId && ['connected', 'connecting'].includes(t.sessionState || ''))) const selected = computed(() => sessions.value.find(t => t.id === selectedId.value) || sessions.value[0]) const desktop = computed(() => data.desktops.find(d => d.id === selected.value?.sessionDesktopId)) @@ -18,10 +20,14 @@ async function disconnect(terminal: Terminal) { diff --git a/console/src/views/Tasks.vue b/console/src/views/Tasks.vue index c46b51e..656fe2a 100644 --- a/console/src/views/Tasks.vue +++ b/console/src/views/Tasks.vue @@ -5,6 +5,8 @@ import StatusTag from '../components/StatusTag.vue' import { api, errorMessage } from '../api' import { useAuth } from '../stores/auth' import { ElMessage } from 'element-plus' +import { useAction } from '../composables/useAction' +const { busy, run } = useAction() const auth = useAuth() async function downloadDiagnostics(id: string) { try { @@ -15,7 +17,7 @@ async function downloadDiagnostics(id: string) { } const data = usePlatform(), tab = ref('tasks'), keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref('') const selected = computed(() => data.tasks.find(t => t.id === selectedId.value)) -const labels: Record = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面' } +const labels: Record = { '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: '纳管桌面' } const filtered = computed(() => data.tasks.filter(t => `${t.targetName} ${t.id} ${t.actor}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || t.status === status.value))) const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10)) const auditFiltered = computed(() => data.audits.filter(a => `${a.actor} ${a.target} ${a.action}`.toLowerCase().includes(keyword.value.toLowerCase()))) @@ -29,5 +31,5 @@ const auditRows = computed(() => auditFiltered.value.slice((page.value - 1) * 10 - {{ selected.id }}{{ selected.targetName }}{{ labels[selected.action] || selected.action }}{{ selected.message }}{{ selected.actor }}{{ selected.providerRef || '无' }}{{ new Date(selected.updatedAt).toLocaleString('zh-CN') }}下载诊断日志

结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。

+ {{ selected.id }}{{ selected.targetName }}{{ labels[selected.action] || selected.action }}{{ selected.message }}{{ selected.actor }}{{ selected.providerRef || '无' }}{{ new Date(selected.updatedAt).toLocaleString('zh-CN') }}下载诊断日志核对克隆结果核对规格结果核对快照结果核对电源结果

结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。

diff --git a/console/src/views/Templates.vue b/console/src/views/Templates.vue new file mode 100644 index 0000000..c34de3d --- /dev/null +++ b/console/src/views/Templates.vue @@ -0,0 +1,59 @@ + + + diff --git a/console/src/views/TerminalPolicies.vue b/console/src/views/TerminalPolicies.vue new file mode 100644 index 0000000..05158f4 --- /dev/null +++ b/console/src/views/TerminalPolicies.vue @@ -0,0 +1,88 @@ + + + diff --git a/console/src/views/Terminals.vue b/console/src/views/Terminals.vue index 99d45df..d6527ac 100644 --- a/console/src/views/Terminals.vue +++ b/console/src/views/Terminals.vue @@ -1,16 +1,23 @@ + + diff --git a/console/src/views/Users.vue b/console/src/views/Users.vue index 98487dd..4ab96de 100644 --- a/console/src/views/Users.vue +++ b/console/src/views/Users.vue @@ -4,9 +4,15 @@ import { api, type User } from '../api' import { useAuth } from '../stores/auth' import { usePlatform } from '../stores/platform' import { useAction } from '../composables/useAction' +import CsvImportDialog from '../components/CsvImportDialog.vue' import StatusTag from '../components/StatusTag.vue' -const auth = useAuth(), data = usePlatform(), { busy, run } = useAction() +const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction() +const csvVisible = ref(false), csvKind = ref<'users' | 'bindings'>('users') const keyword = ref(''), page = ref(1), createVisible = ref(false), bindingVisible = ref(false), selectedId = ref('') +const profileVisible = ref(false), profileId = ref(''), profileUsername = ref(''), checked = ref([]) +const profile = reactive({ displayName: '', enabled: true }) +const batchVisible = ref(false), batchUsers = ref([]) +const batch = reactive({ action: 'grant' as 'grant' | 'revoke', desktopIds: [] as string[] }) const createForm = reactive({ username: '', displayName: '' }) const binding = reactive({ terminalId: null as string | null, desktopIds: [] as string[], enabled: true }) const filtered = computed(() => data.users.filter(u => `${u.username} ${u.displayName}`.toLowerCase().includes(keyword.value.toLowerCase()))) @@ -15,12 +21,34 @@ const terminalName = (id: string | null) => data.terminals.find(t => t.id === id const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || id function edit(u: User) { selectedId.value = u.id; binding.terminalId = u.terminalId; binding.desktopIds = [...u.desktopIds]; binding.enabled = u.enabled; bindingVisible.value = true } 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 (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false } +async function save() { if (busy.value) return; if (!await confirm('保存用户授权', '更新关联终端、用户状态和所选桌面。未消费的旧连接票据将失效;已有串流不会被强制中断。')) return; if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false } const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id) +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 +} +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 run(() => api.batchBindings({ userIds: batchUsers.value.map(u => u.id), desktopIds: batch.desktopIds, action: batch.action }), `已批量${action}桌面授权`)) batchVisible.value = false +} + + diff --git a/deploy/compose.live.yml b/deploy/compose.live.yml index e2c10c4..39b7113 100644 --- a/deploy/compose.live.yml +++ b/deploy/compose.live.yml @@ -26,6 +26,7 @@ services: environment: VDI_MODE: production VDI_LISTEN: 0.0.0.0:8080 + VDI_TRUSTED_PROXIES: "172.16.0.0/12" VDI_JWT_SECRET: ${VDI_JWT_SECRET:?required} VDI_ADMIN_USERNAME: ${VDI_ADMIN_USERNAME:?required} VDI_ADMIN_PASSWORD: ${VDI_ADMIN_PASSWORD:?required} diff --git a/deploy/nginx.conf b/deploy/nginx.conf index e9580eb..e814c53 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -7,7 +7,7 @@ server { add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy same-origin always; add_header X-Frame-Options DENY always; - location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; } location /healthz { proxy_pass http://api:8080; } location / { try_files $uri $uri/ /index.html; } } diff --git a/deploy/nginx.live.conf b/deploy/nginx.live.conf index 70b4810..fa9bb0d 100644 --- a/deploy/nginx.live.conf +++ b/deploy/nginx.live.conf @@ -11,7 +11,7 @@ server { add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy same-origin always; add_header X-Frame-Options DENY always; - location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } + location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; } location /healthz { proxy_pass http://api:8080; } location / { try_files $uri $uri/ /index.html; } } diff --git a/docs/openapi/platform.v1.json b/docs/openapi/platform.v1.json index 2884244..aec7389 100644 --- a/docs/openapi/platform.v1.json +++ b/docs/openapi/platform.v1.json @@ -11,6 +11,4197 @@ } ], "paths": { + "/settings/pve": { + "get": { + "operationId": "pveConnection", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PveConnection" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "savePveConnection", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PveConnectionInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/settings/pve/check": { + "post": { + "operationId": "checkPveConnection", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PveConnectionInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PveConnectionCheck" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/hardware": { + "get": { + "operationId": "desktopHardware", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VMHardware" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "changeDesktopHardware", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/hardware/check": { + "post": { + "operationId": "checkDesktopHardware", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareCheck" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktop-batches": { + "get": { + "operationId": "desktopPowerBatches", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "submitDesktopPowerBatch", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchInput" + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchDetail" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktop-batches/check": { + "post": { + "operationId": "checkDesktopPowerBatch", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchPreview" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktop-batches/{id}": { + "get": { + "operationId": "desktopPowerBatch", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PowerBatchDetail" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/{id}/batch-power-reconcile": { + "post": { + "operationId": "reconcileBatchPower", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/snapshots": { + "get": { + "operationId": "desktopSnapshots", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotView" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/snapshots/check": { + "post": { + "operationId": "checkDesktopSnapshot", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareCheck" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/snapshots/actions": { + "post": { + "operationId": "changeDesktopSnapshot", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/{id}/snapshot-reconcile": { + "post": { + "operationId": "reconcileSnapshot", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/{id}/hardware-reconcile": { + "post": { + "operationId": "reconcileHardware", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/imports/preview": { + "post": { + "operationId": "previewCsvImport", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CsvImportInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CsvImportPreview" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/imports/commit": { + "post": { + "operationId": "commitCsvImport", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CsvImportInput" + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CsvImportResult" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/sessions/history": { + "get": { + "operationId": "sessionHistory", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionRecordList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/monitor/series": { + "get": { + "operationId": "resourceSeries", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "resourceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceSeries" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/monitor/rules": { + "get": { + "operationId": "alertRules", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRuleList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createAlertRule", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRuleInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRule" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/monitor/rules/{id}": { + "put": { + "operationId": "updateAlertRule", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRuleInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRule" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/monitor/alerts": { + "get": { + "operationId": "alerts", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRecordList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/monitor/alerts/{id}/acknowledge": { + "post": { + "operationId": "acknowledgeAlert", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminal-policy-templates": { + "get": { + "operationId": "terminalPolicyTemplates", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalPolicyList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createTerminalPolicy", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalPolicyInput" + } + } + } + }, + "parameters": [], + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalPolicy" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminal-policy-states": { + "get": { + "operationId": "terminalPolicyStates", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResultList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminal-policy-deployments": { + "get": { + "operationId": "policyDeployments", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDeploymentList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "deployPolicy", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDeploymentInput" + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDeployment" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminal-policy-deployments/{id}/results": { + "get": { + "operationId": "policyResults", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResultList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminal/config/ack": { + "post": { + "operationId": "policyAck", + "security": [ + { + "deviceBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyAck" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/auth/login": { "post": { "operationId": "login", @@ -87,6 +4278,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -190,6 +4391,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -286,6 +4497,652 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/password": { + "post": { + "operationId": "changeOwnPassword", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + } + } + }, + "parameters": [], + "responses": { + "204": { + "description": "Successful response" + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/administrators": { + "get": { + "operationId": "administrators", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministratorAccountList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createAdministrator", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAdministrator" + } + } + } + }, + "parameters": [], + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministratorAccount" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/administrators/{id}": { + "put": { + "operationId": "updateAdministrator", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdministrator" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministratorAccount" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/administrators/{id}/password": { + "post": { + "operationId": "resetAdministratorPassword", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetAdministratorPassword" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful response" + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -389,6 +5246,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -492,6 +5359,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -624,6 +5501,138 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/resources/gpus": { + "get": { + "operationId": "gpuInventory", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "node", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GPUInventory" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -756,6 +5765,1740 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/resources/templates": { + "get": { + "operationId": "discoverTemplates", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoveredVMList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/resources/storage": { + "get": { + "operationId": "storages", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "node", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates": { + "get": { + "operationId": "templates", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageTemplateList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "registerTemplate", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateInput" + } + } + } + }, + "parameters": [], + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageTemplate" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/{id}": { + "patch": { + "operationId": "updateTemplate", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateUpdate" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageTemplate" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/{id}/validate": { + "post": { + "operationId": "validateTemplate", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageTemplate" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pools": { + "get": { + "operationId": "pools", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "keyword", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopPoolList" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createPool", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopPool" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pools/{id}": { + "put": { + "operationId": "updatePool", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopPool" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pools/{id}/members": { + "put": { + "operationId": "poolMembers", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembers" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/clone/check": { + "post": { + "operationId": "checkClone", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloneInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloneCheck" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/clone": { + "post": { + "operationId": "cloneDesktop", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloneInput" + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/desktops/{id}/connection": { + "put": { + "operationId": "configureDesktop", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionInput" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Desktop" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -869,6 +7612,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1001,6 +7754,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1114,6 +7877,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1226,6 +7999,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1357,6 +8140,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1489,6 +8282,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1600,6 +8403,530 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminals/{id}": { + "put": { + "operationId": "updateTerminal", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalUpdate" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminals/{id}/metrics": { + "get": { + "operationId": "terminalMetrics", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalMetrics" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminals/{id}/disable": { + "post": { + "operationId": "disableTerminal", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/terminals/{id}/reactivate": { + "post": { + "operationId": "reactivateTerminal", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Enrollment" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1731,6 +9058,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1863,6 +9200,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -1974,6 +9321,271 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/users/{id}": { + "put": { + "operationId": "updateUser", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/users/batch-bindings": { + "post": { + "operationId": "batchBindings", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchBindings" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchBindingsResult" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2096,6 +9708,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2228,6 +9850,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2340,6 +9972,138 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/{id}/reconcile": { + "post": { + "operationId": "reconcileProvision", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2452,6 +10216,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2584,6 +10358,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2693,6 +10477,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2806,6 +10600,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -2909,6 +10713,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3041,6 +10855,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3173,6 +10997,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3295,6 +11129,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3408,6 +11252,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3521,6 +11375,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3634,6 +11498,16 @@ } } }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Request failed", "content": { @@ -3709,6 +11583,9 @@ "operator", "viewer" ] + }, + "enabled": { + "type": "boolean" } }, "required": [ @@ -3812,6 +11689,13 @@ "updatedAt": { "type": "string", "format": "date-time" + }, + "poolId": { + "type": "string", + "nullable": true + }, + "setupRequired": { + "type": "boolean" } }, "required": [ @@ -3876,6 +11760,9 @@ }, "sessionState": { "type": "string" + }, + "enabled": { + "type": "boolean" } }, "required": [ @@ -3970,6 +11857,191 @@ ], "additionalProperties": false }, + "GPUReference": { + "type": "object", + "properties": { + "vmid": { + "type": "integer" + }, + "vmName": { + "type": "string" + }, + "vmStatus": { + "type": "string" + }, + "template": { + "type": "boolean" + }, + "desktopId": { + "type": "string" + }, + "desktopName": { + "type": "string" + }, + "slot": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "current", + "pending" + ] + }, + "source": { + "type": "string", + "enum": [ + "direct", + "mapping" + ] + }, + "mapping": { + "type": "string" + }, + "mediated": { + "type": "boolean" + }, + "pendingRemoval": { + "type": "boolean" + } + }, + "required": [ + "vmid", + "vmName", + "vmStatus", + "template", + "desktopId", + "desktopName", + "slot", + "phase", + "source", + "mapping", + "mediated", + "pendingRemoval" + ], + "additionalProperties": false + }, + "GPUItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "vendorId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "class": { + "type": "string" + }, + "iommuGroup": { + "type": "integer", + "nullable": true + }, + "state": { + "type": "string", + "enum": [ + "unreferenced", + "unknown", + "referenced", + "multiple" + ] + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUReference" + } + } + }, + "required": [ + "id", + "address", + "name", + "vendor", + "vendorId", + "deviceId", + "class", + "iommuGroup", + "state", + "references" + ], + "additionalProperties": false + }, + "GPUIssue": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "vmid": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "vmid", + "message" + ], + "additionalProperties": false + }, + "GPUInventory": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "complete": { + "type": "boolean" + }, + "visibleVmCount": { + "type": "integer" + }, + "scannedVmCount": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUItem" + } + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUIssue" + } + } + }, + "required": [ + "node", + "observedAt", + "complete", + "visibleVmCount", + "scannedVmCount", + "items", + "issues" + ], + "additionalProperties": false + }, "Task": { "type": "object", "properties": { @@ -4240,6 +12312,88 @@ ], "additionalProperties": false }, + "TerminalUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "group": { + "type": "string" + } + }, + "required": [ + "name", + "group" + ], + "additionalProperties": false + }, + "UserUpdate": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "displayName", + "enabled" + ], + "additionalProperties": false + }, + "BatchBindings": { + "type": "object", + "properties": { + "userIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 100 + }, + "desktopIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 100 + }, + "action": { + "type": "string", + "enum": [ + "grant", + "revoke" + ] + } + }, + "required": [ + "userIds", + "desktopIds", + "action" + ], + "additionalProperties": false + }, + "BatchBindingsResult": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "userCount": { + "type": "integer" + } + }, + "required": [ + "accepted", + "userCount" + ], + "additionalProperties": false + }, "CreateUserInput": { "type": "object", "properties": { @@ -4367,6 +12521,15 @@ "rtt": { "type": "number", "nullable": true + }, + "policyProtocol": { + "type": "integer", + "minimum": 0, + "maximum": 1 + }, + "systemMetrics": { + "$ref": "#/components/schemas/TerminalSystemMetrics", + "nullable": true } }, "required": [ @@ -4374,6 +12537,157 @@ ], "additionalProperties": false }, + "TerminalSystemMetrics": { + "type": "object", + "properties": { + "cpuPercent": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 100 + }, + "memoryPercent": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 100 + }, + "socTemperatureC": { + "type": "number", + "nullable": true, + "minimum": -40, + "maximum": 150 + }, + "uptimeSeconds": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 3155760000 + } + }, + "required": [], + "additionalProperties": false + }, + "TerminalMetricLatest": { + "type": "object", + "properties": { + "cpuPercent": { + "type": "number", + "nullable": true + }, + "memoryPercent": { + "type": "number", + "nullable": true + }, + "socTemperatureC": { + "type": "number", + "nullable": true + }, + "uptimeSeconds": { + "type": "number", + "nullable": true + }, + "sampledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "supported": { + "type": "boolean" + }, + "online": { + "type": "boolean" + }, + "fresh": { + "type": "boolean" + } + }, + "required": [ + "cpuPercent", + "memoryPercent", + "socTemperatureC", + "uptimeSeconds", + "sampledAt", + "supported", + "online", + "fresh" + ], + "additionalProperties": false + }, + "TerminalMetricPoint": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "cpuPercent": { + "type": "number", + "nullable": true + }, + "memoryPercent": { + "type": "number", + "nullable": true + }, + "socTemperatureC": { + "type": "number", + "nullable": true + }, + "uptimeSeconds": { + "type": "number", + "nullable": true + }, + "samples": { + "type": "integer" + } + }, + "required": [ + "time", + "cpuPercent", + "memoryPercent", + "socTemperatureC", + "uptimeSeconds", + "samples" + ], + "additionalProperties": false + }, + "TerminalMetrics": { + "type": "object", + "properties": { + "latest": { + "$ref": "#/components/schemas/TerminalMetricLatest" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TerminalMetricPoint" + } + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "bucketSeconds": { + "type": "integer" + }, + "retentionDays": { + "type": "integer" + } + }, + "required": [ + "latest", + "items", + "from", + "to", + "bucketSeconds", + "retentionDays" + ], + "additionalProperties": false + }, "Accepted": { "type": "object", "properties": { @@ -4417,13 +12731,29 @@ }, "heartbeatInterval": { "type": "integer" + }, + "deploymentId": { + "type": "string" + }, + "policyProtocol": { + "type": "integer" + }, + "commandPollInterval": { + "type": "integer" + }, + "desktopRefreshInterval": { + "type": "integer" } }, "required": [ "configVersion", "identityMode", "commandTransport", - "heartbeatInterval" + "heartbeatInterval", + "deploymentId", + "policyProtocol", + "commandPollInterval", + "desktopRefreshInterval" ], "additionalProperties": false }, @@ -4590,6 +12920,1663 @@ ], "additionalProperties": false }, + "PveConnectionInput": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "tokenId": { + "type": "string" + }, + "tokenSecret": { + "type": "string", + "format": "password", + "writeOnly": true + }, + "caPem": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "url", + "tokenId", + "revision" + ], + "additionalProperties": false + }, + "PveConnection": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "tokenId": { + "type": "string" + }, + "caPem": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "tokenConfigured": { + "type": "boolean" + }, + "source": { + "type": "string", + "enum": [ + "environment", + "database" + ] + } + }, + "required": [ + "url", + "tokenId", + "caPem", + "revision", + "tokenConfigured", + "source" + ], + "additionalProperties": false + }, + "PveConnectionCheck": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "nodes": { + "type": "integer" + }, + "vms": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "available", + "nodes", + "vms", + "message" + ], + "additionalProperties": false + }, + "VMDisk": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "storage": { + "type": "string" + }, + "sizeGiB": { + "type": "number" + } + }, + "required": [ + "key", + "storage", + "sizeGiB" + ], + "additionalProperties": false + }, + "VMHardware": { + "type": "object", + "properties": { + "cores": { + "type": "integer" + }, + "sockets": { + "type": "integer" + }, + "memoryMiB": { + "type": "integer" + }, + "digest": { + "type": "string" + }, + "disks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VMDisk" + } + }, + "powerState": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "cores", + "sockets", + "memoryMiB", + "digest", + "disks", + "powerState", + "blockers" + ], + "additionalProperties": false + }, + "HardwareInput": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "compute", + "disk" + ] + }, + "digest": { + "type": "string" + }, + "cores": { + "type": "integer" + }, + "sockets": { + "type": "integer" + }, + "memoryMiB": { + "type": "integer" + }, + "disk": { + "type": "string" + }, + "sizeGiB": { + "type": "integer" + } + }, + "required": [ + "kind", + "digest" + ], + "additionalProperties": false + }, + "PowerBatchInput": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "start", + "shutdown", + "reboot" + ] + }, + "desktopIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 50 + }, + "previewToken": { + "type": "string" + } + }, + "required": [ + "action", + "desktopIds" + ], + "additionalProperties": false + }, + "PowerBatchCheckItem": { + "type": "object", + "properties": { + "desktopId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vmid": { + "type": "integer" + }, + "powerState": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "desktopId", + "name", + "vmid", + "powerState", + "available", + "code", + "message" + ], + "additionalProperties": false + }, + "PowerBatchPreview": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PowerBatchCheckItem" + } + }, + "eligibleCount": { + "type": "integer" + }, + "skippedCount": { + "type": "integer" + }, + "previewToken": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "items", + "eligibleCount", + "skippedCount", + "previewToken", + "expiresAt" + ], + "additionalProperties": false + }, + "PowerBatch": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "start", + "shutdown", + "reboot" + ] + }, + "actor": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "totalCount": { + "type": "integer" + }, + "submittedCount": { + "type": "integer" + }, + "skippedCount": { + "type": "integer" + }, + "activeCount": { + "type": "integer" + }, + "successCount": { + "type": "integer" + }, + "failedCount": { + "type": "integer" + }, + "uncertainCount": { + "type": "integer" + } + }, + "required": [ + "id", + "action", + "actor", + "createdAt", + "totalCount", + "submittedCount", + "skippedCount", + "activeCount", + "successCount", + "failedCount", + "uncertainCount" + ], + "additionalProperties": false + }, + "PowerBatchItem": { + "type": "object", + "properties": { + "desktopId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vmid": { + "type": "integer" + }, + "taskId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "desktopId", + "name", + "vmid", + "taskId", + "status", + "code", + "message" + ], + "additionalProperties": false + }, + "PowerBatchDetail": { + "type": "object", + "properties": { + "batch": { + "$ref": "#/components/schemas/PowerBatch" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PowerBatchItem" + } + } + }, + "required": [ + "batch", + "items" + ], + "additionalProperties": false + }, + "SnapshotItem": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "createdAt": { + "type": "integer" + }, + "parent": { + "type": "string" + }, + "includesMemory": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "name", + "description", + "createdAt", + "parent", + "includesMemory", + "state", + "revision" + ], + "additionalProperties": false + }, + "SnapshotView": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotItem" + } + }, + "digest": { + "type": "string" + }, + "powerState": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "disks": { + "type": "array", + "items": { + "type": "string" + } + }, + "canCreate": { + "type": "boolean" + }, + "canDelete": { + "type": "boolean" + }, + "canRestore": { + "type": "boolean" + } + }, + "required": [ + "items", + "digest", + "powerState", + "blockers", + "disks", + "canCreate", + "canDelete", + "canRestore" + ], + "additionalProperties": false + }, + "SnapshotInput": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create", + "delete", + "rollback" + ] + }, + "name": { + "type": "string", + "maxLength": 40 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "digest": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "confirmVmid": { + "type": "integer" + } + }, + "required": [ + "action", + "name", + "digest" + ], + "additionalProperties": false + }, + "HardwareCheck": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "available", + "message" + ], + "additionalProperties": false + }, + "CsvImportInput": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "users", + "terminals", + "bindings" + ] + }, + "csv": { + "type": "string", + "maxLength": 524288 + }, + "previewToken": { + "type": "string" + } + }, + "required": [ + "kind", + "csv" + ], + "additionalProperties": false + }, + "CsvImportRow": { + "type": "object", + "properties": { + "line": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "line", + "key", + "values", + "errors" + ], + "additionalProperties": false + }, + "CsvImportPreview": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "totalRows": { + "type": "integer" + }, + "validRows": { + "type": "integer" + }, + "errorRows": { + "type": "integer" + }, + "previewToken": { + "type": "string" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CsvImportRow" + } + } + }, + "required": [ + "kind", + "totalRows", + "validRows", + "errorRows", + "previewToken", + "rows" + ], + "additionalProperties": false + }, + "CsvImportResult": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "line", + "key", + "id" + ], + "additionalProperties": false + } + }, + "activationSecrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sn": { + "type": "string" + }, + "activationSecret": { + "type": "string" + } + }, + "required": [ + "sn", + "activationSecret" + ], + "additionalProperties": false + } + }, + "replayed": { + "type": "boolean" + } + }, + "required": [ + "id", + "kind", + "count", + "items", + "activationSecrets", + "replayed" + ], + "additionalProperties": false + }, + "SessionRecord": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "terminalName": { + "type": "string" + }, + "desktopId": { + "type": "string" + }, + "desktopName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "ended", + "interrupted" + ] + }, + "source": { + "type": "string", + "enum": [ + "ticket", + "observed" + ] + }, + "reservedAt": { + "type": "string", + "format": "date-time" + }, + "connectedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastReportedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "endReason": { + "type": "string" + } + }, + "required": [ + "id", + "sessionId", + "terminalId", + "terminalName", + "desktopId", + "desktopName", + "username", + "displayName", + "state", + "source", + "reservedAt", + "connectedAt", + "lastReportedAt", + "endedAt", + "endReason" + ], + "additionalProperties": false + }, + "AlertRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "metric": { + "type": "string", + "enum": [ + "node_cpu", + "node_memory", + "node_storage", + "node_unavailable", + "terminal_offline" + ] + }, + "threshold": { + "type": "number" + }, + "holdSeconds": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "metric", + "threshold", + "holdSeconds", + "enabled" + ], + "additionalProperties": false + }, + "AlertRule": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "metric": { + "type": "string", + "enum": [ + "node_cpu", + "node_memory", + "node_storage", + "node_unavailable", + "terminal_offline" + ] + }, + "threshold": { + "type": "number" + }, + "holdSeconds": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "metric", + "threshold", + "holdSeconds", + "enabled" + ], + "additionalProperties": false + }, + "AlertRecord": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ruleId": { + "type": "string" + }, + "ruleName": { + "type": "string" + }, + "metric": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "targetName": { + "type": "string" + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "firing", + "resolved" + ] + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "openedAt": { + "type": "string", + "format": "date-time" + }, + "resolvedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resolveReason": { + "type": "string" + }, + "acknowledgedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "acknowledgedBy": { + "type": "string" + } + }, + "required": [ + "id", + "ruleId", + "ruleName", + "metric", + "targetId", + "targetName", + "value", + "threshold", + "status", + "startedAt", + "openedAt", + "resolvedAt", + "resolveReason", + "acknowledgedAt", + "acknowledgedBy" + ], + "additionalProperties": false + }, + "ResourcePoint": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "cpu": { + "type": "number", + "nullable": true + }, + "memory": { + "type": "number", + "nullable": true + }, + "storage": { + "type": "number", + "nullable": true + }, + "samples": { + "type": "integer" + } + }, + "required": [ + "time", + "cpu", + "memory", + "storage", + "samples" + ], + "additionalProperties": false + }, + "ResourceSeries": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResourcePoint" + } + }, + "bucketSeconds": { + "type": "integer" + }, + "retentionDays": { + "type": "integer" + } + }, + "required": [ + "items", + "bucketSeconds", + "retentionDays" + ], + "additionalProperties": false + }, + "TerminalPolicyInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "heartbeatInterval": { + "type": "integer", + "minimum": 15, + "maximum": 60 + }, + "commandPollInterval": { + "type": "integer", + "minimum": 3, + "maximum": 30 + }, + "desktopRefreshInterval": { + "type": "integer", + "minimum": 5, + "maximum": 120 + } + }, + "required": [ + "name", + "version", + "heartbeatInterval", + "commandPollInterval", + "desktopRefreshInterval" + ], + "additionalProperties": false + }, + "TerminalPolicy": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "heartbeatInterval": { + "type": "integer" + }, + "commandPollInterval": { + "type": "integer" + }, + "desktopRefreshInterval": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "version", + "heartbeatInterval", + "commandPollInterval", + "desktopRefreshInterval", + "createdAt" + ], + "additionalProperties": false + }, + "PolicyDeployment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "templateId": { + "type": "string" + }, + "templateName": { + "type": "string" + }, + "templateVersion": { + "type": "string" + }, + "actor": { + "type": "string" + }, + "targetCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "templateId", + "templateName", + "templateVersion", + "actor", + "targetCount", + "createdAt" + ], + "additionalProperties": false + }, + "PolicyDeploymentInput": { + "type": "object", + "properties": { + "templateId": { + "type": "string" + }, + "targetMode": { + "type": "string", + "enum": [ + "devices", + "group" + ] + }, + "terminalIds": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 200 + }, + "group": { + "type": "string" + } + }, + "required": [ + "templateId", + "targetMode" + ], + "additionalProperties": false + }, + "PolicyResult": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "terminalName": { + "type": "string" + }, + "group": { + "type": "string" + }, + "protocol": { + "type": "integer" + }, + "deploymentId": { + "type": "string" + }, + "templateName": { + "type": "string" + }, + "templateVersion": { + "type": "string" + }, + "configVersion": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "unconfigured", + "pending", + "applied", + "failed", + "superseded" + ] + }, + "errorCode": { + "type": "string" + }, + "acknowledgedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "online": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "registered": { + "type": "boolean" + } + }, + "required": [ + "id", + "terminalId", + "terminalName", + "group", + "protocol", + "deploymentId", + "templateName", + "templateVersion", + "configVersion", + "status", + "errorCode", + "acknowledgedAt", + "online", + "enabled", + "registered" + ], + "additionalProperties": false + }, + "PolicyAck": { + "type": "object", + "properties": { + "deploymentId": { + "type": "string" + }, + "configVersion": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "applied", + "failed" + ] + }, + "errorCode": { + "type": "string", + "enum": [ + "invalid_policy", + "persist_failed", + "runtime_unavailable" + ] + } + }, + "required": [ + "deploymentId", + "configVersion", + "status" + ], + "additionalProperties": false + }, + "AdministratorAccount": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "super_admin", + "operator", + "viewer" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "displayName", + "role", + "enabled" + ], + "additionalProperties": false + }, + "CreateAdministrator": { + "type": "object", + "properties": { + "username": { + "type": "string", + "minLength": 3, + "maxLength": 80 + }, + "displayName": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "super_admin", + "operator", + "viewer" + ] + }, + "password": { + "type": "string", + "format": "password", + "minLength": 12, + "maxLength": 72 + } + }, + "required": [ + "username", + "displayName", + "role", + "password" + ], + "additionalProperties": false + }, + "UpdateAdministrator": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "super_admin", + "operator", + "viewer" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "displayName", + "role", + "enabled" + ], + "additionalProperties": false + }, + "ResetAdministratorPassword": { + "type": "object", + "properties": { + "password": { + "type": "string", + "format": "password", + "minLength": 12, + "maxLength": 72 + } + }, + "required": [ + "password" + ], + "additionalProperties": false + }, + "ChangePassword": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string", + "format": "password" + }, + "newPassword": { + "type": "string", + "format": "password", + "minLength": 12, + "maxLength": 72 + } + }, + "required": [ + "currentPassword", + "newPassword" + ], + "additionalProperties": false + }, + "ImageTemplate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "os": { + "type": "string", + "enum": [ + "Windows", + "Ubuntu", + "麒麟" + ] + }, + "node": { + "type": "string" + }, + "vmid": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "validatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "version", + "os", + "node", + "vmid", + "enabled", + "validatedAt" + ], + "additionalProperties": false + }, + "TemplateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "os": { + "type": "string", + "enum": [ + "Windows", + "Ubuntu", + "麒麟" + ] + }, + "node": { + "type": "string" + }, + "vmid": { + "type": "integer" + } + }, + "required": [ + "name", + "version", + "os", + "node", + "vmid" + ], + "additionalProperties": false + }, + "TemplateUpdate": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "DesktopPool": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dedicated", + "shared" + ] + }, + "maxCount": { + "type": "integer" + }, + "templateId": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "desktopCount": { + "type": "integer" + }, + "reservedCount": { + "type": "integer" + }, + "assignedCount": { + "type": "integer" + }, + "activeCount": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "type", + "maxCount", + "templateId", + "enabled", + "createdAt", + "desktopCount", + "reservedCount", + "assignedCount", + "activeCount" + ], + "additionalProperties": false + }, + "PoolInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dedicated", + "shared" + ] + }, + "maxCount": { + "type": "integer" + }, + "templateId": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "type", + "maxCount", + "templateId", + "enabled" + ], + "additionalProperties": false + }, + "PoolMembers": { + "type": "object", + "properties": { + "desktopIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "desktopIds" + ], + "additionalProperties": false + }, + "CloneInput": { + "type": "object", + "properties": { + "templateId": { + "type": "string" + }, + "poolId": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "vmid": { + "type": "integer" + }, + "full": { + "type": "boolean" + }, + "storage": { + "type": "string" + } + }, + "required": [ + "templateId", + "poolId", + "name", + "vmid", + "full", + "storage" + ], + "additionalProperties": false + }, + "CloneCheck": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "available", + "message" + ], + "additionalProperties": false + }, + "Storage": { + "type": "object", + "properties": { + "storage": { + "type": "string" + }, + "avail": { + "type": "number" + }, + "active": { + "type": "integer" + }, + "enabled": { + "type": "integer" + }, + "content": { + "type": "string" + } + }, + "required": [ + "storage", + "avail", + "active", + "enabled", + "content" + ], + "additionalProperties": false + }, + "ConnectionInput": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "fps": { + "type": "integer" + }, + "initialized": { + "type": "boolean" + } + }, + "required": [ + "endpoint", + "width", + "height", + "fps", + "initialized" + ], + "additionalProperties": false + }, "DesktopList": { "type": "object", "properties": { @@ -4806,6 +14793,355 @@ "requestId" ], "additionalProperties": false + }, + "ImageTemplateList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageTemplate" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "DesktopPoolList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopPool" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "StorageList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Storage" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "AdministratorAccountList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdministratorAccount" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "TerminalPolicyList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TerminalPolicy" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "PolicyDeploymentList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyDeployment" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "PolicyResultList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyResult" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "SessionRecordList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionRecord" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "AlertRuleList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlertRule" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false + }, + "AlertRecordList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlertRecord" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + }, + "firingCount": { + "type": "integer" + }, + "unacknowledgedCount": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId", + "firingCount", + "unacknowledgedCount" + ], + "additionalProperties": false + }, + "PowerBatchList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PowerBatch" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "pageSize", + "total", + "requestId" + ], + "additionalProperties": false } }, "securitySchemes": { diff --git a/platform/cmd/api-gateway/main.go b/platform/cmd/api-gateway/main.go index 7091b42..1ad06ee 100644 --- a/platform/cmd/api-gateway/main.go +++ b/platform/cmd/api-gateway/main.go @@ -39,10 +39,12 @@ func main() { } } s := server.New(cfg, db, pve) - go s.RunWorker(ctx) - if pve != nil { - go s.RunSync(ctx) + if err = s.RefreshCompute(startup); err != nil { + log.Fatal("saved compute configuration unavailable") } + go s.RunWorker(ctx) + go s.RunObservability(ctx) + go s.RunSync(ctx) httpServer := &http.Server{Addr: cfg.Address, Handler: s.Router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} go func() { log.Printf("VDI API listening on %s (mode=%s)", cfg.Address, cfg.Mode) diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 57c1b4f..52dc6f8 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -2,17 +2,28 @@ package config import ( "fmt" + "net" "os" "strings" ) type Config struct { + TrustedProxies []string Mode, Address, DatabaseURL, JWTSecret, AdminUsername, AdminPassword string PVEURL, PVETokenID, PVETokenSecret, PVECAFile 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")} + if value := os.Getenv("VDI_TRUSTED_PROXIES"); value != "" { + for _, proxy := range strings.Split(value, ",") { + proxy = strings.TrimSpace(proxy) + if _, _, err := net.ParseCIDR(proxy); err != nil && net.ParseIP(proxy) == nil { + return c, fmt.Errorf("invalid VDI_TRUSTED_PROXIES entry") + } + c.TrustedProxies = append(c.TrustedProxies, proxy) + } + } if c.Mode != "demo" && c.Mode != "production" { return c, fmt.Errorf("VDI_MODE must be demo or production") } diff --git a/platform/internal/model/model.go b/platform/internal/model/model.go index 1fbb6a1..e190639 100644 --- a/platform/internal/model/model.go +++ b/platform/internal/model/model.go @@ -7,26 +7,55 @@ type Administrator struct { Username string `json:"username" db:"username"` DisplayName string `json:"displayName" db:"display_name"` Role string `json:"role" db:"role"` + Enabled bool `json:"enabled" db:"enabled"` PasswordHash string `json:"-" db:"password_hash"` } type Desktop struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - OS string `json:"os" db:"os"` - Node string `json:"node" db:"node"` - VMID int `json:"vmid" db:"vmid"` - Pool string `json:"pool" db:"pool"` - PowerState string `json:"powerState" db:"power_state"` - ServiceState string `json:"serviceState" db:"service_state"` - Provider string `json:"provider" db:"provider"` - Endpoint string `json:"endpoint" db:"endpoint"` - App string `json:"app" db:"app"` - Width int `json:"width" db:"width"` - Height int `json:"height" db:"height"` - FPS int `json:"fps" db:"fps"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + OS string `json:"os" db:"os"` + Node string `json:"node" db:"node"` + VMID int `json:"vmid" db:"vmid"` + Pool string `json:"pool" db:"pool"` + PowerState string `json:"powerState" db:"power_state"` + ServiceState string `json:"serviceState" db:"service_state"` + Provider string `json:"provider" db:"provider"` + Endpoint string `json:"endpoint" db:"endpoint"` + App string `json:"app" db:"app"` + Width int `json:"width" db:"width"` + Height int `json:"height" db:"height"` + FPS int `json:"fps" db:"fps"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + PoolID *string `json:"poolId" db:"pool_id"` + SetupRequired bool `json:"setupRequired" db:"setup_required"` +} + +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"` +} +type DesktopPool struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Type string `json:"type" db:"type"` + MaxCount int `json:"maxCount" db:"max_count"` + TemplateID *string `json:"templateId" db:"template_id"` + Enabled bool `json:"enabled" db:"enabled"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + DesktopCount int `json:"desktopCount" db:"desktop_count"` + ReservedCount int `json:"reservedCount" db:"reserved_count"` + AssignedCount int `json:"assignedCount" db:"assigned_count"` + ActiveCount int `json:"activeCount" db:"active_count"` } type Terminal struct { + Enabled bool `json:"enabled" db:"enabled"` ID string `json:"id" db:"id"` SN string `json:"sn" db:"sn"` Name string `json:"name" db:"name"` diff --git a/platform/internal/server/admin.go b/platform/internal/server/admin.go index 9b32bcd..16b348f 100644 --- a/platform/internal/server/admin.go +++ b/platform/internal/server/admin.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgconn" + "github.com/jmoiron/sqlx" "log" "net" "strings" @@ -25,7 +26,7 @@ func (s *Server) overview(c *gin.Context) { Sessions int `db:"sessions"` Failed int `db:"failed"` } - err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`) + err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE enabled AND last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE enabled AND session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`) if err != nil { s.dbError(c, err) return @@ -33,14 +34,15 @@ func (s *Server) overview(c *gin.Context) { c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode}) } func (s *Server) settings(c *gin.Context) { + pve := s.currentPVE() provider := "unconfigured" if s.Config.Mode == "demo" { provider = "demo" } - if s.PVE != nil { + if pve != nil { provider = "proxmox" } - c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}}) + c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"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"}}) } func (s *Server) resources(c *gin.Context) { items := []model.Resource{} @@ -70,7 +72,7 @@ func (s *Server) desktop(c *gin.Context) { } func (s *Server) terminals(c *gin.Context) { items := []model.Terminal{} - err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%") + err := s.DB.SelectContext(c.Request.Context(), &items, terminalListSQL+` WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%") if err != nil { s.dbError(c, err) return @@ -135,6 +137,9 @@ func (s *Server) bindings(c *gin.Context) { return } defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } var user model.User if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { s.dbError(c, err) @@ -171,6 +176,10 @@ func (s *Server) bindings(c *gin.Context) { return } } + if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND (terminal_id=$1 OR terminal_id=$2)", user.TerminalID, in.TerminalID); err != nil { + s.dbError(c, err) + return + } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId")) if err == nil { err = tx.Commit() @@ -211,11 +220,12 @@ func (s *Server) audits(c *gin.Context) { list(c, items) } func (s *Server) discoverVMs(c *gin.Context) { - if s.PVE == nil { + pve := s.currentPVE() + if pve == nil { s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM") return } - items, err := s.PVE.Resources(c.Request.Context()) + items, err := pve.Resources(c.Request.Context()) if err != nil { s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限") return @@ -229,13 +239,15 @@ func (s *Server) discoverVMs(c *gin.Context) { list(c, vms) } func (s *Server) syncResources(c *gin.Context) { - if s.PVE == nil { + pve := s.currentPVE() + if pve == nil { s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入") return } s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群") } func (s *Server) importDesktop(c *gin.Context) { + pve := s.currentPVE() var in struct { Name string `json:"name" binding:"required,max=80"` OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"` @@ -249,7 +261,7 @@ func (s *Server) importDesktop(c *gin.Context) { if !s.decode(c, &in) { return } - if s.PVE == nil { + if pve == nil { s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox") return } @@ -261,7 +273,7 @@ func (s *Server) importDesktop(c *gin.Context) { } } } - power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID) + power, err := pve.Power(c.Request.Context(), in.Node, in.VMID) if err != nil { s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID") return @@ -290,6 +302,7 @@ func (s *Server) importDesktop(c *gin.Context) { s.desktop(c) } func (s *Server) desktopAction(c *gin.Context) { + pve := s.currentPVE() var in struct { Action string `json:"action" binding:"required,oneof=start shutdown reboot"` } @@ -305,10 +318,19 @@ func (s *Server) desktopAction(c *gin.Context) { s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务") return } - if d.Provider == "proxmox" && s.PVE == nil { + if d.Provider == "proxmox" && pve == nil { s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置") return } + unresolved, err := maintenanceBlocked(c.Request.Context(), s.DB, d.ID, "", false) + if err != nil { + s.dbError(c, err) + return + } + if unresolved { + s.fail(c, 409, "hardware_unconfirmed", "请先核对结果未确认的桌面维护任务") + return + } s.submitTask(c, "desktop", in.Action, d.ID, d.Name) } func (s *Server) terminalCommand(c *gin.Context) { @@ -353,6 +375,33 @@ func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName s return } defer tx.Rollback() + if kind == "desktop" { + var id string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", targetID); err != nil { + s.dbError(c, err) + return + } + unresolved, err := maintenanceBlocked(ctx, tx, targetID, "", false) + if err != nil { + s.dbError(c, err) + return + } + if unresolved { + s.fail(c, 409, "hardware_unconfirmed", "请先核对未确认的桌面维护任务") + return + } + } + if kind == "terminal" { + var enabled bool + if err = tx.GetContext(ctx, &enabled, "SELECT enabled FROM terminals WHERE id=$1 FOR UPDATE", targetID); err != nil { + s.dbError(c, err) + return + } + if !enabled { + s.fail(c, 409, "terminal_disabled", "终端已停用,请重新激活后操作") + return + } + } var task model.Task err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key) if err != nil { @@ -381,6 +430,12 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str return err } defer tx.Rollback() + if err = s.completeTx(ctx, tx, t, status, message); err != nil { + return err + } + return tx.Commit() +} +func (s *Server) completeTx(ctx context.Context, tx *sqlx.Tx, t model.Task, status, message string) error { result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID) if err != nil { return err @@ -395,7 +450,7 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str if err != nil { return err } - return tx.Commit() + return nil } func ratio(a, b float64) float64 { if b <= 0 { @@ -404,7 +459,14 @@ func ratio(a, b float64) float64 { return a / b } func (s *Server) sync(ctx context.Context) error { - items, err := s.PVE.Resources(ctx) + if err := s.RefreshCompute(ctx); err != nil { + return err + } + pve := s.currentPVE() + if pve == nil { + return nil + } + items, err := pve.Resources(ctx) if err != nil { return err } @@ -421,6 +483,9 @@ func (s *Server) sync(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT INTO resource_samples(resource_id,cpu,memory,storage) VALUES($1,CASE WHEN $7 THEN $2::double precision ELSE NULL END,CASE WHEN $7 AND $3::double precision>0 THEN $4::double precision ELSE NULL END,CASE WHEN $7 AND $5::double precision>0 THEN $6::double precision ELSE NULL END)`, r.Node, r.CPU, r.MaxMemory, ratio(r.Memory, r.MaxMemory), r.MaxDisk, ratio(r.Disk, r.MaxDisk), r.Status == "online"); err != nil { + return err + } } if r.Type == "qemu" { if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil { @@ -432,26 +497,81 @@ func (s *Server) sync(ctx context.Context) error { } func (s *Server) RunWorker(ctx context.Context) { + // Hold a session lock so only one worker can resume PVE tasks across API replicas. + conn, err := s.DB.Connx(ctx) + if err != nil { + return + } + defer conn.Close() + for { + var acquired bool + if err = conn.GetContext(ctx, &acquired, "SELECT pg_try_advisory_lock(7651092027)"); err != nil { + return + } + if acquired { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } + defer conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock(7651092027)") + workerCtx, stop := context.WithCancel(ctx) + provisionDone := make(chan struct{}, 1) + provisionBusy := false + defer func() { + stop() + if provisionBusy { + <-provisionDone + } + }() ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return + case <-provisionDone: + provisionBusy = false case <-ticker.C: + if err = conn.PingContext(ctx); err != nil { + return + } // Unknown outcomes are never automatically retried after a worker restart. var expired []model.Task - if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at= 400 && httpError.Status < 500 { @@ -517,11 +658,11 @@ func (s *Server) execute(parent context.Context, t model.Task) { _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID) } if err == nil { - err = s.PVE.Wait(ctx, d.Node, upid, time.Second) + err = pve.Wait(ctx, d.Node, upid, time.Second) } if err == nil { var power string - power, err = s.PVE.Power(ctx, d.Node, d.VMID) + power, err = pve.Power(ctx, d.Node, d.VMID) if err == nil { expected := "running" if t.Action == "shutdown" { diff --git a/platform/internal/server/administrators.go b/platform/internal/server/administrators.go new file mode 100644 index 0000000..b7524ec --- /dev/null +++ b/platform/internal/server/administrators.go @@ -0,0 +1,261 @@ +package server + +import ( + "context" + "database/sql" + "errors" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" +) + +func (s *Server) administratorList(c *gin.Context) { + items := []model.Administrator{} + if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM administrators ORDER BY username"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} + +func validNewPassword(password string) bool { + return utf8.RuneCountInString(password) >= 12 && len(password) <= 72 && strings.TrimSpace(password) != "" +} + +// All account writes share a transaction lock, then recheck the actor's current role. +// This also prevents concurrent administrators from disabling each other and leaving no owner. +func (s *Server) accountTransaction(c *gin.Context, superOnly bool) (*sqlx.Tx, model.Administrator) { + var actor model.Administrator + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return nil, actor + } + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(7651092028)"); err == nil { + err = tx.GetContext(ctx, &actor, "SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE a.id=$1 AND x.id=$2 AND x.expires_at>now() FOR UPDATE OF a", s.admin(c).ID, c.GetString("sessionId")) + } + if err != nil { + tx.Rollback() + if errors.Is(err, sql.ErrNoRows) { + s.fail(c, 401, "unauthorized", "登录已失效,请重新登录") + } else { + s.dbError(c, err) + } + return nil, actor + } + if !actor.Enabled || superOnly && actor.Role != "super_admin" { + tx.Rollback() + s.fail(c, 403, "forbidden", "当前账号没有该操作权限") + return nil, actor + } + return tx, actor +} + +func accountAudit(ctx context.Context, tx *sqlx.Tx, actor, action, target, requestID string) error { + _, err := tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'success',$5)", store.ID("audit_"), actor, action, target, requestID) + return err +} + +func (s *Server) createAdministrator(c *gin.Context) { + var in struct { + Username string `json:"username" binding:"required,max=80"` + DisplayName string `json:"displayName" binding:"required,max=80"` + Role string `json:"role" binding:"required,oneof=super_admin operator viewer"` + Password string `json:"password" binding:"required,max=72"` + } + if !s.decode(c, &in) { + return + } + in.Username = strings.TrimSpace(in.Username) + in.DisplayName = strings.TrimSpace(in.DisplayName) + if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$`).MatchString(in.Username) || in.DisplayName == "" { + s.fail(c, 400, "invalid_account", "账号使用 3~80 位字母、数字、点、下划线或短横线,显示名称不能为空") + return + } + if !validNewPassword(in.Password) { + s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + s.dbError(c, err) + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + var item model.Administrator + err = tx.GetContext(c.Request.Context(), &item, "INSERT INTO administrators(id,username,display_name,role,password_hash) VALUES($1,$2,$3,$4,$5) RETURNING *", store.ID("adm_"), in.Username, in.DisplayName, in.Role, string(hash)) + if conflict(err) { + s.fail(c, 409, "account_conflict", "管理员账号已存在") + return + } + if err != nil { + s.dbError(c, err) + return + } + if err = accountAudit(c.Request.Context(), tx, actor.Username, "create_administrator", item.Username, c.GetString("requestId")); err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(201, item) +} + +func (s *Server) updateAdministrator(c *gin.Context) { + var in struct { + DisplayName string `json:"displayName" binding:"required,max=80"` + Role string `json:"role" binding:"required,oneof=super_admin operator viewer"` + Enabled *bool `json:"enabled" binding:"required"` + } + if !s.decode(c, &in) { + return + } + in.DisplayName = strings.TrimSpace(in.DisplayName) + if in.DisplayName == "" { + s.fail(c, 400, "invalid_account", "显示名称不能为空") + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + ctx := c.Request.Context() + var item model.Administrator + if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if item.Enabled && item.Role == "super_admin" && (!*in.Enabled || in.Role != "super_admin") { + var count int + if err := tx.GetContext(ctx, &count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil { + s.dbError(c, err) + return + } + if count <= 1 { + s.fail(c, 409, "last_super_admin", "至少保留一个启用的超级管理员") + return + } + } + if item.ID == actor.ID && (!*in.Enabled || in.Role != actor.Role) { + s.fail(c, 409, "self_account_protected", "不能停用自己或修改自己的角色,请使用其他超级管理员账号操作") + return + } + invalidate := item.Role != in.Role || item.Enabled != *in.Enabled + err := tx.GetContext(ctx, &item, "UPDATE administrators SET display_name=$1,role=$2,enabled=$3 WHERE id=$4 RETURNING *", in.DisplayName, in.Role, *in.Enabled, item.ID) + if err == nil && invalidate { + _, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", item.ID) + } + if err == nil { + err = accountAudit(ctx, tx, actor.Username, "update_administrator", item.Username, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, item) +} + +func (s *Server) resetAdministratorPassword(c *gin.Context) { + var in struct { + Password string `json:"password" binding:"required,max=72"` + } + if !s.decode(c, &in) { + return + } + if !validNewPassword(in.Password) { + s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节") + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + if c.Param("id") == actor.ID { + s.fail(c, 409, "use_own_password", "修改本人密码请使用修改密码入口并验证原密码") + return + } + var item model.Administrator + ctx := c.Request.Context() + if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if err := replacePassword(ctx, tx, item.ID, in.Password); err != nil { + s.dbError(c, err) + return + } + err := accountAudit(ctx, tx, actor.Username, "reset_administrator_password", item.Username, c.GetString("requestId")) + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.Status(204) +} + +func replacePassword(ctx context.Context, tx *sqlx.Tx, id, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE administrators SET password_hash=$1 WHERE id=$2", string(hash), id) + } + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", id) + } + return err +} + +func (s *Server) changeOwnPassword(c *gin.Context) { + var in struct { + CurrentPassword string `json:"currentPassword" binding:"required,max=200"` + NewPassword string `json:"newPassword" binding:"required,max=72"` + } + if !s.decode(c, &in) { + return + } + if !validNewPassword(in.NewPassword) { + s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节") + return + } + tx, actor := s.accountTransaction(c, false) + if tx == nil { + return + } + defer tx.Rollback() + if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(in.CurrentPassword)) != nil { + s.fail(c, 400, "current_password_invalid", "原密码不正确") + return + } + ctx := c.Request.Context() + if err := replacePassword(ctx, tx, actor.ID, in.NewPassword); err != nil { + s.dbError(c, err) + return + } + err := accountAudit(ctx, tx, actor.Username, "change_own_password", actor.Username, c.GetString("requestId")) + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.Status(204) +} diff --git a/platform/internal/server/administrators_test.go b/platform/internal/server/administrators_test.go new file mode 100644 index 0000000..d177fe8 --- /dev/null +++ b/platform/internal/server/administrators_test.go @@ -0,0 +1,195 @@ +package server + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "vdi-cloud/platform/internal/store" +) + +const testAccountPassword = "Account-test-2026" + +func createTestAccount(t *testing.T, f *fixture, token, username, role string) string { + t.Helper() + status, out := f.request("POST", "/administrators", token, map[string]any{"username": username, "displayName": "Account test", "role": role, "password": testAccountPassword}, "") + expect(t, 201, status, out) + for _, field := range []string{"password", "passwordHash", "password_hash"} { + if _, ok := out[field]; ok { + t.Fatal("credential leaked", field) + } + } + return out["id"].(string) +} +func accountLogin(t *testing.T, f *fixture, username, password string) string { + t.Helper() + status, out := f.request("POST", "/auth/login", "", map[string]any{"username": username, "password": password}, "") + expect(t, 200, status, out) + return out["accessToken"].(string) +} + +func TestAdministratorLifecycleAndSessionRevocation(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + id := createTestAccount(t, f, admin, "maintainer", "operator") + token := accountLogin(t, f, "maintainer", testAccountPassword) + status, out := f.request("GET", "/administrators", token, nil, "") + expect(t, 403, status, out) + status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Read-only", "role": "viewer", "enabled": true}, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/auth/me", token, nil, "") + expect(t, 401, status, out) + token = accountLogin(t, f, "maintainer", testAccountPassword) + status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", token, map[string]any{"action": "start"}, "viewer-no-power") + expect(t, 403, status, out) + status, out = f.request("POST", "/administrators/"+id+"/password", admin, map[string]any{"password": "Reset-test-2026"}, "") + expect(t, 204, status, out) + status, out = f.request("GET", "/auth/me", token, nil, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": testAccountPassword}, "") + expect(t, 401, status, out) + token = accountLogin(t, f, "maintainer", "Reset-test-2026") + status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Disabled", "role": "viewer", "enabled": false}, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/auth/me", token, nil, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": "Reset-test-2026"}, "") + expect(t, 401, status, out) + if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil { + t.Fatal(err) + } + var enabled bool + if err := f.db.Get(&enabled, "SELECT enabled FROM administrators WHERE id=$1", id); err != nil { + t.Fatal(err) + } + if enabled { + t.Fatal("restart re-enabled disabled account") + } + status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Restored", "role": "operator", "enabled": true}, "") + expect(t, 200, status, out) + _ = accountLogin(t, f, "maintainer", "Reset-test-2026") + var audit []string + if err := f.db.Select(&audit, "SELECT action||':'||target FROM audit_logs WHERE target='maintainer' AND action LIKE '%administrator%'"); err != nil { + t.Fatal(err) + } + if len(audit) != 5 { + t.Fatal("account changes not audited", audit) + } + status, out = f.request("GET", "/administrators", admin, nil, "") + expect(t, 200, status, out) + encoded, _ := json.Marshal(out) + if strings.Contains(string(encoded), "$2") || strings.Contains(string(encoded), testAccountPassword) { + t.Fatal("credential material in account listing") + } +} + +func TestOwnPasswordRequiresCurrentPasswordAndInvalidatesAllSessions(t *testing.T) { + f := newFixture(t) + token := f.login("viewer") + other := f.login("viewer") + status, out := f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "wrong-password", "newPassword": "Updated-test-2026"}, "") + expect(t, 400, status, out) + status, out = f.request("GET", "/auth/me", token, nil, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "Demo@2026", "newPassword": "Updated-test-2026"}, "") + expect(t, 204, status, out) + for _, old := range []string{token, other} { + status, out = f.request("GET", "/auth/me", old, nil, "") + expect(t, 401, status, out) + } + status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "viewer", "password": "Demo@2026"}, "") + expect(t, 401, status, out) + _ = accountLogin(t, f, "viewer", "Updated-test-2026") + var audit string + if err := f.db.Get(&audit, "SELECT action FROM audit_logs WHERE action='change_own_password'"); err != nil { + t.Fatal(err) + } +} + +func TestAdministratorValidationRBACAndLastOwner(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + var adminID string + if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil { + t.Fatal(err) + } + for _, role := range []string{"operator", "viewer"} { + token := f.login(role) + for _, r := range []struct{ method, path string }{{"GET", "/administrators"}, {"POST", "/administrators"}, {"PUT", "/administrators/" + adminID}, {"POST", "/administrators/" + adminID + "/password"}} { + status, out := f.request(r.method, r.path, token, map[string]any{}, "") + expect(t, 403, status, out) + } + } + for _, password := range []string{"short", strings.Repeat("a", 73), strings.Repeat("密", 25)} { + status, out := f.request("POST", "/administrators", admin, map[string]any{"username": "invalid-pass", "displayName": "Invalid", "role": "viewer", "password": password}, "") + expect(t, 400, status, out) + } + body := map[string]any{"username": "test-user", "displayName": "Valid", "role": "super_admin", "password": testAccountPassword} + status, out := f.request("POST", "/administrators", admin, body, "") + expect(t, 201, status, out) + status, out = f.request("POST", "/administrators", admin, body, "") + expect(t, 409, status, out) + status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "viewer", "enabled": true}, "") + expect(t, 409, status, out) + status, out = f.request("POST", "/administrators/"+adminID+"/password", admin, map[string]any{"password": testAccountPassword}, "") + expect(t, 409, status, out) + status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self name", "role": "super_admin", "enabled": true}, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/auth/me", admin, nil, "") + expect(t, 200, status, out) + if out["displayName"] != "Self name" { + t.Fatal(out) + } + // The final owner's role and enabled state remain protected independently of UI restrictions. + if _, err := f.db.Exec("UPDATE administrators SET enabled=false WHERE username='test-user'"); err != nil { + t.Fatal(err) + } + status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "super_admin", "enabled": false}, "") + expect(t, 409, status, out) + if out["code"] != "last_super_admin" { + t.Fatal(out) + } +} + +func TestConcurrentAdministratorDisableKeepsOneOwner(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + otherID := createTestAccount(t, f, admin, "second-owner", "super_admin") + other := accountLogin(t, f, "second-owner", testAccountPassword) + var adminID string + if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan int, 2) + var group sync.WaitGroup + for _, r := range []struct{ token, target string }{{admin, otherID}, {other, adminID}} { + group.Add(1) + go func(token, target string) { + defer group.Done() + <-start + status, _ := f.request("PUT", "/administrators/"+target, token, map[string]any{"displayName": "Concurrent", "role": "super_admin", "enabled": false}, "") + results <- status + }(r.token, r.target) + } + close(start) + group.Wait() + close(results) + success := 0 + for status := range results { + if status == 200 { + success++ + } else if status != 401 && status != 403 { + t.Fatalf("unexpected losing request status %d", status) + } + } + var count int + if err := f.db.Get(&count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil { + t.Fatal(err) + } + if success != 1 || count != 1 { + t.Fatal("concurrent writes lost owner", success, count) + } +} diff --git a/platform/internal/server/catalog.go b/platform/internal/server/catalog.go new file mode 100644 index 0000000..f464d37 --- /dev/null +++ b/platform/internal/server/catalog.go @@ -0,0 +1,597 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +func (s *Server) discoverTemplates(c *gin.Context) { + pve := s.currentPVE() + if pve == nil { + s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox") + return + } + resources, err := pve.Resources(c.Request.Context()) + if err != nil { + s.fail(c, 502, "pve_unavailable", "无法发现模板,请检查 PVE 接入") + return + } + items := []gin.H{} + for _, r := range resources { + if r.Type == "qemu" && r.Template == 1 { + items = append(items, gin.H{"name": r.Name, "node": r.Node, "vmid": r.VMID, "status": r.Status}) + } + } + list(c, items) +} +func (s *Server) templateList(c *gin.Context) { + items := []model.ImageTemplate{} + if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM image_templates ORDER BY name,version"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +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"` + } + if !s.decode(c, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + in.Version = strings.TrimSpace(in.Version) + if in.Name == "" || in.Version == "" { + s.fail(c, 400, "invalid_request", "模板名称和版本不能为空") + return + } + if pve == nil { + s.fail(c, 503, "pve_unconfigured", "模板登记需要先配置 Proxmox") + return + } + config, err := pve.VMConfig(c.Request.Context(), in.Node, in.VMID) + if err != nil { + s.fail(c, 502, "pve_unavailable", "无法读取模板配置,请核对节点、VMID 和 VM.Audit 权限") + return + } + if _, err = proxmox.TemplateDisks(config); err != nil { + s.fail(c, 400, "invalid_template", err.Error()) + return + } + digest, _ := config["digest"].(string) + if digest == "" { + s.fail(c, 502, "invalid_template", "PVE 模板配置缺少版本摘要") + 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) + if conflict(err) { + s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记") + return + } + if err != nil { + s.dbError(c, err) + return + } + s.log(c, "register_template", item.ID, "success") + c.JSON(201, item) +} +func (s *Server) updateTemplate(c *gin.Context) { + var in struct { + Enabled *bool `json:"enabled" binding:"required"` + } + if !s.decode(c, &in) { + 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 { + s.dbError(c, err) + return + } + s.log(c, "update_template", item.ID, "success") + c.JSON(200, item) +} + +const poolSelect = `SELECT p.*, + (SELECT count(*) FROM desktops d WHERE d.pool_id=p.id) desktop_count, + (SELECT count(*) FROM provisioning_jobs j WHERE j.pool_id=p.id AND j.state IN ('reserved','uncertain')) reserved_count, + (SELECT count(*) FROM desktops d WHERE d.pool_id=p.id AND EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=d.id AND u.enabled)) assigned_count, + (SELECT count(*) FROM desktops d WHERE d.pool_id=p.id AND EXISTS(SELECT 1 FROM terminals t WHERE t.session_desktop_id=d.id AND t.last_seen>now()-interval '90 seconds')) active_count + FROM desktop_pools p` + +func (s *Server) poolList(c *gin.Context) { + items := []model.DesktopPool{} + if err := s.DB.SelectContext(c.Request.Context(), &items, poolSelect+" ORDER BY p.name"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} + +type poolInput struct { + Name string `json:"name" binding:"required,max=80"` + Type string `json:"type" binding:"required,oneof=dedicated shared"` + MaxCount int `json:"maxCount" binding:"min=1,max=10000"` + TemplateID *string `json:"templateId"` + Enabled *bool `json:"enabled" binding:"required"` +} + +// The catalog lock serializes capacity reservations, pool edits and membership changes. +func catalogLock(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(7651092026)") + return err +} +func (s *Server) savePool(c *gin.Context) { + var in poolInput + if !s.decode(c, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + s.fail(c, 400, "invalid_request", "桌面池名称不能为空") + return + } + if in.TemplateID != nil && *in.TemplateID == "" { + in.TemplateID = nil + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if err = catalogLock(ctx, tx); err != nil { + s.dbError(c, err) + return + } + id := c.Param("id") + if id == "" { + id = store.ID("pool_") + _, err = tx.ExecContext(ctx, "INSERT INTO desktop_pools(id,name,type,max_count,template_id,enabled) VALUES($1,$2,$3,$4,$5,$6)", id, in.Name, in.Type, in.MaxCount, in.TemplateID, *in.Enabled) + } else { + var current model.DesktopPool + if err = tx.GetContext(ctx, ¤t, poolSelect+" WHERE p.id=$1", id); err != nil { + s.dbError(c, err) + return + } + if in.MaxCount < current.DesktopCount+current.ReservedCount { + s.fail(c, 409, "pool_capacity", "容量不能小于现有桌面与待确认创建任务的总数") + return + } + if in.Type != current.Type && current.DesktopCount+current.ReservedCount > 0 { + s.fail(c, 409, "pool_in_use", "非空桌面池不能切换公共/专属类型") + return + } + _, err = tx.ExecContext(ctx, "UPDATE desktop_pools SET name=$1,type=$2,max_count=$3,template_id=$4,enabled=$5 WHERE id=$6", in.Name, in.Type, in.MaxCount, in.TemplateID, *in.Enabled, id) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE desktops SET pool=$1 WHERE pool_id=$2", in.Name, id) + } + } + if conflict(err) { + s.fail(c, 409, "pool_conflict", "名称已存在或所选模板不存在") + return + } + if err != nil { + s.dbError(c, err) + return + } + var item model.DesktopPool + if err = tx.GetContext(ctx, &item, poolSelect+" WHERE p.id=$1", id); err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + s.log(c, "save_pool", id, "success") + c.JSON(200, item) +} +func (s *Server) poolMembers(c *gin.Context) { + var in struct { + DesktopIDs []string `json:"desktopIds" binding:"max=1000"` + } + 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() + if err = catalogLock(ctx, tx); err != nil { + s.dbError(c, err) + return + } + var pool model.DesktopPool + if err = tx.GetContext(ctx, &pool, poolSelect+" WHERE p.id=$1", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + ids := map[string]bool{} + for _, id := range in.DesktopIDs { + ids[id] = true + } + if len(ids)+pool.ReservedCount > pool.MaxCount { + s.fail(c, 409, "pool_capacity", "桌面数量与创建预留超过池容量") + return + } + for id := range ids { + var desktop model.Desktop + if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1", id); err != nil { + s.fail(c, 400, "invalid_desktop", "所选桌面不存在") + return + } + if desktop.PoolID != nil && *desktop.PoolID != pool.ID { + s.fail(c, 409, "pool_membership", "桌面已属于其他池,请先从原池移出") + return + } + } + if _, err = tx.ExecContext(ctx, "UPDATE desktops SET pool_id=NULL,pool='' WHERE pool_id=$1", pool.ID); err != nil { + s.dbError(c, err) + return + } + for id := range ids { + if _, err = tx.ExecContext(ctx, "UPDATE desktops SET pool_id=$1,pool=$2 WHERE id=$3", pool.ID, pool.Name, id); err != nil { + s.dbError(c, err) + return + } + } + if err = tx.Commit(); err != nil { + s.dbError(c, err) + return + } + s.log(c, "update_pool_members", pool.ID, "success") + c.JSON(200, gin.H{"accepted": true}) +} + +type cloneInput struct { + TemplateID string `json:"templateId" binding:"required,max=100"` + PoolID *string `json:"poolId"` + Name string `json:"name" binding:"required,max=80"` + VMID int `json:"vmid" binding:"min=100,max=999999999"` + Full bool `json:"full"` + Storage string `json:"storage" binding:"max=100"` +} +type provisioningJob struct { + TaskID string `db:"task_id"` + TemplateID string `db:"template_id"` + PoolID *string `db:"pool_id"` + DesktopID string `db:"desktop_id"` + VMID int `db:"vmid"` + RequestJSON string `db:"request_json"` + RequestHash string `db:"request_hash"` + State string `db:"state"` + CreatedAt time.Time `db:"created_at"` +} + +func normalizeClone(in *cloneInput) { + in.Name = strings.TrimSpace(in.Name) + if in.PoolID != nil && *in.PoolID == "" { + in.PoolID = nil + } +} +func (s *Server) clonePreflight(ctx context.Context, in cloneInput) (model.ImageTemplate, error) { + pve := s.currentPVE() + var template model.ImageTemplate + if pve == nil { + return template, errors.New("尚未配置 Proxmox") + } + if in.Name == "" || in.Full && in.Storage == "" || !in.Full && in.Storage != "" { + return template, errors.New("请填写桌面名称;完整克隆必须选择存储,链接克隆沿用模板存储") + } + if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1 AND enabled", in.TemplateID); err != nil { + return template, errors.New("模板不存在或已停用") + } + config, err := pve.VMConfig(ctx, template.Node, template.VMID) + if err != nil { + return template, errors.New("无法读取源模板配置") + } + disks, err := proxmox.TemplateDisks(config) + if err != nil { + return template, err + } + if config["digest"] != template.Digest { + return template, errors.New("源模板已变更,请重新验证模板后再创建") + } + can, err := pve.CanClone(ctx, template.Node, template.VMID, in.Full) + if err != nil || !can { + return template, errors.New("模板所在存储不支持所选克隆方式") + } + resources, err := pve.Resources(ctx) + if err != nil { + return template, errors.New("无法检查目标 VMID") + } + for _, r := range resources { + if (r.Type == "qemu" || r.Type == "lxc") && r.VMID == in.VMID { + return template, errors.New("目标 VMID 已被 PVE 使用") + } + } + for _, permission := range []struct{ path, name string }{{fmt.Sprintf("/vms/%d", template.VMID), "VM.Clone"}, {fmt.Sprintf("/vms/%d", in.VMID), "VM.Allocate"}} { + ok, err := pve.HasPermission(ctx, permission.path, permission.name) + if err != nil || !ok { + return template, fmt.Errorf("PVE 平台账号缺少 %s 权限(%s)", permission.name, permission.path) + } + } + storages, err := pve.Storages(ctx, template.Node) + if err != nil { + return template, errors.New("无法查询目标存储") + } + if in.Full { + total := float64(0) + for _, size := range disks { + total += size + } + disks = map[string]float64{in.Storage: total} + } + for name, size := range disks { + found := false + for _, storage := range storages { + if storage.Name == name && storage.Active == 1 && storage.Enabled == 1 && strings.Contains(","+storage.Content+",", ",images,") { + found = true + needed := float64(1 << 30) + if in.Full { + needed += size + } + if storage.Available < needed { + return template, fmt.Errorf("存储 %s 可用空间不足,已预留 1 GiB 余量", name) + } + } + } + if !found { + return template, fmt.Errorf("存储 %s 不可用或不支持虚拟机磁盘", name) + } + ok, err := pve.HasPermission(ctx, "/storage/"+name, "Datastore.AllocateSpace") + if err != nil || !ok { + return template, fmt.Errorf("存储 %s 缺少 Datastore.AllocateSpace 权限", name) + } + } + return template, nil +} +func (s *Server) storageList(c *gin.Context) { + pve := s.currentPVE() + if pve == nil { + s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox") + return + } + node := c.Query("node") + if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]{0,99}$`).MatchString(node) { + s.fail(c, 400, "invalid_node", "请选择节点") + return + } + items, err := pve.Storages(c.Request.Context(), node) + if err != nil { + s.fail(c, 502, "pve_unavailable", "存储查询失败") + return + } + list(c, items) +} +func (s *Server) checkClone(c *gin.Context) { + var in cloneInput + if !s.decode(c, &in) { + return + } + normalizeClone(&in) + _, err := s.clonePreflight(c.Request.Context(), in) + if err != nil { + c.JSON(200, gin.H{"available": false, "message": err.Error()}) + return + } + if in.PoolID != nil { + var pool model.DesktopPool + if err = s.DB.GetContext(c.Request.Context(), &pool, poolSelect+" WHERE p.id=$1", *in.PoolID); err != nil { + c.JSON(200, gin.H{"available": false, "message": "所选桌面池不存在"}) + return + } + if !pool.Enabled || pool.DesktopCount+pool.ReservedCount >= pool.MaxCount || (pool.TemplateID != nil && *pool.TemplateID != in.TemplateID) { + c.JSON(200, gin.H{"available": false, "message": "桌面池已停用、容量不足或模板不匹配"}) + return + } + } + var reserved bool + if err = s.DB.GetContext(c.Request.Context(), &reserved, "SELECT EXISTS(SELECT 1 FROM provisioning_jobs WHERE vmid=$1)", in.VMID); err != nil { + s.dbError(c, err) + return + } + if reserved { + c.JSON(200, gin.H{"available": false, "message": "目标 VMID 已有创建记录,请核对任务并选择其他 VMID"}) + return + } + c.JSON(200, gin.H{"available": true, "message": "计算侧预检通过;提交时会再次检查容量和模板,创建后需要完成系统初始化与配对"}) +} +func (s *Server) cloneDesktop(c *gin.Context) { + var in cloneInput + if !s.decode(c, &in) { + return + } + normalizeClone(&in) + key := c.GetHeader("Idempotency-Key") + if key == "" || len(key) > 100 { + s.fail(c, 400, "idempotency_required", "请提供 Idempotency-Key") + return + } + ctx := c.Request.Context() + body, _ := json.Marshal(in) + hash := store.Hash(string(body)) + actor := s.admin(c).Username + var existing model.Task + if err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key); err == nil { + var same bool + _ = s.DB.GetContext(ctx, &same, "SELECT EXISTS(SELECT 1 FROM provisioning_jobs WHERE task_id=$1 AND request_hash=$2)", existing.ID, hash) + if !same { + s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同创建参数") + return + } + c.JSON(202, existing) + return + } else if !errors.Is(err, sql.ErrNoRows) { + s.dbError(c, err) + return + } + _, err := s.clonePreflight(ctx, in) + if err != nil { + s.fail(c, 409, "clone_preflight_failed", err.Error()) + return + } + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if err = catalogLock(ctx, tx); err != nil { + s.dbError(c, err) + return + } + var enabled bool + if err = tx.GetContext(ctx, &enabled, "SELECT enabled FROM image_templates WHERE id=$1 FOR SHARE", in.TemplateID); err != nil || !enabled { + s.fail(c, 409, "template_disabled", "模板已停用") + return + } + if in.PoolID != nil { + var pool model.DesktopPool + if err = tx.GetContext(ctx, &pool, poolSelect+" WHERE p.id=$1", *in.PoolID); err != nil { + s.fail(c, 400, "invalid_pool", "所选桌面池不存在") + return + } + if !pool.Enabled || pool.DesktopCount+pool.ReservedCount >= pool.MaxCount { + s.fail(c, 409, "pool_capacity", "桌面池已停用或没有剩余容量") + return + } + if pool.TemplateID != nil && *pool.TemplateID != in.TemplateID { + s.fail(c, 409, "pool_template", "所选模板与池的模板不一致") + return + } + } + var taken bool + if err = tx.GetContext(ctx, &taken, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='proxmox' AND vmid=$1)", in.VMID); err != nil { + s.dbError(c, err) + return + } + if taken { + s.fail(c, 409, "vmid_conflict", "目标 VMID 已纳管") + return + } + var task model.Task + desktopID := store.ID("desk_") + err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'provision','clone',$2,$3,$4,'queued','创建请求已排队,等待 PVE 最终结果',$5) RETURNING *`, store.ID("task_"), desktopID, in.Name, actor, key) + if err == nil { + _, err = tx.ExecContext(ctx, `INSERT INTO provisioning_jobs(task_id,template_id,pool_id,desktop_id,vmid,request_json,request_hash) VALUES($1,$2,$3,$4,$5,$6,$7)`, task.ID, in.TemplateID, in.PoolID, desktopID, in.VMID, string(body), hash) + } + if conflict(err) { + s.fail(c, 409, "provision_conflict", "该 VMID 已有创建记录或重复请求,请核对任务") + return + } + if err != nil { + s.dbError(c, err) + return + } + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'clone',$3,'submitted',$4,now())", store.ID("audit_"), actor, in.Name, c.GetString("requestId")) + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(202, task) +} + +func validEndpoint(endpoint string) bool { + if net.ParseIP(endpoint) != nil { + return true + } + if len(endpoint) > 253 { + return false + } + for _, label := range strings.Split(endpoint, ".") { + if !regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`).MatchString(label) { + return false + } + } + return true +} +func (s *Server) configureDesktop(c *gin.Context) { + var in struct { + Endpoint string `json:"endpoint" binding:"required,max=253"` + Width int `json:"width" binding:"min=640,max=7680"` + Height int `json:"height" binding:"min=480,max=4320"` + FPS int `json:"fps" binding:"min=10,max=120"` + Initialized bool `json:"initialized"` + } + if !s.decode(c, &in) { + return + } + if !validEndpoint(in.Endpoint) { + s.fail(c, 400, "invalid_endpoint", "请填写有效的 IP 或主机名") + return + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + var desktop model.Desktop + if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + var active bool + if err = tx.GetContext(ctx, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", desktop.ID); err != nil { + s.dbError(c, err) + return + } + if active { + s.fail(c, 409, "desktop_busy", "桌面有连接中或活动会话,请先结束连接") + return + } + if blocked, e := maintenanceBlocked(ctx, tx, desktop.ID, "", true); e != nil { + s.dbError(c, e) + return + } else if blocked { + s.fail(c, 409, "desktop_maintenance", "桌面维护任务进行中或结果未确认,请先核对") + return + } + if desktop.SetupRequired && !in.Initialized { + s.fail(c, 400, "initialization_required", "请先确认系统身份、IP、Sunshine 服务和终端配对已经完成") + return + } + err = tx.GetContext(ctx, &desktop, `UPDATE desktops SET endpoint=$1,width=$2,height=$3,fps=$4,setup_required=false,service_state='unknown',updated_at=now() WHERE id=$5 RETURNING *`, in.Endpoint, in.Width, in.Height, in.FPS, desktop.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE desktop_id=$1", desktop.ID) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + s.log(c, "configure_desktop", desktop.ID, "success") + c.JSON(200, desktop) +} diff --git a/platform/internal/server/catalog_test.go b/platform/internal/server/catalog_test.go new file mode 100644 index 0000000..f306e4a --- /dev/null +++ b/platform/internal/server/catalog_test.go @@ -0,0 +1,345 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + "vdi-cloud/platform/pkg/proxmox" +) + +type catalogPVE struct { + posts atomic.Int32 + deny atomic.Bool + changed atomic.Bool + lowSpace atomic.Bool + pollFailure atomic.Bool + holdPoll atomic.Bool + outcome string +} + +func fakeCatalogPVE(t *testing.T, f *fixture, outcome string) *catalogPVE { + t.Helper() + p := &catalogPVE{outcome: outcome} + p.pollFailure.Store(outcome == "poll-failure") + p.holdPoll.Store(outcome == "running") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + var data any + switch { + case path == "/cluster/resources": + data = []map[string]any{{"type": "qemu", "node": "node-a", "vmid": 8000, "template": 1, "name": "ubuntu-template", "status": "stopped"}} + case path == "/nodes/node-a/qemu/8000/config": + digest := "v1" + if p.changed.Load() { + digest = "v2" + } + data = map[string]any{"template": 1, "digest": digest, "scsi0": "local:base-8000,size=4G"} + case path == "/nodes/node-a/qemu/8000/feature": + data = map[string]any{"hasFeature": 1} + case path == "/access/permissions": + allowed := 1 + if p.deny.Load() { + allowed = 0 + } + data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Clone": allowed, "VM.Allocate": allowed, "Datastore.AllocateSpace": allowed}} + case path == "/nodes/node-a/storage": + avail := float64(100 * (1 << 30)) + if p.lowSpace.Load() { + avail = 0 + } + data = []map[string]any{{"storage": "local", "avail": avail, "active": 1, "enabled": 1, "content": "images"}} + case path == "/nodes/node-a/qemu/8000/clone": + p.posts.Add(1) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Method != "POST" || r.Form.Get("name") != "vdi-9000" || r.Form.Get("newid") != "9000" || r.Form.Get("full") != "1" || r.Form.Get("storage") != "local" { + t.Errorf("incorrect clone form: %v", r.Form) + } + if outcome == "ambiguous" { + w.WriteHeader(500) + return + } + data = "UPID:test-clone" + case strings.Contains(path, "/tasks/"): + if p.pollFailure.Load() { + w.WriteHeader(503) + return + } + if p.holdPoll.Load() { + data = map[string]any{"status": "running"} + break + } + exit := "OK" + if outcome == "failed" { + exit = "clone failed" + } + data = map[string]any{"status": "stopped", "exitstatus": exit} + case path == "/nodes/node-a/qemu/9000/config": + data = map[string]any{"name": "vdi-9000", "scsi0": "local:vm-9000,size=4G"} + case path == "/nodes/node-a/qemu/9000/status/current": + data = map[string]any{"status": "stopped"} + default: + t.Errorf("unexpected PVE request: %s", path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} + return p +} +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}, "") + 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}, "") + expect(t, 200, status, out) + return tpl, out["id"].(string) +} +func cloneBody(tpl, pool string) map[string]any { + return map[string]any{"templateId": tpl, "poolId": pool, "name": "New desktop", "vmid": 9000, "full": true, "storage": "local"} +} +func waitCatalogTask(t *testing.T, f *fixture, admin, id string) map[string]any { + t.Helper() + deadline := time.Now().Add(6 * time.Second) + for { + status, out := f.request("GET", "/tasks/"+id, admin, nil, "") + expect(t, 200, status, out) + if out["status"] != "queued" && out["status"] != "running" { + return out + } + if time.Now().After(deadline) { + t.Fatal("clone never completed", out) + } + time.Sleep(30 * time.Millisecond) + } +} +func TestCloneCreatesDurableUninitializedDesktop(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, "success") + admin := f.login("admin") + tpl, pool := registerCatalog(t, f, admin) + body := cloneBody(tpl, pool) + status, out := f.request("POST", "/desktops/clone/check", admin, body, "") + expect(t, 200, status, out) + if out["available"] != true { + t.Fatal(out) + } + status, out = f.request("POST", "/desktops/clone", admin, body, "clone-once") + expect(t, 202, status, out) + id := out["id"].(string) + task := waitCatalogTask(t, f, admin, id) + if task["status"] != "success" { + t.Fatal(task) + } + status, again := f.request("POST", "/desktops/clone", admin, body, "clone-once") + expect(t, 202, status, again) + if again["id"] != id || p.posts.Load() != 1 { + t.Fatal("clone replayed", again, p.posts.Load()) + } + body["name"] = "different" + status, out = f.request("POST", "/desktops/clone", admin, body, "clone-once") + expect(t, 409, status, out) + status, out = f.request("GET", "/desktops/"+task["targetId"].(string), admin, nil, "") + expect(t, 200, status, out) + if out["setupRequired"] != true || out["endpoint"] != "" || out["poolId"] != pool { + t.Fatal("new VM incorrectly advertised ready", out) + } + connection := map[string]any{"endpoint": "192.0.2.20", "width": 1920, "height": 1080, "fps": 30, "initialized": false} + desk := task["targetId"].(string) + status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "") + expect(t, 400, status, out) + connection["initialized"] = true + connection["endpoint"] = "https://invalid" + status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "") + expect(t, 400, status, out) + connection["endpoint"] = "192.0.2.20" + status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "") + expect(t, 200, status, out) + if out["setupRequired"] != false { + t.Fatal(out) + } + body["vmid"] = 9001 + status, out = f.request("POST", "/desktops/clone/check", admin, body, "") + expect(t, 200, status, out) + if out["available"] != false { + t.Fatal("full pool passed preflight", out) + } + status, out = f.request("POST", "/desktops/clone", admin, body, "over-capacity") + expect(t, 409, status, out) + var count int + _ = f.db.Get(&count, "SELECT count(*) FROM audit_logs WHERE action='clone' AND result='success'") + if count != 1 { + t.Fatal("missing final audit") + } +} +func TestClonePreflightAndCatalogRBAC(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, "success") + admin := f.login("admin") + tpl, pool := registerCatalog(t, f, admin) + body := cloneBody(tpl, pool) + for _, role := range []string{"viewer", "operator"} { + token := f.login(role) + for _, endpoint := range []string{"/templates", "/pools", "/desktops/clone", "/desktops/clone/check"} { + status, out := f.request("POST", endpoint, token, body, "forbidden") + expect(t, 403, status, out) + } + } + for _, tc := range []struct { + name string + flag *atomic.Bool + }{{"permission", &p.deny}, {"template-changed", &p.changed}, {"storage-capacity", &p.lowSpace}} { + t.Run(tc.name, func(t *testing.T) { + tc.flag.Store(true) + defer tc.flag.Store(false) + status, out := f.request("POST", "/desktops/clone/check", admin, body, "") + expect(t, 200, status, out) + if out["available"] != false { + t.Fatal(out) + } + status, out = f.request("POST", "/desktops/clone", admin, body, tc.name) + expect(t, 409, status, out) + }) + } + if p.posts.Load() != 0 { + t.Fatal("preflight failure still issued clone") + } + status, out := f.request("PATCH", "/templates/"+tpl, admin, map[string]any{"enabled": false}, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/desktops/clone", admin, body, "disabled") + expect(t, 409, status, out) +} +func TestAmbiguousAndFailedCloneRetainPoolCapacity(t *testing.T) { + for _, outcome := range []string{"ambiguous", "failed"} { + t.Run(outcome, func(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, outcome) + admin := f.login("admin") + tpl, pool := registerCatalog(t, f, admin) + status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "uncertain") + expect(t, 202, status, out) + task := waitCatalogTask(t, f, admin, out["id"].(string)) + want := "timeout" + if outcome == "failed" { + want = "failed" + } + if task["status"] != want { + t.Fatal(task) + } + var state string + _ = f.db.Get(&state, "SELECT state FROM provisioning_jobs WHERE task_id=$1", task["id"]) + if state != "uncertain" { + t.Fatal("lost uncertain reservation", state) + } + status, out = f.request("GET", "/pools", admin, nil, "") + expect(t, 200, status, out) + if out["items"].([]any)[0].(map[string]any)["reservedCount"] != float64(1) { + t.Fatal(out) + } + time.Sleep(350 * time.Millisecond) + if p.posts.Load() != 1 { + t.Fatal("ambiguous POST replayed") + } + }) + } +} +func TestKnownUPIDReconcilesWithoutAnotherClone(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, "poll-failure") + admin := f.login("admin") + tpl, pool := registerCatalog(t, f, admin) + status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "resume-known") + expect(t, 202, status, out) + id := out["id"].(string) + task := waitCatalogTask(t, f, admin, id) + if task["status"] != "timeout" || task["providerRef"] == "" { + t.Fatal(task) + } + p.pollFailure.Store(false) + status, out = f.request("POST", "/tasks/"+id+"/reconcile", admin, nil, "") + expect(t, 202, status, out) + task = waitCatalogTask(t, f, admin, id) + if task["status"] != "success" || p.posts.Load() != 1 { + t.Fatal("known UPID was not safely resumed", task, p.posts.Load()) + } + status, out = f.request("POST", "/tasks/"+id+"/reconcile", admin, nil, "") + expect(t, 409, status, out) +} + +func TestCloneDoesNotBlockOrdinaryTasks(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, "running") + admin := f.login("admin") + tpl, pool := registerCatalog(t, f, admin) + status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "long-clone") + expect(t, 202, status, out) + cloneID := out["id"].(string) + deadline := time.Now().Add(5 * time.Second) + for p.posts.Load() == 0 { + if time.Now().After(deadline) { + t.Fatal("clone did not start") + } + time.Sleep(20 * time.Millisecond) + } + status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]any{"action": "start"}, "during-clone") + expect(t, 202, status, out) + power := waitCatalogTask(t, f, admin, out["id"].(string)) + if power["status"] != "success" { + t.Fatal("ordinary task blocked by clone", power) + } + status, out = f.request("GET", "/tasks/"+cloneID, admin, nil, "") + expect(t, 200, status, out) + if out["status"] != "running" { + t.Fatal("clone should still be polling", out) + } + p.holdPoll.Store(false) + if task := waitCatalogTask(t, f, admin, cloneID); task["status"] != "success" { + t.Fatal(task) + } +} + +func TestPoolMembershipCapacityAndConnectionBusy(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + input := map[string]any{"name": "Pool A", "type": "shared", "maxCount": 1, "templateId": nil, "enabled": true} + status, out := f.request("POST", "/pools", admin, input, "") + expect(t, 200, status, out) + pool := out["id"].(string) + status, out = f.request("PUT", "/pools/"+pool+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu", "desk_demo_kylin"}}, "") + expect(t, 409, status, out) + status, out = f.request("PUT", "/pools/"+pool+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu"}}, "") + expect(t, 200, status, out) + input["name"] = "Pool B" + status, out = f.request("POST", "/pools", admin, input, "") + expect(t, 200, status, out) + other := out["id"].(string) + status, out = f.request("PUT", "/pools/"+other+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu"}}, "") + expect(t, 409, status, out) + input["type"] = "dedicated" + input["name"] = "Renamed" + status, out = f.request("PUT", "/pools/"+pool, admin, input, "") + expect(t, 409, status, out) + input["type"] = "shared" + status, out = f.request("PUT", "/pools/"+pool, admin, input, "") + expect(t, 200, status, out) + var name string + _ = f.db.Get(&name, "SELECT pool FROM desktops WHERE id='desk_demo_ubuntu'") + if name != "Renamed" { + t.Fatal("pool display name stale", name) + } + _, err := f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_ubuntu',last_seen=now() WHERE id='term_demo_001'") + if err != nil { + t.Fatal(err) + } + status, out = f.request("PUT", "/desktops/desk_demo_ubuntu/connection", admin, map[string]any{"endpoint": "192.0.2.1", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "") + expect(t, 409, status, out) +} diff --git a/platform/internal/server/compute_connection.go b/platform/internal/server/compute_connection.go new file mode 100644 index 0000000..12a559a --- /dev/null +++ b/platform/internal/server/compute_connection.go @@ -0,0 +1,270 @@ +package server + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/jmoiron/sqlx" + "io" + "os" + "strings" + "unicode" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type pveInput struct { + URL string `json:"url" binding:"required,max=250"` + TokenID string `json:"tokenId" binding:"required,max=200"` + TokenSecret string `json:"tokenSecret" binding:"max=200"` + CAPEM string `json:"caPem" binding:"max=32768"` + Revision string `json:"revision" binding:"max=80"` +} + +// New configuration writes reject fields outside the supported API contract. +func (s *Server) decodeConfiguration(c *gin.Context, value any) bool { + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(value) + if err == nil { + var trailing any + if decoder.Decode(&trailing) != io.EOF { + err = errors.New("trailing JSON") + } + } + if err == nil { + err = binding.Validator.ValidateStruct(value) + } + if err != nil { + s.fail(c, 400, "invalid_request", "请检查必填字段和格式,仅接受当前支持的配置参数") + return false + } + return true +} + +func (s *Server) cryptPVE(value string, seal bool) (string, error) { + key := sha256.Sum256([]byte("vdi-compute-config-v1:" + s.Config.JWTSecret)) + block, _ := aes.NewCipher(key[:]) + gcm, _ := cipher.NewGCM(block) + aad := []byte("pve_connection:v1") + if seal { + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(value), aad)), nil + } + data, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(data) < gcm.NonceSize() { + return "", errors.New("invalid encrypted connection") + } + data, err = gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], aad) + return string(data), err +} +func (s *Server) effectivePVE(ctx context.Context, db sqlx.ExtContext) (pveInput, error) { + var row struct { + Revision string `db:"revision"` + Encrypted string `db:"encrypted_config"` + } + var in pveInput + err := sqlx.GetContext(ctx, db, &row, "SELECT revision,encrypted_config FROM pve_connection WHERE singleton") + if err == sql.ErrNoRows { + in = pveInput{URL: s.Config.PVEURL, TokenID: s.Config.PVETokenID, TokenSecret: s.Config.PVETokenSecret, Revision: "environment"} + if s.Config.PVECAFile != "" { + data, e := os.ReadFile(s.Config.PVECAFile) + if e != nil { + return in, e + } + in.CAPEM = string(data) + } + return in, nil + } + if err != nil { + return in, err + } + plain, err := s.cryptPVE(row.Encrypted, false) + if err != nil { + return in, err + } + err = json.Unmarshal([]byte(plain), &in) + in.Revision = row.Revision + return in, err +} +func (s *Server) currentPVE() *proxmox.Client { + if p := s.computeRuntime.Load(); p != nil { + return p + } + return s.PVE +} +func (s *Server) RefreshCompute(ctx context.Context) error { + s.computeMu.Lock() + defer s.computeMu.Unlock() + var revision string + err := s.DB.GetContext(ctx, &revision, "SELECT revision FROM pve_connection WHERE singleton") + if err == sql.ErrNoRows { + s.computeRuntime.Store(nil) + s.computeRevision = "" + return nil + } + if err != nil { + return err + } + if revision == s.computeRevision { + return nil + } + in, err := s.effectivePVE(ctx, s.DB) + if err != nil { + return err + } + client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM) + if err != nil { + return err + } + s.computeRuntime.Store(client) + s.computeRevision = in.Revision + return nil +} +func (s *Server) refreshComputeRequest(c *gin.Context) { + if err := s.RefreshCompute(c.Request.Context()); err != nil { + s.fail(c, 503, "compute_config_unavailable", "无法加载计算平台配置,请检查服务端密钥和数据库") + return + } + c.Next() +} +func (s *Server) getPVEConnection(c *gin.Context) { + in, err := s.effectivePVE(c.Request.Context(), s.DB) + if err != nil { + s.fail(c, 503, "compute_config_unavailable", "无法读取接入配置") + return + } + c.JSON(200, gin.H{"url": in.URL, "tokenId": in.TokenID, "caPem": in.CAPEM, "revision": in.Revision, "tokenConfigured": in.TokenSecret != "", "source": map[bool]string{true: "environment", false: "database"}[in.Revision == "environment"]}) +} +func (s *Server) resolvePVEInput(c *gin.Context, in *pveInput, db sqlx.ExtContext) bool { + old, err := s.effectivePVE(c.Request.Context(), db) + if err != nil { + s.fail(c, 503, "compute_config_unavailable", "无法读取原接入配置") + return false + } + in.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/") + in.TokenID = strings.TrimSpace(in.TokenID) + if in.Revision != old.Revision { + s.fail(c, 409, "connection_changed", "配置已变化,请重新加载") + return false + } + if !strings.Contains(in.TokenID, "@") || !strings.Contains(in.TokenID, "!") || strings.ContainsFunc(in.TokenID, unicode.IsSpace) || strings.ContainsFunc(in.TokenSecret, unicode.IsControl) { + s.fail(c, 400, "invalid_connection", "请输入有效的 API Token ID 和密钥") + return false + } + if in.TokenSecret == "" { + if in.TokenID != old.TokenID || in.URL != strings.TrimRight(old.URL, "/") { + s.fail(c, 400, "token_required", "更换接入地址或 Token ID 时必须填写新密钥") + return false + } + in.TokenSecret = old.TokenSecret + } + // Once attached, keep one cluster identity even if inventory is currently empty. + // This prevents an in-flight sync/import from writing the previous cluster's data. + if old.URL != "" && in.URL != strings.TrimRight(old.URL, "/") { + s.fail(c, 409, "compute_in_use", "已配置接入地址,本期仅支持维护同一地址的凭据与证书;切换集群需离线迁移") + return false + } + if in.TokenSecret == "" { + s.fail(c, 400, "token_required", "请填写 API Token 密钥") + return false + } + return true +} +func (s *Server) testPVE(c *gin.Context, in pveInput) bool { + client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM) + if err != nil { + s.fail(c, 400, "invalid_connection", "请检查 HTTPS 地址和 PEM CA 证书") + return false + } + resources, err := client.Resources(c.Request.Context()) + if err != nil { + s.fail(c, 502, "pve_connection_failed", "连接检查失败,请检查网络、TLS 证书与 Token 的资源查看权限") + return false + } + nodes, vms := 0, 0 + for _, r := range resources { + if r.Type == "node" { + nodes++ + } + if r.Type == "qemu" { + vms++ + } + } + if nodes == 0 { + s.fail(c, 403, "pve_audit_required", "未读取到节点,请核查 Token 的资源查看权限") + return false + } + c.Set("connectionNodes", nodes) + c.Set("connectionVMs", vms) + return true +} +func (s *Server) checkPVEConnection(c *gin.Context) { + var in pveInput + if !s.decodeConfiguration(c, &in) || !s.resolvePVEInput(c, &in, s.DB) || !s.testPVE(c, in) { + return + } + c.JSON(200, gin.H{"available": true, "nodes": c.GetInt("connectionNodes"), "vms": c.GetInt("connectionVMs"), "message": "TLS、身份与资源读取检查通过"}) +} +func (s *Server) savePVEConnection(c *gin.Context) { + var in pveInput + if !s.decodeConfiguration(c, &in) { + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(76511102027)"); err != nil { + s.dbError(c, err) + return + } + if !s.resolvePVEInput(c, &in, tx) { + return + } + var busy bool + if err := tx.GetContext(c.Request.Context(), &busy, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind IN ('desktop','hardware','snapshot','batch-desktop','provision','resource') AND status IN ('queued','running'))"); err != nil { + s.dbError(c, err) + return + } + if busy { + s.fail(c, 409, "compute_busy", "计算任务执行中,请完成后再修改接入配置") + return + } + if !s.testPVE(c, in) { + return + } + in.Revision = store.ID("connection_") + plain, _ := json.Marshal(in) + encrypted, err := s.cryptPVE(string(plain), true) + if err == nil { + _, err = tx.ExecContext(c.Request.Context(), "INSERT INTO pve_connection(singleton,revision,encrypted_config) VALUES(true,$1,$2) ON CONFLICT(singleton) DO UPDATE SET revision=$1,encrypted_config=$2,updated_at=now()", in.Revision, encrypted) + } + if err == nil { + err = accountAudit(c.Request.Context(), tx, actor.Username, "save_pve_connection", "pve", c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + if err = s.RefreshCompute(c.Request.Context()); err != nil { + s.fail(c, 503, "compute_reload_failed", "配置已保存,加载失败,请检查服务状态") + return + } + c.JSON(200, gin.H{"accepted": true}) +} diff --git a/platform/internal/server/configuration_test.go b/platform/internal/server/configuration_test.go new file mode 100644 index 0000000..4e50566 --- /dev/null +++ b/platform/internal/server/configuration_test.go @@ -0,0 +1,471 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "vdi-cloud/platform/internal/config" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +func TestPVEConnectionEncryptedReloadAndRestrictions(t *testing.T) { + f := newFixtureWorker(t, false) + admin := f.login("admin") + var calls atomic.Int32 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != "GET" || r.URL.Path != "/api2/json/cluster/resources" { + t.Error("unexpected connection write", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "PVEAPIToken=test@pve!console=private-test-token" { + w.WriteHeader(401) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"type": "node", "node": "test-node", "status": "online"}}}) + })) + defer srv.Close() + ca := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})) + f.server.Config.PVEURL = srv.URL + f.server.Config.PVETokenID = "test@pve!console" + f.server.Config.PVETokenSecret = "private-test-token" + code, out := f.request("GET", "/settings/pve", admin, nil, "") + expect(t, 200, code, out) + if out["source"] != "environment" || out["tokenConfigured"] != true || out["tokenSecret"] != nil { + t.Fatal(out) + } + body := map[string]any{"url": srv.URL, "tokenId": "test@pve!console", "tokenSecret": "", "caPem": ca, "revision": "environment"} + bad := map[string]any{} + for k, v := range body { + bad[k] = v + } + bad["caPem"] = "" + code, out = f.request("POST", "/settings/pve/check", admin, bad, "") + expect(t, 502, code, out) + code, out = f.request("POST", "/settings/pve/check", admin, body, "") + expect(t, 200, code, out) + code, out = f.request("PUT", "/settings/pve", f.login("operator"), body, "") + expect(t, 403, code, out) + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 200, code, out) + var encrypted string + if err := f.db.Get(&encrypted, "SELECT encrypted_config FROM pve_connection"); err != nil { + t.Fatal(err) + } + if strings.Contains(encrypted, "private-test-token") || strings.Contains(encrypted, "test@pve") { + t.Fatal("plaintext credential persisted") + } + code, out = f.request("GET", "/settings/pve", admin, nil, "") + expect(t, 200, code, out) + raw, _ := json.Marshal(out) + if bytes.Contains(raw, []byte("private-test-token")) || out["source"] != "database" { + t.Fatal("credential leaked", out) + } + revision := out["revision"] + replica := New(f.server.Config, f.db, nil) + if err := replica.RefreshCompute(context.Background()); err != nil { + t.Fatal(err) + } + if replica.currentPVE() == nil || replica.currentPVE().TokenSecret != "private-test-token" { + t.Fatal("replica failed to load") + } + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 409, code, out) + body["revision"] = revision + body["tokenId"] = "other@pve!console" + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 400, code, out) + body["tokenSecret"] = "wrong" + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 502, code, out) + body["url"] = "https://different.example:8006" + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 409, code, out) + body["url"] = srv.URL + body["tokenId"] = "test@pve!console" + body["tokenSecret"] = "" + code, out = f.request("PUT", "/settings/pve", admin, body, "") + expect(t, 200, code, out) + if err := replica.RefreshCompute(context.Background()); err != nil { + t.Fatal(err) + } + if replica.computeRevision == revision { + t.Fatal("replica kept stale revision") + } + replica.Config.JWTSecret = strings.Repeat("different", 10) + replica.computeRevision = "" + if err := replica.RefreshCompute(context.Background()); err == nil { + t.Fatal("wrong encryption key accepted") + } + var leaked bool + f.db.Get(&leaked, "SELECT EXISTS(SELECT 1 FROM audit_logs WHERE target LIKE '%private-test-token%' OR action LIKE '%private-test-token%')") + if leaked || calls.Load() < 3 { + t.Fatal("audit leak or connection never checked") + } +} + +func loginRecorder(s *Server, username, remote, forwarded string) *httptest.ResponseRecorder { + raw, _ := json.Marshal(map[string]string{"username": username, "password": "wrong-password"}) + r := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(raw)) + r.RemoteAddr = remote + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Forwarded-For", forwarded) + w := httptest.NewRecorder() + s.Router.ServeHTTP(w, r) + return w +} +func TestLoginLimitsPersistentConcurrentAndForwarding(t *testing.T) { + f := newFixtureWorker(t, false) + for i := 0; i < 10; i++ { + w := loginRecorder(f.server, "missing", "192.0.2.1:1000", fmt.Sprintf("198.51.100.%d", i)) + if w.Code != 401 { + t.Fatal(w.Code, w.Body.String()) + } + } + replica := New(f.server.Config, f.db, nil) + w := loginRecorder(replica, " MISSING ", "192.0.2.2:1000", "") + if w.Code != 429 || w.Header().Get("Retry-After") == "" { + t.Fatal("account budget reset", w.Code) + } + if _, err := f.db.Exec("UPDATE login_limits SET window_started=now()-interval '6 minutes'"); err != nil { + t.Fatal(err) + } + w = loginRecorder(replica, "missing", "192.0.2.1:1000", "") + if w.Code != 401 { + t.Fatal("window did not reopen") + } + // Each account is different; spoofed forwarding must not bypass the source budget. + for i := 0; i < 29; i++ { + w = loginRecorder(f.server, fmt.Sprintf("account-%d", i), "192.0.2.1:1000", fmt.Sprintf("203.0.113.%d", i)) + if w.Code != 401 { + t.Fatal(i, w.Code) + } + } + w = loginRecorder(f.server, "last-account", "192.0.2.1:1000", "203.0.113.250") + if w.Code != 429 { + t.Fatal("forged forwarding bypassed limit", w.Code) + } + var wg sync.WaitGroup + var limited atomic.Int32 + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if loginRecorder(f.server, "parallel", "192.0.2.9:1000", "").Code == 429 { + limited.Add(1) + } + }() + } + wg.Wait() + if limited.Load() != 10 { + t.Fatal("non-atomic account budget", limited.Load()) + } + var key string + if err := f.db.Get(&key, "SELECT key FROM login_limits LIMIT 1"); err != nil || len(key) != 64 { + t.Fatal("cleartext login key") + } + // Only the explicitly trusted proxy may supply a client address. + cfg := config.Config{Mode: "demo", JWTSecret: f.server.Config.JWTSecret, TrustedProxies: []string{"10.20.0.2"}} + proxy := New(cfg, f.db, nil) + for i := 0; i < 31; i++ { + w = loginRecorder(proxy, fmt.Sprintf("proxy-%d", i), "10.20.0.2:80", "198.51.100.10") + if i == 30 && w.Code != 429 { + t.Fatal("trusted proxy IP budget ignored") + } + } +} + +type hardwarePVE struct { + mu sync.Mutex + config map[string]any + state string + pending bool + deny bool + lowSpace bool + outcome string + posts int + forms []map[string]string +} + +func fakeHardwarePVE(t *testing.T, f *fixture) *hardwarePVE { + p := &hardwarePVE{state: "stopped", config: map[string]any{"digest": "d1", "cores": 2, "sockets": 1, "memory": 2048, "balloon": 1024, "vcpus": 2, "scsi0": "local:vm-9000,size=4G", "hostpci0": "0000:01:00.0", "cpu": "host", "args": "preserved"}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + var data any + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + switch { + case strings.Contains(path, "/tasks/"): + if p.outcome == "poll-failure" { + w.WriteHeader(503) + return + } + data = map[string]any{"status": "stopped", "exitstatus": "OK"} + case path == "/access/permissions": + yes := 1 + if p.deny { + yes = 0 + } + data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Config.CPU": yes, "VM.Config.Memory": yes, "VM.Config.Disk": yes, "Datastore.AllocateSpace": yes}} + case path == "/nodes/node-a/storage": + space := float64(100 << 30) + if p.lowSpace { + space = 0 + } + data = []map[string]any{{"storage": "local", "avail": space, "active": 1, "enabled": 1}} + case strings.HasSuffix(path, "/status/current"): + data = map[string]any{"status": p.state} + case strings.HasSuffix(path, "/pending"): + data = []map[string]any{} + if p.pending { + data = []map[string]any{{"key": "memory", "pending": 4096}} + } + case strings.HasSuffix(path, "/config") && r.Method == "GET": + if r.URL.Query().Get("current") != "1" { + t.Error("read pending configuration as current") + } + data = p.config + case strings.HasSuffix(path, "/config") || strings.HasSuffix(path, "/resize"): + p.posts++ + _ = r.ParseForm() + form := map[string]string{} + for k := range r.Form { + form[k] = r.Form.Get(k) + } + p.forms = append(p.forms, form) + if p.outcome == "reject" { + w.WriteHeader(403) + return + } + if p.outcome == "ambiguous" { + w.WriteHeader(500) + return + } + if strings.HasSuffix(path, "/resize") { + if r.Method != "PUT" || form["disk"] != "scsi0" || form["size"] != "8G" { + t.Error("unsafe disk resize", r.Method, form) + } + p.config["scsi0"] = "local:vm-9000,size=8G" + } else { + if r.Method != "POST" { + t.Error("wrong config method") + } + for _, key := range []string{"cores", "sockets", "memory", "vcpus"} { + if v := form[key]; v != "" { + var n int + fmt.Sscan(v, &n) + p.config[key] = n + } + } + for key := range form { + if !strings.Contains("|cores|sockets|memory|vcpus|digest|", "|"+key+"|") { + t.Error("non-whitelisted config write", key) + } + } + } + p.config["digest"] = "d2" + data = "UPID:hardware-test" + default: + t.Error("unexpected hardware request", r.Method, path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} + if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + return p +} +func runHardwareTask(t *testing.T, f *fixture, admin string, body map[string]any, key string) model.Task { + code, out := f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, key) + expect(t, 202, code, out) + var task model.Task + if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil { + t.Fatal(err) + } + f.server.execute(context.Background(), task) + if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", task.ID); err != nil { + t.Fatal(err) + } + return task +} +func TestHardwarePreflightTasksAndDiskAbsoluteSize(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeHardwarePVE(t, f) + admin := f.login("admin") + body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"} + endpoint := "/desktops/desk_demo_windows/hardware" + code, out := f.request("GET", endpoint, f.login("viewer"), nil, "") + expect(t, 200, code, out) + code, out = f.request("POST", endpoint+"/check", f.login("operator"), body, "") + expect(t, 403, code, out) + for _, block := range []string{"running", "pending", "lock", "balloon", "digest", "deny"} { + p.mu.Lock() + switch block { + case "running": + p.state = "running" + case "pending": + p.pending = true + case "lock": + p.config["lock"] = "backup" + case "balloon": + p.config["balloon"] = 8192 + case "digest": + p.config["digest"] = "changed" + case "deny": + p.deny = true + } + p.mu.Unlock() + code, out = f.request("POST", endpoint+"/check", admin, body, "") + expect(t, 409, code, out) + p.mu.Lock() + p.state = "stopped" + p.pending = false + delete(p.config, "lock") + p.config["balloon"] = 1024 + p.config["digest"] = "d1" + p.deny = false + p.mu.Unlock() + } + body["cpu"] = "malicious" + code, out = f.request("POST", endpoint, admin, body, "unknown-field") + expect(t, 400, code, out) + delete(body, "cpu") + task := runHardwareTask(t, f, admin, body, "compute-once") + if task.Status != "success" { + t.Fatal(task) + } + var submitted int + if err := f.db.Get(&submitted, "SELECT count(*) FROM audit_logs WHERE action='configure-hardware' AND result='submitted'"); err != nil || submitted != 1 { + t.Fatal("submission audit is not distinct from completion", submitted, err) + } + code, out = f.request("POST", endpoint, admin, body, "compute-once") + expect(t, 202, code, out) + if out["id"] != task.ID { + t.Fatal("replay lost task") + } + body["memoryMiB"] = 8192 + code, out = f.request("POST", endpoint, admin, body, "compute-once") + expect(t, 409, code, out) + p.mu.Lock() + if p.posts != 1 || p.config["hostpci0"] != "0000:01:00.0" || p.config["cpu"] != "host" || p.config["args"] != "preserved" { + t.Fatal("hardware changed twice or passthrough altered") + } + p.mu.Unlock() + disk := map[string]any{"kind": "disk", "disk": "scsi0", "sizeGiB": 8, "digest": "d2"} + p.mu.Lock() + p.lowSpace = true + p.mu.Unlock() + code, out = f.request("POST", endpoint+"/check", admin, disk, "") + expect(t, 409, code, out) + p.mu.Lock() + p.lowSpace = false + p.mu.Unlock() + task = runHardwareTask(t, f, admin, disk, "disk-once") + if task.Status != "success" { + t.Fatal(task) + } + code, out = f.request("POST", endpoint+"/check", admin, disk, "") + expect(t, 409, code, out) +} +func TestHardwareUnknownRestartReconcileAndExpiration(t *testing.T) { + for _, outcome := range []string{"ambiguous", "poll-failure", "reject"} { + t.Run(outcome, func(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeHardwarePVE(t, f) + p.outcome = outcome + admin := f.login("admin") + body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"} + task := runHardwareTask(t, f, admin, body, "unknown") + want := "timeout" + if outcome == "reject" { + want = "failed" + } + if task.Status != want { + t.Fatal(task) + } + if want == "failed" { + return + } + code, out := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "power") + expect(t, 409, code, out) + code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "another") + expect(t, 409, code, out) + // A worker restarted after submitting must never issue the mutation again. + if _, err := f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID); err != nil { + t.Fatal(err) + } + f.server.execute(context.Background(), task) + p.mu.Lock() + if p.posts != 1 { + t.Fatal("hardware replayed") + } + p.outcome = "" + p.mu.Unlock() + code, out = f.request("POST", "/tasks/"+task.ID+"/hardware-reconcile", admin, nil, "") + if outcome == "poll-failure" { + expect(t, 200, code, out) + } else { + expect(t, 409, code, out) + } + }) + } + f := newFixtureWorker(t, false) + fakeHardwarePVE(t, f) + admin := f.login("admin") + body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"} + code, out := f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "expire") + expect(t, 202, code, out) + var task model.Task + _ = f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]) + f.server.finishHardware(task, "", "expired", "") + var stage string + _ = f.db.Get(&stage, "SELECT stage FROM hardware_jobs WHERE task_id=$1", task.ID) + if stage != "failed" { + t.Fatal(stage) + } + claimed, err := f.server.claimHardware(context.Background(), task) + if err != nil || claimed { + t.Fatal("expired task could submit", err) + } + // Power and specification submissions share a target lock and unique active slot. + code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "submit-expire") + expect(t, 202, code, out) + if err = f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil { + t.Fatal(err) + } + code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked-power") + expect(t, 409, code, out) + claimed, err = f.server.claimHardware(context.Background(), task) + if err != nil || !claimed { + t.Fatal("could not claim prepared task", err) + } + f.server.finishHardware(task, "", "expired", "") + if err = f.db.Get(&stage, "SELECT stage FROM hardware_jobs WHERE task_id=$1", task.ID); err != nil || stage != "uncertain" { + t.Fatal(stage, err) + } + // Even if inventory still says running, uncertain specifications block new tickets. + if _, err = f.db.Exec("UPDATE desktops SET power_state='running' WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + if _, err = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("hardware-device")); err != nil { + t.Fatal(err) + } + code, out = f.request("POST", "/terminal/connect-ticket", "hardware-device", map[string]string{"desktopId": "desk_demo_windows"}, "") + expect(t, 409, code, out) + if out["code"] != "hardware_unconfirmed" { + t.Fatal("ticket did not check hardware maintenance", out) + } +} diff --git a/platform/internal/server/gpu.go b/platform/internal/server/gpu.go new file mode 100644 index 0000000..f5f56c6 --- /dev/null +++ b/platform/internal/server/gpu.go @@ -0,0 +1,266 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "vdi-cloud/platform/pkg/proxmox" +) + +type gpuReference struct { + VMID int `json:"vmid"` + VMName string `json:"vmName"` + VMStatus string `json:"vmStatus"` + Template bool `json:"template"` + DesktopID string `json:"desktopId"` + DesktopName string `json:"desktopName"` + Slot string `json:"slot"` + Phase string `json:"phase"` + Source string `json:"source"` + Mapping string `json:"mapping"` + Mediated bool `json:"mediated"` + PendingRemoval bool `json:"pendingRemoval"` +} +type gpuItem struct { + ID string `json:"id"` + Address string `json:"address"` + Name string `json:"name"` + Vendor string `json:"vendor"` + VendorID string `json:"vendorId"` + DeviceID string `json:"deviceId"` + Class string `json:"class"` + IOMMUGroup *int `json:"iommuGroup"` + State string `json:"state"` + References []gpuReference `json:"references"` +} +type gpuIssue struct { + Code string `json:"code"` + VMID int `json:"vmid"` + Message string `json:"message"` +} + +type gpuScan struct { + VM proxmox.Resource + Options []proxmox.PendingOption + Err error +} + +// This reads the PVE configuration view, not the host driver or running QEMU state. +func (s *Server) gpuInventory(c *gin.Context) { + node := c.Query("node") + if node == "" || len(node) > 100 || strings.TrimSpace(node) != node { + s.fail(c, 400, "invalid_node", "请选择计算节点") + return + } + pve := s.currentPVE() + if pve == nil { + s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox,无法读取真实显卡") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second) + defer cancel() + resources, err := pve.Resources(ctx) + if err != nil { + s.fail(c, 502, "pve_unavailable", "无法读取节点和虚拟机清单,请检查 PVE 接入") + return + } + found := false + vms := []proxmox.Resource{} + for _, r := range resources { + if r.Type == "node" && r.Node == node { + found = true + } + if r.Type == "qemu" && r.Node == node { + vms = append(vms, r) + } + } + if !found { + s.fail(c, 404, "node_not_found", "节点不存在或当前 PVE 凭据不可见") + return + } + issues := []gpuIssue{} + issue := func(code string, vmid int, message string) { issues = append(issues, gpuIssue{code, vmid, message}) } + devices, err := pve.PCIDevices(ctx, node) + if err != nil { + s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限") + return + } + items := []gpuItem{} + for _, d := range devices { + if !d.IsGPU() { + continue + } + address, ok := proxmox.NormalizePCIAddress(d.ID) + if !ok { + issue("pci_invalid", 0, "部分显卡 PCI 地址无法识别") + continue + } + group := d.IOMMUGroup + if group != nil && *group < 0 { + group = nil + } + items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, References: []gpuReference{}}) + } + fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit") + if err != nil || !fullVisibility { + issue("vm_visibility_partial", 0, "未确认根路径 VM.Audit 权限,虚拟机清单可能不完整") + } + var desktops []struct { + ID string `db:"id"` + Name string `db:"name"` + VMID int `db:"vmid"` + } + if err := s.DB.SelectContext(ctx, &desktops, "SELECT id,name,vmid FROM desktops WHERE provider='proxmox' AND node=$1 ORDER BY id", node); err != nil { + s.dbError(c, err) + return + } + sort.Slice(vms, func(i, j int) bool { return vms[i].VMID < vms[j].VMID }) + visibleCount := len(vms) + if len(vms) > 200 { + vms = vms[:200] + issue("scan_limit", 0, "本次最多检查 200 台虚拟机,当前节点超出检查范围") + } + scans := make([]gpuScan, len(vms)) + jobs := make(chan int, len(vms)) + for i := range vms { + jobs <- i + } + close(jobs) + var wg sync.WaitGroup + for worker := 0; worker < 4; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range jobs { + options, err := pve.VMPending(ctx, node, vms[i].VMID) + scans[i] = gpuScan{vms[i], options, err} + } + }() + } + wg.Wait() + var mappings []proxmox.PCIMapping + mappingsLoaded := false + scannedCount := 0 + for _, scan := range scans { + if scan.Err != nil { + issue("vm_config_unavailable", scan.VM.VMID, "无法读取虚拟机当前及待应用配置") + continue + } + scannedCount++ + for _, option := range scan.Options { + if option.Key == "args" || option.Key == "hookscript" { + if rawOptionText(option.Value) != "" || rawOptionText(option.Pending) != "" { + issue("custom_vm_config", scan.VM.VMID, "虚拟机包含自定义参数或宿主脚本,额外设备引用未解析") + } + continue + } + if !proxmox.IsHostPCIKey(option.Key) { + continue + } + for _, phase := range []string{"current", "pending"} { + value := option.Value + if phase == "pending" { + value = option.Pending + } + if len(value) == 0 || string(value) == "null" { + continue + } + ref := gpuReference{VMID: scan.VM.VMID, VMName: scan.VM.Name, VMStatus: scan.VM.Status, Template: scan.VM.Template == 1, Slot: option.Key, Phase: phase, Source: "direct", PendingRemoval: phase == "current" && (string(option.Delete) == "1" || string(option.Delete) == "true")} + for _, d := range desktops { + if d.VMID == scan.VM.VMID { + ref.DesktopID, ref.DesktopName = d.ID, d.Name + break + } + } + parsed, err := proxmox.ParseHostPCI(rawOptionText(value)) + if err != nil { + issue("hostpci_unparsed", ref.VMID, "虚拟机直通配置无法解析") + continue + } + ref.Mediated = parsed.Mediated + selectors := parsed.Addresses + if parsed.Mapping != "" { + ref.Source, ref.Mapping = "mapping", parsed.Mapping + if !mappingsLoaded { + mappings, _ = pve.PCIMappings(ctx) + mappingsLoaded = true + } + selectors, err = gpuMappingSelectors(mappings, parsed.Mapping, node) + if err != nil { + issue("mapping_unresolved", ref.VMID, "PCI 映射不可见、配置无效或当前节点未定义") + continue + } + if len(selectors) > 1 { + issue("mapping_candidates", ref.VMID, "PCI 映射包含多个候选设备,仅展示可能的配置引用") + } + } + for i := range items { + for _, selector := range selectors { + if proxmox.PCIAddressMatches(selector, items[i].Address) { + items[i].References = append(items[i].References, ref) + break + } + } + } + } + } + } + for i := range items { + items[i].State = "unreferenced" + if len(issues) != 0 { + items[i].State = "unknown" + } + owners := map[int]bool{} + for _, ref := range items[i].References { + owners[ref.VMID] = true + } + if len(owners) == 1 { + items[i].State = "referenced" + } else if len(owners) > 1 { + items[i].State = "multiple" + } + } + sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address }) + c.JSON(200, gin.H{"node": node, "observedAt": time.Now().UTC(), "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues}) +} + +func rawOptionText(value json.RawMessage) string { + var text string + _ = json.Unmarshal(value, &text) + return text +} + +func gpuMappingSelectors(mappings []proxmox.PCIMapping, id, node string) ([]string, error) { + selectors := []string{} + for _, mapping := range mappings { + if mapping.ID != id { + continue + } + for _, entry := range mapping.Map { + properties, err := proxmox.PCIProperties(entry) + if err != nil { + return nil, err + } + if properties["node"] != node { + continue + } + for _, path := range strings.Split(properties["path"], ";") { + address, ok := proxmox.NormalizePCIAddress(path) + if !ok { + return nil, fmt.Errorf("invalid mapping address") + } + selectors = append(selectors, address) + } + } + } + if len(selectors) == 0 { + return nil, fmt.Errorf("mapping not visible") + } + return selectors, nil +} diff --git a/platform/internal/server/gpu_test.go b/platform/internal/server/gpu_test.go new file mode 100644 index 0000000..6f8b646 --- /dev/null +++ b/platform/internal/server/gpu_test.go @@ -0,0 +1,181 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "vdi-cloud/platform/pkg/proxmox" +) + +func fakeGPUPVE(t *testing.T, f *fixture, mode string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("GPU endpoint attempted write: %s %s", r.Method, r.URL.Path) + w.WriteHeader(500) + return + } + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + var data any + switch path { + case "/cluster/resources": + if mode == "cluster-failed" { + w.WriteHeader(503) + return + } + data = []map[string]any{{"type": "node", "node": "node-a", "name": "node-a", "status": "online"}, {"type": "qemu", "node": "node-a", "vmid": 8000, "name": "managed-windows", "status": "stopped"}, {"type": "qemu", "node": "node-a", "vmid": 8001, "name": "unmanaged-ubuntu", "status": "running"}} + case "/nodes/node-a/hardware/pci": + if mode == "pci-denied" { + w.WriteHeader(403) + return + } + data = []map[string]any{{"id": "0000:00:02.0", "class": "0x030000", "device_name": "Intel GPU", "vendor_name": "Intel", "iommugroup": 0}, {"id": "0000:01:00.0", "class": "0x030200", "device_name": "NVIDIA GPU", "vendor": "0x10de", "iommugroup": 2}, {"id": "0000:01:00.1", "class": "0x040300", "device_name": "Audio"}, {"id": "0000:02:00.0", "class": "0x030000", "device_name": "AMD GPU", "iommugroup": -1}} + case "/access/permissions": + allowed := 1 + if mode == "partial-visibility" { + allowed = 0 + } + data = map[string]any{"/": map[string]int{"VM.Audit": allowed}} + case "/nodes/node-a/qemu/8000/pending": + value := "01:00,pcie=1" + if mode == "mapping" || mode == "mapping-missing" || mode == "mapping-candidates" { + value = "mapping=gpu-map" + } + if mode == "malformed" { + value = "host=invalid,SECRET=do-not-return" + } + rows := []map[string]any{{"key": "hostpci0", "value": value, "pending": "host=02:00.0", "delete": 1}} + if mode == "custom" { + rows = append(rows, map[string]any{"key": "args", "value": "-device vfio-pci,host=00:02.0 SECRET-do-not-return"}) + } + data = rows + case "/nodes/node-a/qemu/8001/pending": + if mode == "config-failed" { + w.WriteHeader(403) + return + } + data = []map[string]any{} + if mode == "multiple" { + data = []map[string]any{{"key": "hostpci0", "value": "0000:01:00.0,mdev=test"}} + } + case "/cluster/mapping/pci": + if mode == "mapping-missing" { + w.WriteHeader(403) + return + } + entries := []string{"node=node-a,path=0000:01:00.0,id=10de:1234", "node=node-b,path=0000:03:00.0,id=10de:1234"} + if mode == "mapping-candidates" { + entries = append(entries, "node=node-a,path=0000:00:02.0,id=8086:1234") + } + data = []map[string]any{{"id": "gpu-map", "map": entries}} + default: + t.Errorf("unexpected GPU PVE read: %s", path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} +} + +func TestGPUInventoryReadOnlyCoverage(t *testing.T) { + for _, mode := range []string{"normal", "partial-visibility", "config-failed", "mapping", "mapping-missing", "mapping-candidates", "custom", "malformed", "multiple"} { + t.Run(mode, func(t *testing.T) { + f := newFixtureWorker(t, false) + fakeGPUPVE(t, f, mode) + if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=8000 WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + code, out := f.request("GET", "/resources/gpus?node=node-a", f.login("viewer"), nil, "") + expect(t, 200, code, out) + raw, _ := json.Marshal(out) + if strings.Contains(string(raw), "SECRET") { + t.Fatal("raw VM config leaked") + } + var view struct { + Complete bool + VisibleVMCount int + ScannedVMCount int + Items []gpuItem + Issues []gpuIssue + } + if err := json.Unmarshal(raw, &view); err != nil { + t.Fatal(err) + } + if len(view.Items) != 3 || view.VisibleVMCount != 2 || view.Items[0].IOMMUGroup == nil || *view.Items[0].IOMMUGroup != 0 || view.Items[2].IOMMUGroup != nil { + t.Fatalf("incorrect inventory %s", raw) + } + complete := mode == "normal" || mode == "mapping" || mode == "multiple" + if view.Complete != complete || (len(view.Issues) == 0) != complete { + t.Fatalf("coverage state %s", raw) + } + if complete && view.Items[0].State != "unreferenced" { + t.Fatalf("unreferenced classification %s", raw) + } + if mode == "config-failed" && view.ScannedVMCount != 1 { + t.Fatalf("failed config counted as scanned %s", raw) + } + if mode == "custom" || mode == "partial-visibility" || mode == "config-failed" || mode == "mapping-missing" || mode == "malformed" { + if view.Items[0].State != "unknown" { + t.Fatalf("unknown classified as free %s", raw) + } + } + if mode == "multiple" { + if view.Items[1].State != "multiple" || len(view.Items[1].References) != 2 { + t.Fatalf("multiple references lost %s", raw) + } + } + if mode == "normal" || mode == "mapping" { + owner := view.Items[1].References[0] + if owner.DesktopID != "desk_demo_windows" || !owner.PendingRemoval || owner.Phase != "current" || owner.VMStatus != "stopped" { + t.Fatalf("owner metadata %s", raw) + } + if view.Items[2].References[0].Phase != "pending" { + t.Fatalf("pending reservation lost %s", raw) + } + if mode == "mapping" && (owner.Source != "mapping" || owner.Mapping != "gpu-map") { + t.Fatalf("mapping owner %s", raw) + } + } + var count int + if err := f.db.Get(&count, "SELECT count(*) FROM tasks"); err != nil || count != 0 { + t.Fatalf("read created tasks: %d %v", count, err) + } + }) + } +} + +func TestGPUInventoryErrorsAndAuthentication(t *testing.T) { + f := newFixtureWorker(t, false) + admin := f.login("admin") + code, out := f.request("GET", "/resources/gpus?node=node-a", "", nil, "") + expect(t, 401, code, out) + code, out = f.request("GET", "/resources/gpus?node=node-a", admin, nil, "") + expect(t, 503, code, out) + for _, mode := range []string{"pci-denied", "cluster-failed", "normal"} { + fakeGPUPVE(t, f, mode) + code, out = f.request("GET", "/resources/gpus?node=node-a", admin, nil, "") + want := 502 + if mode == "normal" { + want = 200 + } + expect(t, want, code, out) + } + code, out = f.request("GET", "/resources/gpus", admin, nil, "") + expect(t, 400, code, out) + code, out = f.request("GET", "/resources/gpus?node=unknown", admin, nil, "") + expect(t, 404, code, out) + code, out = f.request("GET", "/resources/gpus?node=..%2Fsecret", admin, nil, "") + expect(t, 404, code, out) + r := httptest.NewRequest("POST", "/api/v1/resources/gpus?node=node-a", nil) + r.Header.Set("Authorization", "Bearer "+admin) + w := httptest.NewRecorder() + f.server.Router.ServeHTTP(w, r) + if w.Code != 404 { + t.Fatalf("GPU write route exists: %d", w.Code) + } +} diff --git a/platform/internal/server/hardware.go b/platform/internal/server/hardware.go new file mode 100644 index 0000000..852e06a --- /dev/null +++ b/platform/internal/server/hardware.go @@ -0,0 +1,469 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "math" + "strings" + "time" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type hardwareInput struct { + Kind string `json:"kind" binding:"required,oneof=compute disk"` + Cores int `json:"cores"` + Sockets int `json:"sockets"` + MemoryMiB int `json:"memoryMiB"` + Disk string `json:"disk" binding:"max=20"` + SizeGiB int `json:"sizeGiB"` + Digest string `json:"digest" binding:"required,max=80"` +} + +func (s *Server) loadHardware(ctx context.Context, id string, db sqlx.ExtContext) (model.Desktop, proxmox.VMHardware, map[string]any, error) { + var d model.Desktop + if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", id); err != nil { + return d, proxmox.VMHardware{}, nil, err + } + pve := s.currentPVE() + if pve == nil || d.Provider != "proxmox" { + return d, proxmox.VMHardware{}, nil, errors.New("仅支持已接入 Proxmox 的真实桌面") + } + h, config, err := pve.Hardware(ctx, d.Node, d.VMID) + return d, h, config, err +} +func (s *Server) desktopHardware(c *gin.Context) { + _, h, _, err := s.loadHardware(c.Request.Context(), c.Param("id"), s.DB) + if err != nil { + s.fail(c, 502, "hardware_unavailable", "无法读取真实规格,请检查 PVE 的 VM.Audit 权限和连接") + return + } + if h.PowerState != "stopped" { + h.Blockers = append(h.Blockers, "请先正常关闭桌面,再修改规格") + } + unresolved, err := maintenanceBlocked(c.Request.Context(), s.DB, c.Param("id"), "", true) + if err != nil { + s.dbError(c, err) + return + } + if unresolved { + h.Blockers = append(h.Blockers, "有桌面维护任务正在执行或结果未确认,请先核对任务") + } + c.JSON(200, h) +} +func (s *Server) hardwarePreflight(ctx context.Context, id string, in hardwareInput, db sqlx.ExtContext, ownTask string) (model.Desktop, proxmox.VMHardware, map[string]any, error) { + d, h, config, err := s.loadHardware(ctx, id, db) + if err != nil { + return d, h, config, errors.New("无法读取 PVE 规格") + } + reject := func(message string) (model.Desktop, proxmox.VMHardware, map[string]any, error) { + return d, h, config, errors.New(message) + } + if h.PowerState != "stopped" { + return reject("桌面仍在运行,请先正常关机") + } + if len(h.Blockers) > 0 { + return reject(strings.Join(h.Blockers, ";")) + } + if h.Digest != in.Digest { + return reject("PVE 配置已变化,请重新读取规格") + } + var busy bool + busy, err = maintenanceBlocked(ctx, db, id, ownTask, true) + if err != nil { + return d, h, config, err + } + var connected bool + if err = sqlx.GetContext(ctx, db, &connected, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil { + return d, h, config, err + } + busy = busy || connected + if busy { + return reject("桌面有活动连接、执行中任务或未确认规格变更") + } + pve := s.currentPVE() + permissions := []string{} + if in.Kind == "compute" { + if in.Cores < 1 || in.Cores > 256 || in.Sockets < 1 || in.Sockets > 8 || in.Cores*in.Sockets > 1024 || in.MemoryMiB < 512 || in.MemoryMiB > 1048576 || in.MemoryMiB%128 != 0 || in.Disk != "" || in.SizeGiB != 0 { + return reject("CPU 或内存规格无效;内存按 128 MiB 递增") + } + if in.MemoryMiB < h.Balloon { + return reject("目标内存低于当前 balloon 最小内存,请先在 PVE 调整") + } + if in.Cores != h.Cores || in.Sockets != h.Sockets { + permissions = append(permissions, "VM.Config.CPU") + } + if in.MemoryMiB != h.MemoryMiB { + permissions = append(permissions, "VM.Config.Memory") + } + if len(permissions) == 0 { + return reject("规格没有变化") + } + } else if in.Kind == "disk" { + if in.Cores != 0 || in.Sockets != 0 || in.MemoryMiB != 0 || in.SizeGiB < 1 || in.SizeGiB > 65536 { + return reject("磁盘目标容量无效") + } + var disk *proxmox.VMDisk + for i := range h.Disks { + if h.Disks[i].Key == in.Disk { + disk = &h.Disks[i] + } + } + if disk == nil || float64(in.SizeGiB) <= disk.SizeGiB { + return reject("只能扩容已识别的虚拟磁盘,不能缩小或保持原容量") + } + ok, e := pve.HasPermission(ctx, "/storage/"+disk.Storage, "Datastore.AllocateSpace") + if e != nil || !ok { + return reject("缺少磁盘存储分配权限") + } + storages, e := pve.Storages(ctx, d.Node) + if e != nil { + return reject("无法核查节点存储余量") + } + available := false + for _, st := range storages { + if st.Name == disk.Storage && st.Active == 1 && st.Enabled == 1 && st.Available >= (float64(in.SizeGiB)-disk.SizeGiB+1)*(1<<30) { + available = true + } + } + if !available { + return reject("存储不足或不可用;需扩容量加 1 GiB 余量") + } + permissions = append(permissions, "VM.Config.Disk") + } else { + return reject("规格操作类型无效") + } + for _, permission := range permissions { + ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), permission) + if e != nil || !ok { + return reject("缺少 " + permission + " 权限") + } + } + return d, h, config, nil +} +func (s *Server) checkHardware(c *gin.Context) { + var in hardwareInput + if !s.decodeConfiguration(c, &in) { + return + } + _, _, _, err := s.hardwarePreflight(c.Request.Context(), c.Param("id"), in, s.DB, "") + if err != nil { + s.fail(c, 409, "hardware_not_ready", err.Error()) + return + } + c.JSON(200, gin.H{"available": true, "message": "规格、状态、权限与存储预检通过"}) +} +func (s *Server) queueHardware(c *gin.Context) { + var in hardwareInput + if !s.decodeConfiguration(c, &in) { + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if key == "" || len(key) > 100 { + s.fail(c, 400, "idempotency_required", "请携带有效请求标识") + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + ctx := c.Request.Context() + if !s.lockAuthorization(c, tx) { + return + } + raw, _ := json.Marshal(in) + hash := store.Hash(string(raw)) + var old struct { + TaskID string `db:"task_id"` + Hash string `db:"request_hash"` + } + err := tx.GetContext(ctx, &old, "SELECT h.task_id,h.request_hash FROM hardware_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.actor=$1 AND t.idempotency_key=$2", actor.Username, key) + if err == nil { + var task model.Task + if old.Hash != hash { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他规格参数") + return + } + if e := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND target_id=$2", old.TaskID, c.Param("id")); e != nil { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他桌面") + return + } + c.JSON(202, task) + return + } + if !errors.Is(err, sql.ErrNoRows) { + s.dbError(c, err) + return + } + // A target row lock serializes specifications against desktop maintenance. + var target string + if err = tx.GetContext(ctx, &target, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + d, _, _, err := s.hardwarePreflight(ctx, target, in, tx, "") + if err != nil { + s.fail(c, 409, "hardware_not_ready", err.Error()) + return + } + id := store.ID("task_") + action := "configure-hardware" + if in.Kind == "disk" { + action = "expand-disk" + } + var task model.Task + err = tx.GetContext(ctx, &task, "INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'hardware',$2,$3,$4,$5,'queued','已提交,执行前会再次预检',$6) RETURNING *", id, action, d.ID, d.Name, actor.Username, key) + if conflict(err) { + s.fail(c, 409, "task_conflict", "该桌面有其他任务或请求标识已使用") + return + } + if err == nil { + _, err = tx.ExecContext(ctx, "INSERT INTO hardware_jobs(task_id,request_json,request_hash) VALUES($1,$2,$3)", id, string(raw), hash) + } + if err == nil { + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, action, d.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(202, task) +} +func hardwareMatches(h proxmox.VMHardware, in hardwareInput) bool { + if len(h.Blockers) > 0 { + return false + } + if in.Kind == "compute" { + return h.Cores == in.Cores && h.Sockets == in.Sockets && h.MemoryMiB == in.MemoryMiB + } + for _, d := range h.Disks { + if d.Key == in.Disk { + return math.Abs(d.SizeGiB-float64(in.SizeGiB)) < 0.001 + } + } + return false +} +func (s *Server) executeHardware(parent context.Context, task model.Task) { + ctx, cancel := context.WithTimeout(parent, 30*time.Minute) + defer cancel() + var job struct { + Raw string `db:"request_json"` + Stage string `db:"stage"` + } + err := s.DB.GetContext(ctx, &job, "SELECT request_json,stage FROM hardware_jobs WHERE task_id=$1", task.ID) + var in hardwareInput + if err == nil { + err = json.Unmarshal([]byte(job.Raw), &in) + } + submitted := job.Stage != "prepared" + pve := s.currentPVE() + if e := s.RefreshCompute(ctx); err == nil && e != nil { + err = e + } + pve = s.currentPVE() + var d model.Desktop + if err == nil { + err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", task.TargetID) + } + if err == nil && pve == nil { + err = errors.New("计算平台未配置") + } + if err == nil && !submitted { + var config map[string]any + d, _, config, err = s.hardwarePreflight(ctx, d.ID, in, s.DB, task.ID) + if err == nil { + var claimed bool + claimed, err = s.claimHardware(ctx, task) + if err == nil && !claimed { + return + } + + if err == nil { + submitted = true + if in.Kind == "compute" { + task.ProviderRef, err = pve.ConfigureHardware(ctx, d.Node, d.VMID, in.Cores, in.Sockets, in.MemoryMiB, in.Digest, config) + } else { + task.ProviderRef, err = pve.ResizeDisk(ctx, d.Node, d.VMID, in.Disk, in.SizeGiB, in.Digest) + } + var rejected *proxmox.HTTPError + if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { + submitted = false + } + if err == nil { + _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", task.ProviderRef, task.ID) + } + } + } + } + if err == nil && submitted && task.ProviderRef == "" { + err = errors.New("原 PVE 任务标识缺失,需人工核查") + } + if err == nil && task.ProviderRef != "" { + err = pve.Wait(ctx, d.Node, task.ProviderRef, time.Second) + } + if err == nil { + var h proxmox.VMHardware + h, _, err = pve.Hardware(ctx, d.Node, d.VMID) + if err == nil && !hardwareMatches(h, in) { + err = errors.New("实际规格尚未达到目标,请核对 PVE") + } + } + status, stage, message := "success", "completed", "PVE 任务与实际规格已核对完成;磁盘扩容后需在客体内扩展分区和文件系统" + if in.Kind == "compute" { + message = "PVE 任务与实际 CPU/内存规格已核对完成" + } + if err != nil { + status, stage, message = "failed", "failed", "规格变更未提交,请检查关机状态、权限、容量与配置是否变化" + if submitted { + status, stage, message = "timeout", "uncertain", "规格变更结果未确认,已阻止后续修改;请核对原任务,不会自动重发" + } + } + s.finishHardware(task, status, stage, message) +} + +// Lock the desktop before changing job/task state, matching power submissions. +func (s *Server) claimHardware(ctx context.Context, task model.Task) (bool, error) { + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var id string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil { + return false, err + } + result, err := tx.ExecContext(ctx, `UPDATE hardware_jobs SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND status IN ('queued','running'))`, task.ID) + if err != nil { + return false, err + } + n, _ := result.RowsAffected() + return n == 1, tx.Commit() +} +func (s *Server) finishHardware(task model.Task, status, stage, message string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return + } + defer tx.Rollback() + var id, current, jobStage string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil { + return + } + if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", task.ID); err != nil || (current != "queued" && current != "running") { + return + } + if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM hardware_jobs WHERE task_id=$1 FOR UPDATE", task.ID); err != nil { + return + } + if stage == "expired" { + status, stage, message = "failed", "failed", "规格任务在提交前过期,未发送修改" + if jobStage != "prepared" { + status, stage, message = "timeout", "uncertain", "规格变更结果未确认;请核对原任务,不会自动重发" + } + } + if _, err = tx.ExecContext(ctx, "UPDATE hardware_jobs SET stage=$1 WHERE task_id=$2", stage, task.ID); err == nil { + err = s.completeTx(ctx, tx, task, status, message) + } + if err == nil { + _ = tx.Commit() + } +} + +func (s *Server) reconcileHardware(c *gin.Context) { + ctx := c.Request.Context() + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + var task model.Task + var in hardwareInput + var raw string + if err := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND kind='hardware'", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + var locked string + if err := tx.GetContext(ctx, &locked, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil { + s.dbError(c, err) + return + } + if err := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", task.ID); err != nil { + s.dbError(c, err) + return + } + if task.Status != "timeout" { + s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的规格任务") + return + } + if err := tx.GetContext(ctx, &raw, "SELECT request_json FROM hardware_jobs WHERE task_id=$1", task.ID); err != nil { + s.dbError(c, err) + return + } + if json.Unmarshal([]byte(raw), &in) != nil { + s.fail(c, 500, "invalid_job", "任务记录无法读取") + return + } + d, h, _, err := s.loadHardware(ctx, task.TargetID, tx) + if err != nil { + s.fail(c, 502, "hardware_unavailable", "无法读取实际规格") + return + } + if task.ProviderRef == "" { + s.fail(c, 409, "hardware_unconfirmed", "原任务标识缺失,请在 PVE 核查并由管理员离线恢复;未重新发送修改") + return + } + if task.ProviderRef != "" { + status, e := s.currentPVE().Task(ctx, d.Node, task.ProviderRef) + if e != nil || status.Status != "stopped" { + s.fail(c, 409, "hardware_unconfirmed", "原 PVE 任务仍在执行或无法查询") + return + } + } + if !hardwareMatches(h, in) { + s.fail(c, 409, "hardware_unconfirmed", "实际规格未达到目标,请在 PVE 核对;未重新提交修改") + return + } + _, err = tx.ExecContext(ctx, "UPDATE hardware_jobs SET stage='completed' WHERE task_id=$1 AND stage='uncertain'", task.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对确认实际规格已达到目标,未重新提交修改',updated_at=now() WHERE id=$1 AND status='timeout'", task.ID) + } + if err == nil { + err = accountAudit(ctx, tx, actor.Username, "reconcile_hardware", task.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true}) +} + +// Tickets cannot connect to a desktop while specifications are changing or unknown. +func (s *Server) hardwareAllowsConnection(c *gin.Context, id string) bool { + var blocked bool + err := deviceTx(c).GetContext(c.Request.Context(), &blocked, `SELECT EXISTS(SELECT 1 FROM tasks WHERE target_id=$1 AND kind IN ('hardware','snapshot','batch-desktop') AND status IN ('queued','running')) OR EXISTS(SELECT 1 FROM hardware_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain')) OR EXISTS(SELECT 1 FROM snapshot_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain')) OR EXISTS(SELECT 1 FROM power_batch_items h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain'))`, id) + if err != nil { + s.dbError(c, err) + return false + } + if blocked { + s.fail(c, 409, "hardware_unconfirmed", "桌面维护中或结果未确认,请联系管理员核对") + return false + } + return true +} diff --git a/platform/internal/server/imports.go b/platform/internal/server/imports.go new file mode 100644 index 0000000..5c1403d --- /dev/null +++ b/platform/internal/server/imports.go @@ -0,0 +1,388 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/csv" + "encoding/hex" + "encoding/json" + "io" + "strings" + "unicode" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "vdi-cloud/platform/internal/store" +) + +type importInput struct { + Kind string `json:"kind" binding:"required,oneof=users terminals bindings"` + CSV string `json:"csv" binding:"required,max=524288"` + PreviewToken string `json:"previewToken"` +} +type importRow struct { + Line int `json:"line"` + Key string `json:"key"` + Values map[string]string `json:"values"` + Errors []string `json:"errors"` + userID, terminalID string + desktops []string + state string +} +type importPreview struct { + Kind string `json:"kind"` + TotalRows int `json:"totalRows"` + ValidRows int `json:"validRows"` + ErrorRows int `json:"errorRows"` + PreviewToken string `json:"previewToken"` + Rows []importRow `json:"rows"` +} +type importItem struct { + Line int `json:"line"` + Key string `json:"key"` + ID string `json:"id"` +} +type activationItem struct { + SN string `json:"sn"` + ActivationSecret string `json:"activationSecret"` +} +type importResult struct { + ID string `json:"id"` + Kind string `json:"kind"` + Count int `json:"count"` + Items []importItem `json:"items"` + ActivationSecrets []activationItem `json:"activationSecrets"` + Replayed bool `json:"replayed"` +} + +func importText(v string, min, max int) bool { + n := utf8.RuneCountInString(v) + return n >= min && n <= max && !strings.ContainsFunc(v, func(r rune) bool { return unicode.IsControl(r) }) +} +func parseImport(in importInput) ([]importRow, string) { + if !utf8.ValidString(in.CSV) || len(in.CSV) > 524288 { + return nil, "请使用不超过 512 KiB 的 UTF-8 CSV 文件" + } + r := csv.NewReader(strings.NewReader(strings.TrimPrefix(in.CSV, "\ufeff"))) + r.FieldsPerRecord = -1 + header, err := r.Read() + if err != nil { + return nil, "无法读取 CSV 表头" + } + required := map[string][]string{"users": {"username", "displayName"}, "terminals": {"sn", "name", "group"}, "bindings": {"username", "terminalSn", "desktopIds"}}[in.Kind] + if len(header) != len(required) { + return nil, "CSV 表头必须为:" + strings.Join(required, ",") + } + positions := map[string]int{} + for i, h := range header { + h = strings.TrimSpace(h) + if _, ok := positions[h]; ok { + return nil, "CSV 表头重复" + } + positions[h] = i + } + for _, h := range required { + if _, ok := positions[h]; !ok { + return nil, "CSV 表头必须为:" + strings.Join(required, ",") + } + } + rows := []importRow{} + seen := map[string]bool{} + seenSN := map[string]bool{} + for { + cells, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, "CSV 引号或字段格式错误,请检查文件" + } + if len(rows) >= 200 { + return nil, "每次最多导入 200 行,请拆分文件" + } + line, _ := r.FieldPos(0) + row := importRow{Line: line, Values: map[string]string{}, Errors: []string{}} + if len(cells) != len(header) { + row.Errors = append(row.Errors, "字段数量与表头不一致") + rows = append(rows, row) + continue + } + for h, i := range positions { + row.Values[h] = strings.TrimSpace(cells[i]) + } + row.Key = row.Values["username"] + if in.Kind == "terminals" { + row.Key = row.Values["sn"] + } + if seen[row.Key] { + row.Errors = append(row.Errors, "文件内账号或 SN 重复") + } + seen[row.Key] = true + if in.Kind == "users" { + if !importText(row.Key, 2, 80) || !importText(row.Values["displayName"], 1, 80) { + row.Errors = append(row.Errors, "账号需 2~80 字符,姓名需 1~80 字符,不能包含控制字符") + } + } else if in.Kind == "terminals" { + if !importText(row.Key, 3, 100) || !importText(row.Values["name"], 1, 80) || !importText(row.Values["group"], 0, 80) { + row.Errors = append(row.Errors, "SN 需 3~100 字符,名称需 1~80 字符,分组最多 80 字符,不能包含控制字符") + } + } else { + sn := row.Values["terminalSn"] + if !importText(row.Key, 2, 80) || !importText(sn, 0, 100) { + row.Errors = append(row.Errors, "用户账号或终端 SN 格式无效") + } + if sn != "" { + if seenSN[sn] { + row.Errors = append(row.Errors, "文件内同一终端不能分配给多位用户") + } + seenSN[sn] = true + } + if row.Values["desktopIds"] != "" { + row.desktops = uniqueIDs(strings.Split(row.Values["desktopIds"], ";")) + for _, id := range row.desktops { + if !importText(id, 1, 100) { + row.Errors = append(row.Errors, "桌面 ID 格式无效,多个 ID 使用英文分号分隔") + break + } + } + } + if len(row.desktops) > 100 { + row.Errors = append(row.Errors, "每位用户最多追加 100 台桌面") + } + if sn == "" && len(row.desktops) == 0 { + row.Errors = append(row.Errors, "终端 SN 和桌面 ID 至少填写一项") + } + } + rows = append(rows, row) + } + if len(rows) == 0 { + return nil, "CSV 没有数据行" + } + return rows, "" +} +func (s *Server) validateImport(c *gin.Context, tx *sqlx.Tx, in importInput) (importPreview, bool) { + rows, message := parseImport(in) + if message != "" { + s.fail(c, 400, "invalid_csv", message) + return importPreview{}, false + } + ctx := c.Request.Context() + state := []string{} + for i := range rows { + row := &rows[i] + if len(row.Errors) > 0 { + continue + } + if in.Kind == "users" || in.Kind == "terminals" { + var exists bool + query := "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)" + if in.Kind == "terminals" { + query = "SELECT EXISTS(SELECT 1 FROM terminals WHERE sn=$1)" + } + if err := tx.GetContext(ctx, &exists, query, row.Key); err != nil { + s.dbError(c, err) + return importPreview{}, false + } + if exists { + row.Errors = append(row.Errors, "该账号或 SN 已存在;导入仅新增,不覆盖现有资料") + } + } else { + var u struct { + ID string `db:"id"` + TerminalID string `db:"terminal_id"` + Enabled bool `db:"enabled"` + } + err := tx.GetContext(ctx, &u, "SELECT id,COALESCE(terminal_id,'') terminal_id,enabled FROM users WHERE username=$1", row.Key) + if err == sql.ErrNoRows { + row.Errors = append(row.Errors, "用户不存在,请先导入用户") + continue + } + if err != nil { + s.dbError(c, err) + return importPreview{}, false + } + row.userID = u.ID + oldDesktops := []string{} + if err = tx.SelectContext(ctx, &oldDesktops, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", u.ID); err != nil { + s.dbError(c, err) + return importPreview{}, false + } + snapshot, _ := json.Marshal([]any{u.ID, u.TerminalID, u.Enabled, oldDesktops}) + row.state = string(snapshot) + if sn := row.Values["terminalSn"]; sn != "" { + var t struct { + ID string `db:"id"` + Enabled bool `db:"enabled"` + Owner string `db:"owner"` + } + err = tx.GetContext(ctx, &t, "SELECT t.id,t.enabled,COALESCE(u.id,'') owner FROM terminals t LEFT JOIN users u ON u.terminal_id=t.id WHERE t.sn=$1", sn) + if err == sql.ErrNoRows { + row.Errors = append(row.Errors, "终端 SN 不存在") + } else if err != nil { + s.dbError(c, err) + return importPreview{}, false + } else { + if !t.Enabled { + row.Errors = append(row.Errors, "终端已停用") + } + if t.Owner != "" && t.Owner != u.ID { + row.Errors = append(row.Errors, "该终端已关联其他用户") + } + if u.TerminalID != "" && u.TerminalID != t.ID { + row.Errors = append(row.Errors, "该用户已关联其他终端,请在用户页面明确换绑") + } + row.terminalID = t.ID + row.state += "|" + t.ID + } + } + if len(row.desktops) > 0 { + var count int + if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", row.desktops); err != nil { + s.dbError(c, err) + return importPreview{}, false + } + if count != len(row.desktops) { + row.Errors = append(row.Errors, "存在未纳管的桌面 ID") + } + } + } + state = append(state, row.state) + } + out := importPreview{Kind: in.Kind, TotalRows: len(rows), Rows: rows} + for _, row := range rows { + if len(row.Errors) == 0 { + out.ValidRows++ + } else { + out.ErrorRows++ + } + } + if out.ErrorRows == 0 { + raw, _ := json.Marshal([]any{in.Kind, in.CSV, state}) + mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret)) + mac.Write(raw) + out.PreviewToken = hex.EncodeToString(mac.Sum(nil)) + } + return out, true +} +func (s *Server) previewImport(c *gin.Context) { + var in importInput + if !s.decode(c, &in) { + return + } + tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if out, ok := s.validateImport(c, tx, in); ok { + c.JSON(200, out) + } +} +func (s *Server) commitImport(c *gin.Context) { + var in importInput + if !s.decode(c, &in) { + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if key == "" || len(key) > 100 || len(in.PreviewToken) != 64 { + s.fail(c, 400, "invalid_request", "请先完成预检,并携带有效请求标识") + return + } + raw, _ := json.Marshal([]string{in.Kind, in.CSV}) + hash := store.Hash(string(raw)) + ctx := c.Request.Context() + actor := s.admin(c) + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + var previous struct { + Hash string `db:"request_hash"` + Result []byte `db:"result"` + } + err = tx.GetContext(ctx, &previous, "SELECT request_hash,result FROM import_batches WHERE actor_id=$1 AND idempotency_key=$2", actor.ID, key) + if err == nil { + if hash != previous.Hash { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他导入内容") + return + } + var out importResult + if err = json.Unmarshal(previous.Result, &out); err != nil { + s.dbError(c, err) + return + } + out.Replayed = true + c.JSON(200, out) + return + } + if err != sql.ErrNoRows { + s.dbError(c, err) + return + } + err = nil + preview, ok := s.validateImport(c, tx, in) + if !ok { + return + } + if preview.ErrorRows > 0 || !hmac.Equal([]byte(preview.PreviewToken), []byte(in.PreviewToken)) { + s.fail(c, 409, "import_changed", "数据或授权状态已变化,请重新预检;本次没有导入任何行") + return + } + out := importResult{ID: store.ID("import_"), Kind: in.Kind, Count: len(preview.Rows), Items: []importItem{}, ActivationSecrets: []activationItem{}} + for _, row := range preview.Rows { + id := row.userID + switch in.Kind { + case "users": + id = store.ID("user_") + _, err = tx.ExecContext(ctx, "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, row.Key, row.Values["displayName"]) + case "terminals": + id = store.ID("term_") + secret := store.ID("activate_") + _, err = tx.ExecContext(ctx, "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, row.Key, row.Values["name"], row.Values["group"], store.Hash(secret)) + out.ActivationSecrets = append(out.ActivationSecrets, activationItem{row.Key, secret}) + case "bindings": + if row.terminalID != "" { + _, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1 WHERE id=$2", row.terminalID, row.userID) + } + if err == nil && len(row.desktops) > 0 { + _, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", row.userID, row.desktops) + } + 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)", row.userID) + } + } + if conflict(err) { + s.fail(c, 409, "import_changed", "数据已变化,请重新预检;本次没有导入任何行") + return + } + if err != nil { + s.dbError(c, err) + return + } + out.Items = append(out.Items, importItem{row.Line, row.Key, id}) + } + // Keep the reusable result, never plaintext activation credentials or source CSV. + safe := out + safe.ActivationSecrets = []activationItem{} + result, _ := json.Marshal(safe) + _, err = tx.ExecContext(ctx, "INSERT INTO import_batches(id,actor_id,kind,row_count,idempotency_key,request_hash,result) VALUES($1,$2,$3,$4,$5,$6,$7)", out.ID, actor.ID, in.Kind, out.Count, key, hash, string(result)) + if err == nil { + err = accountAudit(ctx, tx, actor.Username, "import_"+in.Kind, out.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, out) +} diff --git a/platform/internal/server/lifecycle.go b/platform/internal/server/lifecycle.go new file mode 100644 index 0000000..b1f424d --- /dev/null +++ b/platform/internal/server/lifecycle.go @@ -0,0 +1,263 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "strings" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" +) + +const terminalListSQL = `SELECT id,sn,name,group_name,enabled, + CASE WHEN NOT enabled THEN 'disabled' WHEN status='unregistered' THEN 'unregistered' + WHEN last_seen>now()-interval '90 seconds' THEN 'online' ELSE 'offline' END status, + agent_version,last_seen,CASE WHEN enabled AND last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id, + session_state,config_version,fps,rtt FROM terminals` + +// Only authorization and identity changes share this lock. Device heartbeats and +// command delivery use their own terminal row and remain independent. +func (s *Server) lockAuthorization(c *gin.Context, tx *sqlx.Tx) bool { + if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(7651092029)"); err != nil { + s.dbError(c, err) + return false + } + return true +} + +func (s *Server) updateTerminal(c *gin.Context) { + var in struct { + Name string `json:"name" binding:"required,max=80"` + Group string `json:"group" binding:"max=80"` + } + if !s.decode(c, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + in.Group = strings.TrimSpace(in.Group) + if in.Name == "" { + s.fail(c, 400, "invalid_request", "终端名称不能为空") + return + } + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + var id string + if err = tx.GetContext(c.Request.Context(), &id, "UPDATE terminals SET name=$1,group_name=$2 WHERE id=$3 RETURNING id", in.Name, in.Group, c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "update_terminal", id, c.GetString("requestId")); err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true}) +} + +func (s *Server) disableTerminal(c *gin.Context) { s.resetTerminalIdentity(c, false) } +func (s *Server) reactivateTerminal(c *gin.Context) { s.resetTerminalIdentity(c, true) } +func (s *Server) resetTerminalIdentity(c *gin.Context, reactivate bool) { + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + var state struct { + ID string `db:"id"` + Enabled bool `db:"enabled"` + Active bool `db:"active"` + } + err = tx.GetContext(ctx, &state, "SELECT id,enabled,(session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') active FROM terminals WHERE id=$1 FOR UPDATE", c.Param("id")) + if err != nil { + s.dbError(c, err) + return + } + if !state.Enabled && !reactivate { + c.JSON(200, gin.H{"accepted": true}) + return + } + if state.Active { + s.fail(c, 409, "terminal_in_use", "终端有活动会话或连接预约,请先结束或重置会话") + return + } + var pending bool + if err = tx.GetContext(ctx, &pending, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='terminal' AND target_id=$1 AND status IN ('queued','running'))", state.ID); err != nil { + s.dbError(c, err) + return + } + if pending { + s.fail(c, 409, "terminal_task_pending", "终端有未完成命令,请等待回执或核对超时结果") + return + } + secret, bootstrap, status, action := "", "", "disabled", "disable_terminal" + if reactivate { + secret = store.ID("activate_") + bootstrap = store.Hash(secret) + status = "unregistered" + action = "reactivate_terminal" + } + _, err = tx.ExecContext(ctx, `UPDATE terminals SET enabled=$1,status=$2,token_hash=NULL,bootstrap_hash=$3, + last_seen=NULL,agent_version='',fps=NULL,rtt=NULL,session_id='',session_state='idle',session_desktop_id=NULL WHERE id=$4`, reactivate, status, bootstrap, state.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1", state.ID) + } + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM terminal_policy_capabilities WHERE terminal_id=$1", state.ID) + } + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM terminal_metrics_current WHERE terminal_id=$1", state.ID) + } + if err == nil { + err = closeSessionRecords(ctx, tx, state.ID, "identity_reset") + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE terminal_policy_deliveries SET status='pending',error_code='',acknowledged_at=NULL WHERE terminal_id=$1 AND config_version=(SELECT config_version FROM terminals WHERE id=$1)`, state.ID) + } + if err == nil { + err = accountAudit(ctx, tx, s.admin(c).Username, action, state.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + if reactivate { + c.JSON(200, gin.H{"id": state.ID, "activationSecret": secret}) + } else { + c.JSON(200, gin.H{"accepted": true}) + } +} + +func (s *Server) updateUser(c *gin.Context) { + var in struct { + DisplayName string `json:"displayName" binding:"required,max=80"` + Enabled *bool `json:"enabled" binding:"required"` + } + if !s.decode(c, &in) { + return + } + in.DisplayName = strings.TrimSpace(in.DisplayName) + if in.DisplayName == "" { + s.fail(c, 400, "invalid_request", "用户姓名不能为空") + return + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + var user model.User + err = tx.GetContext(ctx, &user, "UPDATE users SET display_name=$1,enabled=$2 WHERE id=$3 RETURNING *", in.DisplayName, *in.Enabled, c.Param("id")) + if err != nil { + s.dbError(c, err) + return + } + if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1 AND consumed_at IS NULL", user.TerminalID); err != nil { + s.dbError(c, err) + return + } + user.DesktopIDs = []string{} + if err = tx.SelectContext(ctx, &user.DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", user.ID); err != nil { + s.dbError(c, err) + return + } + if err = accountAudit(ctx, tx, s.admin(c).Username, "update_user", user.ID, c.GetString("requestId")); err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, user) +} + +func uniqueIDs(items []string) []string { + result := []string{} + seen := map[string]bool{} + for _, id := range items { + if !seen[id] { + seen[id] = true + result = append(result, id) + } + } + return result +} +func (s *Server) batchBindings(c *gin.Context) { + var in struct { + UserIDs []string `json:"userIds" binding:"required,min=1,max=100,dive,required,max=100"` + DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=100,dive,required,max=100"` + Action string `json:"action" binding:"required,oneof=grant revoke"` + } + if !s.decode(c, &in) { + return + } + in.UserIDs = uniqueIDs(in.UserIDs) + in.DesktopIDs = uniqueIDs(in.DesktopIDs) + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + var ids []string + if err = tx.SelectContext(ctx, &ids, "SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.UserIDs); err != nil { + s.dbError(c, err) + return + } + if len(ids) != len(in.UserIDs) { + s.fail(c, 400, "invalid_user", "所选用户不存在,请刷新列表") + return + } + var count int + if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", in.DesktopIDs); err != nil { + s.dbError(c, err) + return + } + if count != len(in.DesktopIDs) { + s.fail(c, 400, "invalid_desktop", "所选桌面不存在,请刷新列表") + return + } + for _, id := range ids { + if in.Action == "grant" { + _, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", id, in.DesktopIDs) + } else { + _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=ANY($2)", id, in.DesktopIDs) + } + 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)", id) + } + if err == nil { + err = accountAudit(ctx, tx, s.admin(c).Username, "batch_"+in.Action+"_bindings", id, c.GetString("requestId")) + } + if err != nil { + s.dbError(c, err) + return + } + } + if err = tx.Commit(); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true, "userCount": len(ids)}) +} diff --git a/platform/internal/server/lifecycle_test.go b/platform/internal/server/lifecycle_test.go new file mode 100644 index 0000000..8138e0d --- /dev/null +++ b/platform/internal/server/lifecycle_test.go @@ -0,0 +1,220 @@ +package server + +import ( + "context" + "encoding/json" + "strings" + "testing" + "vdi-cloud/platform/internal/store" +) + +func lifecycleDevice(t *testing.T, f *fixture, admin, sn string) (string, string, string) { + t.Helper() + status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": "测试设备", "group": "原分组"}, "") + expect(t, 201, status, out) + id, secret := out["id"].(string), out["activationSecret"].(string) + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "") + expect(t, 200, status, out) + return id, out["accessToken"].(string), secret +} +func lifecycleBind(t *testing.T, f *fixture, admin, id string) { + t.Helper() + if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100',power_state='running' WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows"}, "enabled": true}, "") + expect(t, 200, status, out) +} + +func TestTerminalLifecycleRevokesIdentityAndTickets(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + id, device, originalSecret := lifecycleDevice(t, f, admin, "LIFECYCLE-001") + lifecycleBind(t, f, admin, id) + status, out := f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " 新设备名 ", "group": " 财务 "}, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") + expect(t, 201, status, out) + ticket := out["ticket"].(string) + status, out = f.request("POST", "/terminals/"+id+"/disable", admin, nil, "") + expect(t, 200, status, out) + for _, path := range []string{"/terminal/config", "/terminal/desktops", "/terminal/commands"} { + status, out = f.request("GET", path, device, nil, "") + expect(t, 401, status, out) + } + status, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "stale"}, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": originalSecret, "agentVersion": "stale"}, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "disabled-command") + expect(t, 409, status, out) + status, out = f.request("GET", "/terminals", admin, nil, "") + expect(t, 200, status, out) + var found bool + for _, raw := range out["items"].([]any) { + item := raw.(map[string]any) + if item["id"] == id { + found = true + if item["status"] != "disabled" || item["enabled"] != false || item["name"] != "新设备名" || item["group"] != "财务" { + t.Fatal("incorrect terminal state", item) + } + } + } + if !found { + t.Fatal("terminal disappeared") + } + if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil { + t.Fatal(err) + } + var enabled bool + if err := f.db.Get(&enabled, "SELECT enabled FROM terminals WHERE id=$1", id); err != nil || enabled { + t.Fatal("restart enabled disabled terminal", err) + } + status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "") + expect(t, 200, status, out) + firstSecret := out["activationSecret"].(string) + status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "") + expect(t, 200, status, out) + secret := out["activationSecret"].(string) + if secret == firstSecret || secret == originalSecret { + t.Fatal("reactivation reused credentials") + } + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": firstSecret, "agentVersion": "test"}, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "") + expect(t, 200, status, out) + newDevice := out["accessToken"].(string) + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminal/connect-ticket/consume", newDevice, map[string]any{"ticket": ticket}, "") + expect(t, 401, status, out) + status, out = f.request("GET", "/terminal/desktops", newDevice, nil, "") + expect(t, 200, status, out) + if len(out["items"].([]any)) != 1 { + t.Fatal("reactivation lost authorization") + } + status, out = f.request("GET", "/terminal/config", device, nil, "") + expect(t, 401, status, out) + status, out = f.request("GET", "/audit-logs", admin, nil, "") + expect(t, 200, status, out) + audit, _ := json.Marshal(out) + if strings.Contains(string(audit), secret) || strings.Contains(string(audit), newDevice) || !strings.Contains(string(audit), "reactivate_terminal") { + t.Fatal("audit missing or leaked credentials") + } +} + +func TestTerminalLifecycleGuardsSessionsTasksAndRoles(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-002") + lifecycleBind(t, f, admin, id) + for _, role := range []string{"viewer", "operator"} { + token := f.login(role) + for _, path := range []string{"/disable", "/reactivate"} { + status, out := f.request("POST", "/terminals/"+id+path, token, nil, "") + expect(t, 403, status, out) + } + status, out := f.request("PUT", "/terminals/"+id, token, map[string]any{"name": "无权限", "group": ""}, "") + expect(t, 403, status, out) + status, out = f.request("PUT", "/users/user_demo_wang", token, map[string]any{"displayName": "无权限", "enabled": false}, "") + expect(t, 403, status, out) + status, out = f.request("POST", "/users/batch-bindings", token, map[string]any{"userIds": []string{"user_demo_wang"}, "desktopIds": []string{"desk_demo_windows"}, "action": "revoke"}, "") + expect(t, 403, status, out) + } + status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") + expect(t, 201, status, out) + status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "") + expect(t, 200, status, out) + session := out["sessionId"].(string) + for _, path := range []string{"/disable", "/reactivate"} { + status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "") + expect(t, 409, status, out) + if out["code"] != "terminal_in_use" { + t.Fatal(out) + } + } + status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "pending-command") + expect(t, 202, status, out) + task := out["id"].(string) + for _, path := range []string{"/disable", "/reactivate"} { + status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "") + expect(t, 409, status, out) + if out["code"] != "terminal_task_pending" { + t.Fatal(out) + } + } + status, out = f.request("GET", "/terminal/commands", device, nil, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/commands/"+task+"/ack", device, map[string]any{"status": "failed"}, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminals/"+id+"/disable", admin, nil, "") + expect(t, 200, status, out) + status, out = f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " ", "group": ""}, "") + expect(t, 400, status, out) + status, out = f.request("POST", "/terminals/missing/reactivate", admin, nil, "") + expect(t, 404, status, out) +} + +func TestUserMaintenanceAndBatchBindingsAreAtomic(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-003") + lifecycleBind(t, f, admin, id) + status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") + expect(t, 201, status, out) + ticket := out["ticket"] + status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " 新姓名 ", "enabled": false}, "") + expect(t, 200, status, out) + if out["displayName"] != "新姓名" || out["username"] != "wang.demo" || len(out["desktopIds"].([]any)) != 1 { + t.Fatal("profile update changed authorization", out) + } + status, out = f.request("GET", "/terminal/desktops", device, nil, "") + expect(t, 200, status, out) + if len(out["items"].([]any)) != 0 { + t.Fatal("disabled user still authorized") + } + status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": "新姓名", "enabled": true}, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/users", admin, map[string]any{"username": "batch-other", "displayName": "其他用户"}, "") + expect(t, 201, status, out) + other := out["id"].(string) + body := map[string]any{"userIds": []string{"user_demo_wang", other, other}, "desktopIds": []string{"desk_demo_ubuntu", "desk_demo_ubuntu"}, "action": "grant"} + for i := 0; i < 2; i++ { + status, out = f.request("POST", "/users/batch-bindings", admin, body, "") + expect(t, 200, status, out) + if out["userCount"] != float64(2) { + t.Fatal(out) + } + } + var count int + if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 2 { + t.Fatal("grant replaced existing grants", count, err) + } + body["desktopIds"] = []string{"desk_demo_ubuntu", "missing"} + body["action"] = "revoke" + status, out = f.request("POST", "/users/batch-bindings", admin, body, "") + expect(t, 400, status, out) + f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'") + if count != 2 { + t.Fatal("invalid batch partially committed") + } + body["desktopIds"] = []string{"desk_demo_ubuntu"} + body["userIds"] = []string{"user_demo_wang", "missing"} + status, out = f.request("POST", "/users/batch-bindings", admin, body, "") + expect(t, 400, status, out) + body["userIds"] = []string{"user_demo_wang", other} + status, out = f.request("POST", "/users/batch-bindings", admin, body, "") + expect(t, 200, status, out) + f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'") + if count != 1 { + t.Fatal("revoke removed unrelated desktop") + } + status, out = f.request("POST", "/users/batch-bindings", admin, map[string]any{"userIds": []string{}, "desktopIds": []string{}, "action": "grant"}, "") + expect(t, 400, status, out) + status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " ", "enabled": true}, "") + expect(t, 400, status, out) +} diff --git a/platform/internal/server/login_limits.go b/platform/internal/server/login_limits.go new file mode 100644 index 0000000..c802137 --- /dev/null +++ b/platform/internal/server/login_limits.go @@ -0,0 +1,59 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "github.com/gin-gonic/gin" + "math" + "strconv" + "strings" + "time" +) + +// Count attempts before password hashing. Shared storage keeps the budget across +// API replicas and restarts; neither passwords nor cleartext account/IP keys persist. +func (s *Server) allowLoginAttempt(c *gin.Context, username string) bool { + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return false + } + defer tx.Rollback() + retry := 0 + for _, limit := range []struct { + key string + max int + }{{"account:" + strings.ToLower(strings.TrimSpace(username)), 10}, {"ip:" + c.ClientIP(), 30}} { + mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret)) + mac.Write([]byte("login-limit:" + limit.key)) + key := hex.EncodeToString(mac.Sum(nil)) + var row struct { + Attempts int `db:"attempts"` + Started time.Time `db:"window_started"` + } + err = tx.GetContext(c.Request.Context(), &row, `INSERT INTO login_limits(key,attempts) VALUES($1,1) + ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN login_limits.window_started limit.max { + seconds := int(math.Ceil(time.Until(row.Started.Add(5 * time.Minute)).Seconds())) + if seconds > retry { + retry = seconds + } + } + } + if err = tx.Commit(); err != nil { + s.dbError(c, err) + return false + } + if retry > 0 { + c.Header("Retry-After", strconv.Itoa(retry)) + s.fail(c, 429, "login_rate_limited", "登录尝试过于频繁,请在 "+strconv.Itoa(retry)+" 秒后重试") + return false + } + return true +} diff --git a/platform/internal/server/maintenance.go b/platform/internal/server/maintenance.go new file mode 100644 index 0000000..0b9a553 --- /dev/null +++ b/platform/internal/server/maintenance.go @@ -0,0 +1,17 @@ +package server + +import ( + "context" + "github.com/jmoiron/sqlx" +) + +// Call with the desktop row locked when changing maintenance or power state. +// This is also used by preflight reads; the active task index handles submissions. +func maintenanceBlocked(ctx context.Context, db sqlx.ExtContext, id, own string, active bool) (bool, error) { + var blocked bool + err := sqlx.GetContext(ctx, db, &blocked, `SELECT ($3 AND EXISTS(SELECT 1 FROM tasks WHERE target_id=$1 AND id!=$2 AND kind IN ('desktop','hardware','snapshot','batch-desktop') AND status IN ('queued','running'))) + OR EXISTS(SELECT 1 FROM hardware_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND t.id!=$2 AND h.stage IN ('submitting','uncertain')) + OR EXISTS(SELECT 1 FROM snapshot_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND t.id!=$2 AND h.stage IN ('submitting','uncertain')) + OR EXISTS(SELECT 1 FROM power_batch_items h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND t.id!=$2 AND h.stage IN ('submitting','uncertain'))`, id, own, active) + return blocked, err +} diff --git a/platform/internal/server/observability.go b/platform/internal/server/observability.go new file mode 100644 index 0000000..918e9cb --- /dev/null +++ b/platform/internal/server/observability.go @@ -0,0 +1,299 @@ +package server + +import ( + "context" + "log" + "math" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "vdi-cloud/platform/internal/store" +) + +type alertRule struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name" binding:"required,max=80"` + Metric string `json:"metric" db:"metric" binding:"required,oneof=node_cpu node_memory node_storage node_unavailable terminal_offline"` + Threshold float64 `json:"threshold" db:"threshold" binding:"required"` + HoldSeconds int `json:"holdSeconds" db:"hold_seconds" binding:"min=0,max=3600"` + Enabled bool `json:"enabled" db:"enabled"` +} +type alertRecord struct { + ID string `json:"id" db:"id"` + RuleID string `json:"ruleId" db:"rule_id"` + RuleName string `json:"ruleName" db:"rule_name"` + Metric string `json:"metric" db:"metric"` + TargetID string `json:"targetId" db:"target_id"` + TargetName string `json:"targetName" db:"target_name"` + Value float64 `json:"value" db:"value"` + Threshold float64 `json:"threshold" db:"threshold"` + Status string `json:"status" db:"status"` + StartedAt time.Time `json:"startedAt" db:"started_at"` + OpenedAt time.Time `json:"openedAt" db:"opened_at"` + ResolvedAt *time.Time `json:"resolvedAt" db:"resolved_at"` + ResolveReason string `json:"resolveReason" db:"resolve_reason"` + AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"` + AcknowledgedBy string `json:"acknowledgedBy" db:"acknowledged_by"` +} +type resourcePoint struct { + Time time.Time `json:"time" db:"time"` + CPU *float64 `json:"cpu" db:"cpu"` + Memory *float64 `json:"memory" db:"memory"` + Storage *float64 `json:"storage" db:"storage"` + Samples int `json:"samples" db:"samples"` +} + +func (s *Server) resourceSeries(c *gin.Context) { + from, to, ok := timeRange(c, 7) + if !ok { + s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 7 天") + return + } + id := c.Query("resourceId") + if id == "" || len(id) > 100 { + s.fail(c, 400, "invalid_request", "请选择节点") + return + } + bucket := 60 + if to.Sub(from) > 6*time.Hour { + bucket = 300 + } + if to.Sub(from) > 24*time.Hour { + bucket = 1800 + } + items := []resourcePoint{} + err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT date_bin(make_interval(secs=>$4),sampled_at,'2000-01-01'::timestamptz) time, + avg(cpu)*100 cpu,avg(memory)*100 memory,avg(storage)*100 storage,count(*) samples FROM resource_samples + WHERE resource_id=$1 AND sampled_at>=$2 AND sampled_at<=$3 GROUP BY 1 ORDER BY 1`, id, from, to, bucket) + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"items": items, "bucketSeconds": bucket, "retentionDays": 30}) +} +func (s *Server) alertRules(c *gin.Context) { + items := []alertRule{} + if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM alert_rules ORDER BY name,id"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func observabilityLock(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(76511102026)") + return err +} +func (s *Server) saveAlertRule(c *gin.Context) { + var in alertRule + if !s.decode(c, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + offline := in.Metric == "terminal_offline" || in.Metric == "node_unavailable" + if in.Name == "" || math.IsNaN(in.Threshold) || math.IsInf(in.Threshold, 0) || (!offline && (in.Threshold < 1 || in.Threshold > 100)) || (offline && (in.Threshold < 90 || in.Threshold > 86400 || in.HoldSeconds != 0)) { + s.fail(c, 400, "invalid_request", "水位阈值为 1~100%;离线阈值为 90~86400 秒且不额外设置持续时间") + return + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if err = observabilityLock(ctx, tx); err != nil { + s.dbError(c, err) + return + } + in.ID = c.Param("id") + if in.ID == "" { + in.ID = store.ID("rule_") + _, err = tx.ExecContext(ctx, "INSERT INTO alert_rules(id,name,metric,threshold,hold_seconds,enabled) VALUES($1,$2,$3,$4,$5,$6)", in.ID, in.Name, in.Metric, in.Threshold, in.HoldSeconds, in.Enabled) + } else { + var id string + err = tx.GetContext(ctx, &id, "UPDATE alert_rules SET name=$1,metric=$2,threshold=$3,hold_seconds=$4,enabled=$5 WHERE id=$6 RETURNING id", in.Name, in.Metric, in.Threshold, in.HoldSeconds, in.Enabled, in.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='rule_changed' WHERE rule_id=$1 AND status='firing'", in.ID) + } + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1", in.ID) + } + } + if err == nil { + err = accountAudit(ctx, tx, s.admin(c).Username, "save_alert_rule", in.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, in) +} +func (s *Server) alerts(c *gin.Context) { + status := c.Query("status") + if status != "" && status != "firing" && status != "resolved" { + s.fail(c, 400, "invalid_request", "告警状态无效") + return + } + page, size := pageParams(c) + filter := " FROM alerts WHERE ($1='' OR status=$1) AND (target_name ILIKE $2 OR rule_name ILIKE $2)" + args := []any{status, "%" + c.Query("keyword") + "%"} + var total, firing, unacknowledged int + if err := s.DB.GetContext(c.Request.Context(), &total, "SELECT count(*)"+filter, args...); err != nil { + s.dbError(c, err) + return + } + if err := s.DB.QueryRowContext(c.Request.Context(), "SELECT count(*),count(*) FILTER(WHERE acknowledged_at IS NULL) FROM alerts WHERE status='firing'").Scan(&firing, &unacknowledged); err != nil { + s.dbError(c, err) + return + } + items := []alertRecord{} + args = append(args, size, (page-1)*size) + if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT *"+filter+" ORDER BY opened_at DESC,id LIMIT $3 OFFSET $4", args...); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "firingCount": firing, "unacknowledgedCount": unacknowledged, "requestId": c.GetString("requestId")}) +} +func (s *Server) acknowledgeAlert(c *gin.Context) { + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + var row alertRecord + if err = tx.GetContext(ctx, &row, "SELECT * FROM alerts WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if row.Status != "firing" { + s.fail(c, 409, "alert_resolved", "告警已经恢复") + return + } + if row.AcknowledgedAt == nil { + _, err = tx.ExecContext(ctx, "UPDATE alerts SET acknowledged_at=now(),acknowledged_by=$1 WHERE id=$2", s.admin(c).Username, row.ID) + if err == nil { + err = accountAudit(ctx, tx, s.admin(c).Username, "acknowledge_alert", row.ID, c.GetString("requestId")) + } + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true}) +} + +type observation struct { + ID string `db:"id"` + Name string `db:"name"` + Value *float64 `db:"value"` + Unavailable bool `db:"unavailable"` +} + +// One transaction serializes rule edits, expiry and condition evaluation across replicas. +func (s *Server) observe(ctx context.Context) error { + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var acquired bool + if err = tx.GetContext(ctx, &acquired, "SELECT pg_try_advisory_xact_lock(76511102026)"); err != nil || !acquired { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE session_history SET state='interrupted',ended_at=last_reported_at+interval '90 seconds',end_reason='heartbeat_timeout' + WHERE ended_at IS NULL AND last_reported_atnow()-interval '90 seconds' THEN s.` + column + `*100 ELSE NULL END value,false unavailable + FROM resources r LEFT JOIN LATERAL(SELECT ` + column + ` FROM resource_samples WHERE resource_id=r.id AND sampled_at>now()-interval '90 seconds' ORDER BY sampled_at DESC LIMIT 1) s ON true WHERE r.provider='proxmox'` + } + if err = tx.SelectContext(ctx, &targets, query); err != nil { + return err + } + ids := []string{} + for _, target := range targets { + ids = append(ids, target.ID) + if target.Value == nil { + if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND target_id=$2 AND NOT EXISTS(SELECT 1 FROM alerts WHERE rule_id=$1 AND target_id=$2 AND status='firing')", rule.ID, target.ID); err != nil { + return err + } + continue + } + if *target.Value < rule.Threshold && !target.Unavailable { + if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND target_id=$2", rule.ID, target.ID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='condition_cleared' WHERE rule_id=$1 AND target_id=$2 AND status='firing'", rule.ID, target.ID); err != nil { + return err + } + continue + } + if _, err = tx.ExecContext(ctx, "INSERT INTO alert_states(rule_id,target_id) VALUES($1,$2) ON CONFLICT DO NOTHING", rule.ID, target.ID); err != nil { + return err + } + hold := rule.HoldSeconds + if rule.Metric == "node_unavailable" && target.Unavailable { + hold = int(math.Ceil(rule.Threshold)) + if err = tx.GetContext(ctx, target.Value, "SELECT extract(epoch FROM(now()-first_seen))::double precision FROM alert_states WHERE rule_id=$1 AND target_id=$2", rule.ID, target.ID); err != nil { + return err + } + } + if _, err = tx.ExecContext(ctx, `INSERT INTO alerts(id,rule_id,rule_name,metric,target_id,target_name,value,threshold,started_at) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,first_seen FROM alert_states WHERE rule_id=$2 AND target_id=$5 AND first_seen<=now()-make_interval(secs=>$9) + ON CONFLICT(rule_id,target_id) WHERE status='firing' DO UPDATE SET value=EXCLUDED.value`, store.ID("alert_"), rule.ID, rule.Name, rule.Metric, target.ID, target.Name, *target.Value, rule.Threshold, hold); err != nil { + return err + } + } + if _, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='target_unavailable' WHERE rule_id=$1 AND NOT(target_id=ANY($2::text[])) AND status='firing'", rule.ID, ids); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND NOT(target_id=ANY($2::text[]))", rule.ID, ids); err != nil { + return err + } + } + // Bounded retention. Current incidents and active session records are preserved. + for _, query := range []string{"DELETE FROM login_limits WHERE window_startednow()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered + FROM terminals t LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id + LEFT JOIN terminal_policy_deliveries r ON r.terminal_id=t.id AND r.config_version=t.config_version + LEFT JOIN terminal_policy_deployments d ON d.id=r.deployment_id + LEFT JOIN terminal_policy_templates p ON p.id=d.template_id` + +func (s *Server) policyTemplates(c *gin.Context) { + items := []terminalPolicy{} + if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM terminal_policy_templates ORDER BY created_at DESC,id"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func (s *Server) createPolicyTemplate(c *gin.Context) { + var in terminalPolicy + if !s.decode(c, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + in.Version = strings.TrimSpace(in.Version) + if in.Name == "" || in.Version == "" { + s.fail(c, 400, "invalid_request", "策略名称和版本不能为空") + return + } + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + err = tx.GetContext(c.Request.Context(), &in, `INSERT INTO terminal_policy_templates(id,name,version,heartbeat_interval,command_poll_interval,desktop_refresh_interval) + VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, store.ID("policy_"), in.Name, in.Version, in.HeartbeatInterval, in.CommandPollInterval, in.DesktopRefreshInterval) + if conflict(err) { + s.fail(c, 409, "policy_exists", "该策略名称和版本已存在,请创建新版本") + return + } + if err == nil { + err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "create_terminal_policy", in.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(201, in) +} +func (s *Server) policyDeployments(c *gin.Context) { + items := []policyDeployment{} + if err := s.DB.SelectContext(c.Request.Context(), &items, deploymentSQL+" ORDER BY d.created_at DESC,d.id"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func (s *Server) policyStates(c *gin.Context) { + items := []policyResult{} + if err := s.DB.SelectContext(c.Request.Context(), &items, resultSQL+" ORDER BY t.name,t.id"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func (s *Server) policyResults(c *gin.Context) { + var exists bool + if err := s.DB.GetContext(c.Request.Context(), &exists, "SELECT EXISTS(SELECT 1 FROM terminal_policy_deployments WHERE id=$1)", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if !exists { + s.fail(c, 404, "not_found", "下发记录不存在") + return + } + // Historical results retain their own version and status after a newer dispatch. + items := []policyResult{} + query := `SELECT r.id,t.id terminal_id,t.name terminal_name,t.group_name,COALESCE(c.protocol,0) protocol, + r.deployment_id,p.name template_name,p.version template_version,r.config_version,r.status,r.error_code,r.acknowledged_at, + COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered + FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id + LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id + JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id + WHERE r.deployment_id=$1 ORDER BY t.name,t.id` + if err := s.DB.SelectContext(c.Request.Context(), &items, query, c.Param("id")); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func (s *Server) deployPolicy(c *gin.Context) { + var in struct { + TemplateID string `json:"templateId" binding:"required,max=100"` + TargetMode string `json:"targetMode" binding:"required,oneof=devices group"` + TerminalIDs []string `json:"terminalIds" binding:"max=200,dive,required,max=100"` + Group string `json:"group" binding:"max=80"` + } + if !s.decode(c, &in) { + return + } + in.Group = strings.TrimSpace(in.Group) + in.TerminalIDs = uniqueIDs(in.TerminalIDs) + sort.Strings(in.TerminalIDs) + if (in.TargetMode == "devices" && (len(in.TerminalIDs) == 0 || in.Group != "")) || (in.TargetMode == "group" && (in.Group == "" || len(in.TerminalIDs) > 0)) { + s.fail(c, 400, "invalid_request", "请选择设备或一个非空分组") + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if key == "" || len(key) > 100 { + s.fail(c, 400, "invalid_request", "下发需要有效的请求标识") + return + } + raw, _ := json.Marshal(in) + fingerprint := store.Hash(string(raw)) + ctx := c.Request.Context() + actor := s.admin(c).Username + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,765110))", actor+":"+key); err != nil { + s.dbError(c, err) + return + } + var old struct { + ID string `db:"id"` + Hash string `db:"request_hash"` + } + err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM terminal_policy_deployments WHERE actor=$1 AND idempotency_key=$2", actor, key) + if err == nil { + if old.Hash != fingerprint { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他下发参数") + return + } + var out policyDeployment + if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", old.ID); err != nil { + s.dbError(c, err) + return + } + c.JSON(202, out) + return + } + if err != sql.ErrNoRows { + s.dbError(c, err) + return + } + var template terminalPolicy + if err = tx.GetContext(ctx, &template, "SELECT * FROM terminal_policy_templates WHERE id=$1", in.TemplateID); err != nil { + s.dbError(c, err) + return + } + var targets []struct { + ID string `db:"id"` + Enabled bool `db:"enabled"` + Registered bool `db:"registered"` + Protocol int `db:"protocol"` + } + query := `SELECT t.id,t.enabled,(t.token_hash IS NOT NULL) registered,COALESCE(c.protocol,0) protocol FROM terminals t + LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id WHERE ` + var arg any = in.TerminalIDs + if in.TargetMode == "group" { + query += "t.group_name=$1" + arg = in.Group + } else { + query += "t.id=ANY($1)" + } + if err = tx.SelectContext(ctx, &targets, query+" ORDER BY t.id FOR UPDATE OF t", arg); err != nil { + s.dbError(c, err) + return + } + if len(targets) == 0 || len(targets) > 200 || (in.TargetMode == "devices" && len(targets) != len(in.TerminalIDs)) { + s.fail(c, 400, "invalid_targets", "目标为空、设备不存在或超过 200 台,请缩小范围") + return + } + for _, t := range targets { + if !t.Enabled || !t.Registered || t.Protocol != 1 { + s.fail(c, 409, "policy_unsupported", "所选设备包含已停用、未激活或不支持策略的终端,请检查设备结果") + return + } + } + id := store.ID("dispatch_") + if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deployments(id,template_id,actor,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, in.TemplateID, actor, key, fingerprint); err != nil { + s.dbError(c, err) + return + } + for _, t := range targets { + var version int + if err = tx.GetContext(ctx, &version, "UPDATE terminals SET config_version=config_version+1 WHERE id=$1 RETURNING config_version", t.ID); err != nil { + s.dbError(c, err) + return + } + if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status='superseded' WHERE terminal_id=$1 AND status IN ('pending','failed')", t.ID); err != nil { + s.dbError(c, err) + return + } + if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deliveries(id,deployment_id,terminal_id,config_version) VALUES($1,$2,$3,$4)", store.ID("delivery_"), id, t.ID, version); err != nil { + s.dbError(c, err) + return + } + } + var out policyDeployment + if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", id); err == nil { + err = accountAudit(ctx, tx, actor, "deploy_terminal_policy", id, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(202, out) +} + +func (s *Server) policyAck(c *gin.Context) { + var in struct { + DeploymentID string `json:"deploymentId" binding:"required,max=100"` + ConfigVersion int `json:"configVersion" binding:"min=2"` + Status string `json:"status" binding:"required,oneof=applied failed"` + ErrorCode string `json:"errorCode" binding:"omitempty,oneof=invalid_policy persist_failed runtime_unavailable"` + } + if !s.decode(c, &in) { + return + } + if (in.Status == "applied" && in.ErrorCode != "") || (in.Status == "failed" && in.ErrorCode == "") { + s.fail(c, 400, "invalid_request", "请提供匹配应用结果的错误代码") + return + } + tx := deviceTx(c) + ctx := c.Request.Context() + var row struct { + ID string `db:"id"` + Status string `db:"status"` + ErrorCode string `db:"error_code"` + } + err := tx.GetContext(ctx, &row, `SELECT r.id,r.status,r.error_code FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id + WHERE r.terminal_id=$1 AND r.deployment_id=$2 AND r.config_version=$3 AND t.config_version=r.config_version`, c.GetString("terminalId"), in.DeploymentID, in.ConfigVersion) + if err == sql.ErrNoRows { + s.fail(c, 409, "stale_policy", "该策略已被更新,请重新拉取配置") + return + } + if err != nil { + s.dbError(c, err) + return + } + // A successful application cannot be downgraded by a delayed failed response. + if row.Status == "applied" || (row.Status == in.Status && row.ErrorCode == in.ErrorCode) { + s.deviceJSON(c, 200, gin.H{"accepted": true}) + return + } + if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status=$1,error_code=$2,acknowledged_at=now() WHERE id=$3", in.Status, in.ErrorCode, row.ID); err != nil { + s.dbError(c, err) + return + } + if err = accountAudit(ctx, tx, "device", "terminal_policy_"+in.Status, row.ID, c.GetString("requestId")); err != nil { + s.dbError(c, err) + return + } + s.deviceJSON(c, 200, gin.H{"accepted": true}) +} diff --git a/platform/internal/server/policies_test.go b/platform/internal/server/policies_test.go new file mode 100644 index 0000000..9aef31e --- /dev/null +++ b/platform/internal/server/policies_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "fmt" + "testing" +) + +func policyTemplate(t *testing.T, f *fixture, admin string) string { + t.Helper() + status, out := f.request("POST", "/terminal-policy-templates", admin, map[string]any{"name": "办公策略", "version": "1.0", "heartbeatInterval": 30, "commandPollInterval": 5, "desktopRefreshInterval": 20}, "") + expect(t, 201, status, out) + return out["id"].(string) +} +func policyCap(t *testing.T, f *fixture, device string, protocol int) { + t.Helper() + status, out := f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "qt-platform/0.3.0", "policyProtocol": protocol}, "") + expect(t, 200, status, out) +} +func TestPolicyDispatchActualReceiptsAndHistory(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + id, device, _ := lifecycleDevice(t, f, admin, "POLICY-001") + template := policyTemplate(t, f, admin) + status, out := f.request("GET", "/terminal/config", device, nil, "") + expect(t, 200, status, out) + if out["heartbeatInterval"] != float64(30) || out["configVersion"] != float64(1) || out["deploymentId"] != "" { + t.Fatal(out) + } + body := map[string]any{"templateId": template, "targetMode": "devices", "terminalIds": []string{id}} + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1") + expect(t, 409, status, out) + policyCap(t, f, device, 1) + // Offline capable devices must queue without pretending to have applied. + if _, err := f.db.Exec("UPDATE terminals SET last_seen=now()-interval '5 minutes' WHERE id=$1", id); err != nil { + t.Fatal(err) + } + status, first := f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1") + expect(t, 202, status, first) + deployment := first["id"].(string) + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1") + expect(t, 202, status, out) + if out["id"] != deployment { + t.Fatal("duplicate dispatch", out) + } + status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "") + expect(t, 200, status, out) + row := out["items"].([]any)[0].(map[string]any) + if row["status"] != "pending" || row["online"] != false || row["acknowledgedAt"] != nil { + t.Fatal(row) + } + status, out = f.request("GET", "/terminal/config", device, nil, "") + expect(t, 200, status, out) + if out["commandPollInterval"] != float64(5) || out["desktopRefreshInterval"] != float64(20) || out["deploymentId"] != deployment { + t.Fatal(out) + } + version := out["configVersion"] + ack := map[string]any{"deploymentId": deployment, "configVersion": version, "status": "failed", "errorCode": "persist_failed"} + status, out = f.request("POST", "/terminal/config/ack", device, ack, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "") + expect(t, 200, status, out) + row = out["items"].([]any)[0].(map[string]any) + if row["status"] != "failed" || row["errorCode"] != "persist_failed" { + t.Fatal(row) + } + delete(ack, "errorCode") + ack["status"] = "applied" + status, out = f.request("POST", "/terminal/config/ack", device, ack, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/config/ack", device, ack, "") + expect(t, 200, status, out) + // A later deployment gets a new revision, even when restoring the same template. + status, second := f.request("POST", "/terminal-policy-deployments", admin, body, "policy-2") + expect(t, 202, status, second) + status, out = f.request("POST", "/terminal/config/ack", device, ack, "") + expect(t, 409, status, out) + status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "") + expect(t, 200, status, out) + row = out["items"].([]any)[0].(map[string]any) + if row["status"] != "applied" || row["configVersion"] != version { + t.Fatal("history changed", row) + } + status, out = f.request("GET", "/terminal-policy-deployments/"+second["id"].(string)+"/results", admin, nil, "") + expect(t, 200, status, out) + row = out["items"].([]any)[0].(map[string]any) + if row["status"] != "pending" || row["configVersion"] == version { + t.Fatal(row) + } + // Resetting identity requires a fresh application and capability report. + status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/config/ack", device, ack, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-3") + expect(t, 409, status, out) +} + +func TestPolicyValidationRBACAndGroupSnapshot(t *testing.T) { + f := newFixture(t) + admin := f.login("admin") + viewer := f.login("viewer") + operator := f.login("operator") + template := policyTemplate(t, f, admin) + valid := map[string]any{"name": "办公策略", "version": "1.0", "heartbeatInterval": 30, "commandPollInterval": 5, "desktopRefreshInterval": 20} + status, out := f.request("POST", "/terminal-policy-templates", admin, valid, "") + expect(t, 409, status, out) + for _, field := range []string{"heartbeatInterval", "commandPollInterval", "desktopRefreshInterval"} { + in := map[string]any{} + for k, v := range valid { + in[k] = v + } + in["version"] = "invalid-" + field + in[field] = 0 + status, out = f.request("POST", "/terminal-policy-templates", admin, in, "") + expect(t, 400, status, out) + } + id, device, _ := lifecycleDevice(t, f, admin, "POLICY-GROUP-001") + policyCap(t, f, device, 1) + body := map[string]any{"templateId": template, "targetMode": "group", "group": "原分组"} + for _, role := range []string{viewer, operator} { + status, out = f.request("POST", "/terminal-policy-templates", role, valid, "") + expect(t, 403, status, out) + status, out = f.request("POST", "/terminal-policy-deployments", role, body, "rbac-policy") + expect(t, 403, status, out) + for _, path := range []string{"/terminal-policy-templates", "/terminal-policy-states", "/terminal-policy-deployments"} { + status, out = f.request("GET", path, role, nil, "") + expect(t, 200, status, out) + } + } + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "") + expect(t, 400, status, out) + status, first := f.request("POST", "/terminal-policy-deployments", admin, body, "group-1") + expect(t, 202, status, first) + _, late, _ := lifecycleDevice(t, f, admin, "POLICY-GROUP-002") + policyCap(t, f, late, 1) + status, out = f.request("GET", "/terminal-policy-deployments/"+first["id"].(string)+"/results", viewer, nil, "") + expect(t, 200, status, out) + if len(out["items"].([]any)) != 1 { + t.Fatal("group membership retroactively changed", out) + } + body["group"] = "不存在" + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "group-1") + expect(t, 409, status, out) + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "empty") + expect(t, 400, status, out) + for i, bad := range []map[string]any{ + {"templateId": template, "targetMode": "devices", "terminalIds": []string{id, "missing"}}, + {"templateId": template, "targetMode": "devices", "terminalIds": []string{id}, "group": "原分组"}, + {"templateId": template, "targetMode": "group", "group": "原分组", "terminalIds": []string{id}}, + } { + status, out = f.request("POST", "/terminal-policy-deployments", admin, bad, fmt.Sprint("invalid", i)) + expect(t, 400, status, out) + } + // Downgraded clients explicitly revoke their capability on the next heartbeat. + policyCap(t, f, late, 0) + body["group"] = "原分组" + status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "unsupported") + expect(t, 409, status, out) + var revision int + if err := f.db.Get(&revision, "SELECT config_version FROM terminals WHERE id=$1", id); err != nil || revision != 2 { + t.Fatal("partial group dispatch", revision, err) + } + status, out = f.request("POST", "/terminal/config/ack", device, map[string]any{"deploymentId": first["id"], "configVersion": 2, "status": "failed", "errorCode": "token=secret"}, "") + expect(t, 400, status, out) + // Replacing an unapplied configuration preserves that outcome as superseded. + status, second := f.request("POST", "/terminal-policy-deployments", admin, map[string]any{"templateId": template, "targetMode": "devices", "terminalIds": []string{id}}, "replace") + expect(t, 202, status, second) + status, out = f.request("GET", "/terminal-policy-deployments/"+first["id"].(string)+"/results", admin, nil, "") + expect(t, 200, status, out) + if out["items"].([]any)[0].(map[string]any)["status"] != "superseded" { + t.Fatal(out) + } +} diff --git a/platform/internal/server/power_batch_worker.go b/platform/internal/server/power_batch_worker.go new file mode 100644 index 0000000..5228d13 --- /dev/null +++ b/platform/internal/server/power_batch_worker.go @@ -0,0 +1,237 @@ +package server + +import ( + "context" + "errors" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "time" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/pkg/proxmox" +) + +type powerBatchJob struct { + Stage string `db:"stage"` + Fingerprint string `db:"fingerprint"` +} + +func (s *Server) claimBatchPower(ctx context.Context, t model.Task) (bool, error) { + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var id string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + return false, err + } + r, err := tx.ExecContext(ctx, `UPDATE power_batch_items SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND kind='batch-desktop' AND status IN ('queued','running'))`, t.ID) + if err != nil { + return false, err + } + n, _ := r.RowsAffected() + if n != 1 { + return false, nil + } + err = tx.Commit() + return err == nil, err +} +func (s *Server) confirmBatchPower(ctx context.Context, db sqlx.ExtContext, t model.Task) error { + var d model.Desktop + if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID); err != nil { + return err + } + power := d.PowerState + if d.Provider == "proxmox" { + pve := s.currentPVE() + if pve == nil || t.ProviderRef == "" { + return errors.New("missing provider task") + } + task, err := pve.Task(ctx, d.Node, t.ProviderRef) + if err != nil { + return err + } + if task.Status != "stopped" || task.ExitStatus != "OK" { + return errors.New("original power task not successful") + } + power, err = pve.Power(ctx, d.Node, d.VMID) + if err != nil { + return err + } + } else if d.Provider != "demo" || s.Config.Mode != "demo" { + return errors.New("unsupported provider") + } + expected := "running" + if t.Action == "shutdown" { + expected = "stopped" + } + if power != expected { + return errors.New("actual power differs") + } + return nil +} +func (s *Server) executeBatchPower(parent context.Context, t model.Task) { + ctx, cancel := context.WithTimeout(parent, 90*time.Second) + defer cancel() + var job powerBatchJob + err := s.DB.GetContext(ctx, &job, "SELECT stage,fingerprint FROM power_batch_items WHERE task_id=$1", t.ID) + submitted := job.Stage == "submitting" || job.Stage == "uncertain" + if err == nil { + err = s.RefreshCompute(ctx) + } + var d model.Desktop + if err == nil && !submitted { + var item powerBatchCheckItem + d, item, err = s.powerBatchPreflight(ctx, s.DB, t.TargetID, t.Action, t.ID) + if err == nil && (!item.Available || powerFingerprint(d, item.PowerState) != job.Fingerprint) { + err = errors.New("power preflight changed") + } + if err == nil { + var claimed bool + claimed, err = s.claimBatchPower(ctx, t) + if err == nil && !claimed { + return + } + if err == nil { + submitted = true + if d.Provider == "demo" { + power := "running" + if t.Action == "shutdown" { + power = "stopped" + } + _, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID) + } else { + t.ProviderRef, err = s.currentPVE().Action(ctx, d.Node, d.VMID, t.Action) + var rejected *proxmox.HTTPError + if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { + submitted = false + } + if err == nil { + _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", t.ProviderRef, t.ID) + } + } + } + } + } + if err == nil { + err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID) + if err == nil && d.Provider == "proxmox" { + if t.ProviderRef == "" { + err = errors.New("missing original UPID") + } else { + err = s.currentPVE().Wait(ctx, d.Node, t.ProviderRef, time.Second) + } + } + } + if err == nil { + err = s.confirmBatchPower(ctx, s.DB, t) + } + status, stage, message := "success", "completed", "电源任务和实际状态已确认;连接服务需另行确认" + if err != nil { + status, stage, message = "failed", "failed", "执行前状态发生变化或 PVE 拒绝请求,未发送有效修改" + if submitted { + status, stage, message = "timeout", "uncertain", "批量电源结果未确认,已阻止后续修改和新连接;只核对原任务,不会自动重发" + } + } + s.finishBatchPower(t, status, stage, message) +} +func (s *Server) finishBatchPower(t model.Task, status, stage, message string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return + } + defer tx.Rollback() + var id, current, jobStage string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + return + } + if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil || current != "queued" && current != "running" { + return + } + if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { + return + } + if stage == "expired" { + status, stage, message = "failed", "failed", "批量电源任务提交前过期,未发送修改" + if jobStage != "prepared" { + status, stage, message = "timeout", "uncertain", "原批量电源任务结果未确认,不会自动重发" + } + } + _, err = tx.ExecContext(ctx, "UPDATE power_batch_items SET stage=$1 WHERE task_id=$2", stage, t.ID) + if err == nil && status == "success" { + power := "running" + if t.Action == "shutdown" { + power = "stopped" + } + _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID) + } + if err == nil { + err = s.completeTx(ctx, tx, t, status, message) + } + if err == nil { + _ = tx.Commit() + } +} +func (s *Server) reconcileBatchPower(c *gin.Context) { + tx, actor := s.accountTransaction(c, false) + if tx == nil { + return + } + defer tx.Rollback() + if actor.Role != "super_admin" && actor.Role != "operator" { + s.fail(c, 403, "forbidden", "当前账号不能核对电源任务") + return + } + ctx := c.Request.Context() + var t model.Task + if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 AND kind='batch-desktop'", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + var id string + if err := tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + s.dbError(c, err) + return + } + if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil { + s.dbError(c, err) + return + } + var stage string + if err := tx.GetContext(ctx, &stage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { + s.dbError(c, err) + return + } + if t.Status != "timeout" || stage != "uncertain" { + s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的批量电源任务") + return + } + if err := s.confirmBatchPower(ctx, tx, t); err != nil { + s.fail(c, 409, "power_unconfirmed", "原 PVE 任务和实际电源状态尚未确认;未重新发送修改") + return + } + _, err := tx.ExecContext(ctx, "UPDATE power_batch_items SET stage='completed' WHERE task_id=$1", t.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对原任务和实际电源状态通过,未重新发送修改',updated_at=now() WHERE id=$1", t.ID) + } + if err == nil { + power := "running" + if t.Action == "shutdown" { + power = "stopped" + } + _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID) + } + if err == nil { + err = accountAudit(ctx, tx, actor.Username, "reconcile_batch_power", t.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true}) +} diff --git a/platform/internal/server/power_batches.go b/platform/internal/server/power_batches.go new file mode 100644 index 0000000..9183b41 --- /dev/null +++ b/platform/internal/server/power_batches.go @@ -0,0 +1,330 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/jmoiron/sqlx" + "sort" + "strings" + "time" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type powerBatchInput struct { + Action string `json:"action" binding:"required,oneof=start shutdown reboot"` + DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=50,dive,required,max=100"` + PreviewToken string `json:"previewToken" binding:"max=20000"` +} +type powerBatchCheckItem struct { + DesktopID string `json:"desktopId"` + Name string `json:"name"` + VMID int `json:"vmid"` + PowerState string `json:"powerState"` + Available bool `json:"available"` + Code string `json:"code"` + Message string `json:"message"` +} +type powerBatchClaims struct { + Hash string `json:"requestHash"` + Session string `json:"session"` + Ready map[string]string `json:"ready"` + jwt.RegisteredClaims +} + +func normalizePowerBatch(in *powerBatchInput) (string, error) { + sort.Strings(in.DesktopIDs) + for i, id := range in.DesktopIDs { + if strings.TrimSpace(id) != id || id == "" || i > 0 && id == in.DesktopIDs[i-1] { + return "", errors.New("桌面列表不能包含空值、重复值或多余空格") + } + } + raw, _ := json.Marshal(struct { + Action string + IDs []string + }{in.Action, in.DesktopIDs}) + return store.Hash(string(raw)), nil +} +func powerFingerprint(d model.Desktop, power string) string { + raw, _ := json.Marshal([]any{d.ID, d.Name, d.Provider, d.Node, d.VMID, power}) + return store.Hash(string(raw)) +} +func (s *Server) powerBatchPreflight(ctx context.Context, db sqlx.ExtContext, id, action, own string) (model.Desktop, powerBatchCheckItem, error) { + item := powerBatchCheckItem{DesktopID: id, PowerState: "unknown"} + var d model.Desktop + err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", id) + if errors.Is(err, sql.ErrNoRows) { + item.Code = "not_found" + item.Message = "桌面不存在或已移除" + return d, item, nil + } + if err != nil { + return d, item, err + } + item.Name = d.Name + item.VMID = d.VMID + reject := func(code, message string) (model.Desktop, powerBatchCheckItem, error) { + item.Code = code + item.Message = message + return d, item, nil + } + blocked, err := maintenanceBlocked(ctx, db, id, own, true) + if err != nil { + return d, item, err + } + if blocked { + return reject("maintenance", "有维护任务或未确认结果,请先处理") + } + var latest string + err = sqlx.GetContext(ctx, db, &latest, "SELECT status FROM tasks WHERE target_id=$1 AND id!=$2 AND kind IN ('desktop','batch-desktop') ORDER BY created_at DESC,id DESC LIMIT 1", id, own) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return d, item, err + } + if latest == "timeout" { + return reject("power_unconfirmed", "最近电源任务结果未确认,请先核对") + } + var active bool + if err = sqlx.GetContext(ctx, db, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil { + return d, item, err + } + if active { + return reject("active_session", "有活动会话,批量操作跳过;请先结束会话") + } + power := d.PowerState + if d.Provider == "proxmox" { + pve := s.currentPVE() + if pve == nil { + return reject("pve_unconfigured", "PVE 未配置") + } + config, e := pve.VMConfig(ctx, d.Node, d.VMID) + if e != nil { + return reject("provider_unavailable", "无法读取 PVE 配置") + } + if proxmox.ConfigInt(config, "template", 0) != 0 || config["lock"] != nil && fmt.Sprint(config["lock"]) != "" { + return reject("pve_locked", "PVE 模板或锁定状态不支持批量电源操作") + } + power, e = pve.Power(ctx, d.Node, d.VMID) + if e != nil { + return reject("provider_unavailable", "无法读取真实电源状态") + } + item.PowerState = power + ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), "VM.PowerMgmt") + if e != nil || !ok { + return reject("permission_missing", "无法确认 VM.PowerMgmt 权限") + } + } else if d.Provider != "demo" || s.Config.Mode != "demo" { + return reject("provider_disabled", "当前环境不允许该桌面操作") + } + item.PowerState = power + if action == "start" && power != "stopped" || action != "start" && power != "running" { + return reject("power_mismatch", "当前电源状态不适用此操作") + } + item.Available = true + item.Code = "ready" + item.Message = "可提交,执行前将再次核对" + return d, item, nil +} +func (s *Server) checkPowerBatch(c *gin.Context) { + var in powerBatchInput + if !s.decodeConfiguration(c, &in) { + return + } + hash, err := normalizePowerBatch(&in) + if err != nil || in.PreviewToken != "" { + s.fail(c, 400, "invalid_request", "请检查桌面列表,仅接受预检参数") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + items := []powerBatchCheckItem{} + ready := map[string]string{} + for _, id := range in.DesktopIDs { + d, item, e := s.powerBatchPreflight(ctx, s.DB, id, in.Action, "") + if e != nil { + s.fail(c, 503, "batch_check_unavailable", "批量预检未完成,请稍后重试") + return + } + items = append(items, item) + if item.Available { + ready[id] = powerFingerprint(d, item.PowerState) + } + } + expires := time.Now().Add(5 * time.Minute) + claims := powerBatchClaims{Hash: hash, Session: c.GetString("sessionId"), Ready: ready, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-power-batch-preview", Subject: s.admin(c).ID, ExpiresAt: jwt.NewNumericDate(expires)}} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.Config.JWTSecret)) + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"items": items, "eligibleCount": len(ready), "skippedCount": len(items) - len(ready), "previewToken": token, "expiresAt": expires}) +} + +type powerBatch struct { + ID string `json:"id" db:"id"` + Action string `json:"action" db:"action"` + Actor string `json:"actor" db:"actor"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + TotalCount int `json:"totalCount" db:"total_count"` + SubmittedCount int `json:"submittedCount" db:"submitted_count"` + SkippedCount int `json:"skippedCount" db:"skipped_count"` + ActiveCount int `json:"activeCount" db:"active_count"` + SuccessCount int `json:"successCount" db:"success_count"` + FailedCount int `json:"failedCount" db:"failed_count"` + UncertainCount int `json:"uncertainCount" db:"uncertain_count"` +} +type powerBatchItem struct { + DesktopID string `json:"desktopId" db:"desktop_id"` + Name string `json:"name" db:"desktop_name"` + VMID int `json:"vmid" db:"vmid"` + TaskID string `json:"taskId" db:"task_id"` + Status string `json:"status" db:"status"` + Code string `json:"code" db:"code"` + Message string `json:"message" db:"message"` +} + +const powerBatchQuery = `SELECT b.id,b.action,b.actor,b.created_at,count(*) total_count,count(i.task_id) submitted_count,count(*) FILTER(WHERE i.task_id IS NULL) skipped_count,count(*) FILTER(WHERE t.status IN ('queued','running')) active_count,count(*) FILTER(WHERE t.status='success') success_count,count(*) FILTER(WHERE t.status='failed') failed_count,count(*) FILTER(WHERE t.status='timeout') uncertain_count FROM power_batches b JOIN power_batch_items i ON i.batch_id=b.id LEFT JOIN tasks t ON t.id=i.task_id` + +func (s *Server) powerBatches(c *gin.Context) { + items := []powerBatch{} + if err := s.DB.SelectContext(c.Request.Context(), &items, powerBatchQuery+" GROUP BY b.id ORDER BY b.created_at DESC,b.id DESC LIMIT 100"); err != nil { + s.dbError(c, err) + return + } + list(c, items) +} +func powerBatchDetail(ctx context.Context, db sqlx.ExtContext, id string) (gin.H, error) { + var batch powerBatch + if err := sqlx.GetContext(ctx, db, &batch, powerBatchQuery+" WHERE b.id=$1 GROUP BY b.id", id); err != nil { + return nil, err + } + items := []powerBatchItem{} + err := sqlx.SelectContext(ctx, db, &items, `SELECT i.desktop_id,i.desktop_name,i.vmid,coalesce(i.task_id,'') task_id,coalesce(t.status,'skipped') status,i.code,coalesce(t.message,i.message) message FROM power_batch_items i LEFT JOIN tasks t ON t.id=i.task_id WHERE i.batch_id=$1 ORDER BY i.desktop_id`, id) + return gin.H{"batch": batch, "items": items}, err +} +func (s *Server) getPowerBatch(c *gin.Context) { + out, err := powerBatchDetail(c.Request.Context(), s.DB, c.Param("id")) + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, out) +} +func (s *Server) queuePowerBatch(c *gin.Context) { + var in powerBatchInput + if !s.decodeConfiguration(c, &in) { + return + } + hash, err := normalizePowerBatch(&in) + if err != nil { + s.fail(c, 400, "invalid_request", err.Error()) + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if key == "" || len(key) > 100 { + s.fail(c, 400, "idempotency_required", "请携带有效请求标识") + return + } + tx, actor := s.accountTransaction(c, false) + if tx == nil { + return + } + defer tx.Rollback() + if actor.Role != "super_admin" && actor.Role != "operator" { + s.fail(c, 403, "forbidden", "当前账号不能执行批量电源操作") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + var old struct { + ID string `db:"id"` + Hash string `db:"request_hash"` + } + err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM power_batches WHERE actor=$1 AND idempotency_key=$2", actor.Username, key) + if err == nil { + if old.Hash != hash { + s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于其他桌面或操作") + return + } + out, e := powerBatchDetail(ctx, tx, old.ID) + if e != nil { + s.dbError(c, e) + return + } + c.JSON(202, out) + return + } + if !errors.Is(err, sql.ErrNoRows) { + s.dbError(c, err) + return + } + claims := &powerBatchClaims{} + token, e := jwt.ParseWithClaims(in.PreviewToken, claims, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-power-batch-preview"), jwt.WithExpirationRequired()) + if e != nil || !token.Valid || claims.Subject != actor.ID || claims.Session != c.GetString("sessionId") || claims.Hash != hash || len(claims.Ready) == 0 { + s.fail(c, 409, "batch_preview_expired", "请重新预检,并确认至少一台桌面可执行") + return + } + var locked []string + if err = tx.SelectContext(ctx, &locked, "SELECT id FROM desktops WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.DesktopIDs); err != nil { + s.dbError(c, err) + return + } + id := store.ID("batch_") + _, err = tx.ExecContext(ctx, "INSERT INTO power_batches(id,actor,action,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, actor.Username, in.Action, key, hash) + if err != nil { + s.dbError(c, err) + return + } + for _, desktopID := range in.DesktopIDs { + d, item, e := s.powerBatchPreflight(ctx, tx, desktopID, in.Action, "") + if e != nil { + s.dbError(c, e) + return + } + fingerprint, approved := claims.Ready[desktopID] + if item.Available && (!approved || fingerprint != powerFingerprint(d, item.PowerState)) { + item.Available = false + item.Code = "preview_changed" + item.Message = "与已确认的预检不一致,已跳过,请重新预检" + } + taskID, stage := "", "skipped" + if item.Available { + taskID = store.ID("task_") + stage = "prepared" + _, err = tx.ExecContext(ctx, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'batch-desktop',$2,$3,$4,$5,'queued','已提交批量电源任务,执行前再次预检',$6)`, taskID, in.Action, d.ID, d.Name, actor.Username, id+":"+d.ID) + if err != nil { + s.dbError(c, err) + return + } + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, in.Action, d.ID, c.GetString("requestId")) + if err != nil { + s.dbError(c, err) + return + } + } + _, err = tx.ExecContext(ctx, `INSERT INTO power_batch_items(batch_id,desktop_id,desktop_name,vmid,task_id,code,message,fingerprint,stage) VALUES($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9)`, id, desktopID, item.Name, item.VMID, taskID, item.Code, item.Message, fingerprint, stage) + if err != nil { + s.dbError(c, err) + return + } + } + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, "batch-"+in.Action, id, c.GetString("requestId")) + if err != nil { + s.dbError(c, err) + return + } + out, err := powerBatchDetail(ctx, tx, id) + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(202, out) +} diff --git a/platform/internal/server/power_batches_test.go b/platform/internal/server/power_batches_test.go new file mode 100644 index 0000000..c62ea4e --- /dev/null +++ b/platform/internal/server/power_batches_test.go @@ -0,0 +1,430 @@ +package server + +import ( + "context" + "encoding/json" + "github.com/golang-jwt/jwt/v5" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type batchPVE struct { + mu sync.Mutex + state, outcome string + locked, deny bool + writes int +} + +func fakeBatchPVE(t *testing.T, f *fixture) *batchPVE { + p := &batchPVE{state: "stopped"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + var data any + switch { + case strings.Contains(path, "/tasks/"): + if p.outcome == "poll-failure" { + w.WriteHeader(503) + return + } + status, exit := "stopped", "OK" + if p.outcome == "running" { + status = "running" + } + if p.outcome == "failed" { + exit = "TASK ERROR" + } + data = map[string]any{"status": status, "exitstatus": exit} + case path == "/access/permissions": + n := 1 + if p.deny { + n = 0 + } + data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.PowerMgmt": n, "VM.Snapshot": n, "VM.Config.CPU": n, "VM.Config.Memory": n}} + case strings.HasSuffix(path, "/status/current"): + data = map[string]string{"status": p.state} + case strings.HasSuffix(path, "/config"): + c := map[string]any{"digest": "d1", "cores": 2, "memory": 2048, "scsi0": "local:vm-9000,size=4G"} + if p.locked { + c["lock"] = "backup" + } + data = c + case strings.HasSuffix(path, "/pending"): + data = []any{} + case r.Method == "POST" && strings.Contains(path, "/status/"): + p.writes++ + if p.outcome == "reject" { + w.WriteHeader(403) + return + } + if p.outcome == "ambiguous" { + w.WriteHeader(500) + return + } + p.state = "running" + if strings.HasSuffix(path, "/shutdown") { + p.state = "stopped" + } + data = "UPID:batch-power" + if p.outcome == "missing-upid" { + data = "" + } + default: + t.Error("unexpected batch PVE call", r.Method, path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} + if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + return p +} +func batchRequest(action string, ids ...string) map[string]any { + return map[string]any{"action": action, "desktopIds": ids} +} +func checkedBatch(t *testing.T, f *fixture, token string, body map[string]any) map[string]any { + t.Helper() + code, out := f.request("POST", "/desktop-batches/check", token, body, "") + expect(t, 200, code, out) + b := copySnapshotConfig(body) + b["previewToken"] = out["previewToken"] + return b +} +func queueBatchTest(t *testing.T, f *fixture, token string, body map[string]any, key string) map[string]any { + t.Helper() + code, out := f.request("POST", "/desktop-batches", token, body, key) + expect(t, 202, code, out) + return out +} +func batchTask(t *testing.T, f *fixture, out map[string]any) model.Task { + t.Helper() + for _, value := range out["items"].([]any) { + item := value.(map[string]any) + if id, _ := item["taskId"].(string); id != "" { + return readSnapshotTask(t, f, id) + } + } + t.Fatal("no batch task", out) + return model.Task{} +} +func TestPowerBatchLifecycleAndPartialResults(t *testing.T) { + for _, action := range []string{"start", "shutdown", "reboot"} { + t.Run(action, func(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeBatchPVE(t, f) + if action != "start" { + p.state = "running" + } + operator := f.login("operator") + body := checkedBatch(t, f, operator, batchRequest(action, "desk_demo_windows", "not-managed")) + out := queueBatchTest(t, f, operator, body, "batch-once") + batch := out["batch"].(map[string]any) + if batch["submittedCount"] != float64(1) || batch["skippedCount"] != float64(1) || batch["activeCount"] != float64(1) || batch["successCount"] != float64(0) { + t.Fatal(batch) + } + task := batchTask(t, f, out) + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + if task.Status != "success" || p.writes != 1 || task.ProviderRef == "" { + t.Fatal(task, p.writes) + } + code, view := f.request("GET", "/desktop-batches/"+batch["id"].(string), f.login("viewer"), nil, "") + expect(t, 200, code, view) + counts := view["batch"].(map[string]any) + if counts["successCount"] != float64(1) || counts["activeCount"] != float64(0) { + t.Fatal(counts) + } + code, view = f.request("GET", "/desktop-batches", operator, nil, "") + expect(t, 200, code, view) + if view["total"] != float64(1) { + t.Fatal(view) + } + // A retry after completion, even without a preview token, returns the same batch. + delete(body, "previewToken") + again := queueBatchTest(t, f, operator, body, "batch-once") + if again["batch"].(map[string]any)["id"] != batch["id"] { + t.Fatal("duplicate batch") + } + body["action"] = "start" + if action == "start" { + body["action"] = "shutdown" + } + code, view = f.request("POST", "/desktop-batches", operator, body, "batch-once") + expect(t, 409, code, view) + var submitted, completed int + _ = f.db.Get(&submitted, "SELECT count(*) FROM audit_logs WHERE result='submitted'") + _ = f.db.Get(&completed, "SELECT count(*) FROM audit_logs WHERE result='success' AND action=$1", action) + if submitted != 2 || completed != 1 { + t.Fatal("audit missing", submitted, completed) + } + }) + } +} +func TestPowerBatchPreviewValidationAndRBAC(t *testing.T) { + f := newFixtureWorker(t, false) + fakeBatchPVE(t, f) + admin := f.login("admin") + viewer := f.login("viewer") + operator := f.login("operator") + for _, path := range []string{"/desktop-batches/check", "/desktop-batches"} { + code, out := f.request("POST", path, viewer, batchRequest("start", "desk_demo_windows"), "no") + expect(t, 403, code, out) + } + for _, body := range []map[string]any{batchRequest("reset", "desk_demo_windows"), batchRequest("start"), batchRequest("start", "desk_demo_windows", "desk_demo_windows"), batchRequest("start", " desk_demo_windows"), {"action": "start", "desktopIds": []string{"desk_demo_windows"}, "force": true}} { + code, out := f.request("POST", "/desktop-batches/check", admin, body, "") + expect(t, 400, code, out) + } + ids := []string{} + for i := 0; i < 51; i++ { + ids = append(ids, store.ID("d_")) + } + code, out := f.request("POST", "/desktop-batches/check", admin, batchRequest("start", ids...), "") + expect(t, 400, code, out) + body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows")) + preview := body["previewToken"].(string) + code, out = f.request("GET", "/auth/me", preview, nil, "") + expect(t, 401, code, out) + code, out = f.request("POST", "/desktop-batches", admin, body, "") + expect(t, 400, code, out) + code, out = f.request("POST", "/desktop-batches", operator, body, "wrong-user") + expect(t, 409, code, out) + body["action"] = "shutdown" + code, out = f.request("POST", "/desktop-batches", admin, body, "changed") + expect(t, 409, code, out) + body["action"] = "start" + body["previewToken"] = admin + code, out = f.request("POST", "/desktop-batches", admin, body, "access-not-preview") + expect(t, 409, code, out) + cl := &powerBatchClaims{} + _, err := jwt.ParseWithClaims(preview, cl, func(*jwt.Token) (any, error) { return []byte(f.server.Config.JWTSecret), nil }) + if err != nil { + t.Fatal(err) + } + cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute)) + expired, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, cl).SignedString([]byte(f.server.Config.JWTSecret)) + body["previewToken"] = expired + code, out = f.request("POST", "/desktop-batches", admin, body, "expired") + expect(t, 409, code, out) + body["previewToken"] = preview + secondSession := f.login("admin") + code, out = f.request("POST", "/desktop-batches", secondSession, body, "wrong-session") + expect(t, 409, code, out) + var n int + _ = f.db.Get(&n, "SELECT count(*) FROM power_batches") + if n != 0 { + t.Fatal("invalid request persisted batch") + } +} +func TestPowerBatchPreflightBlockersAndRecheck(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeBatchPVE(t, f) + admin := f.login("admin") + for _, block := range []string{"running", "lock", "deny", "active-session", "maintenance", "unknown"} { + t.Run(block, func(t *testing.T) { + p.state = "stopped" + p.locked = false + p.deny = false + switch block { + case "running": + p.state = "running" + case "lock": + p.locked = true + case "deny": + p.deny = true + case "active-session": + _, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'") + case "maintenance": + _, _ = f.db.Exec("INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,idempotency_key) VALUES('block','desktop','start','desk_demo_windows','blocked','admin','queued','block')") + case "unknown": + _, _ = f.db.Exec("INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,idempotency_key) VALUES('block','desktop','start','desk_demo_windows','blocked','admin','timeout','block')") + } + code, out := f.request("POST", "/desktop-batches/check", admin, batchRequest("start", "desk_demo_windows"), "") + expect(t, 200, code, out) + if out["eligibleCount"] != float64(0) { + t.Fatal(block, out) + } + body := batchRequest("start", "desk_demo_windows") + body["previewToken"] = out["previewToken"] + code, out = f.request("POST", "/desktop-batches", admin, body, "blocked-"+block) + expect(t, 409, code, out) + _, _ = f.db.Exec("DELETE FROM tasks WHERE id='block'") + _, _ = f.db.Exec("UPDATE terminals SET session_desktop_id=NULL WHERE id='term_demo_001'") + }) + } + p.state = "stopped" + p.locked = false + p.deny = false + // A desktop that was excluded from the preview cannot become a newly queued target. + body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows", "desk_demo_ubuntu")) + _, _ = f.db.Exec("UPDATE desktops SET power_state='stopped' WHERE id='desk_demo_ubuntu'") + out := queueBatchTest(t, f, admin, body, "preview-fence") + if out["batch"].(map[string]any)["submittedCount"] != float64(1) { + t.Fatal("added unapproved target", out) + } + task := batchTask(t, f, out) + p.state = "running" + f.server.execute(context.Background(), task) + if readSnapshotTask(t, f, task.ID).Status != "failed" || p.writes != 0 { + t.Fatal("worker did not recheck before send") + } + // Queueing rechecks newly active sessions and records a skipped row, never sends. + p.state = "stopped" + body = checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows")) + _, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'") + out = queueBatchTest(t, f, admin, body, "session-after-preview") + if out["batch"].(map[string]any)["submittedCount"] != float64(0) { + t.Fatal(out) + } +} +func TestPowerBatchUnknownAndMaintenanceProtection(t *testing.T) { + for _, outcome := range []string{"reject", "ambiguous", "missing-upid", "poll-failure", "failed"} { + t.Run(outcome, func(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeBatchPVE(t, f) + admin := f.login("admin") + body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows")) + out := queueBatchTest(t, f, admin, body, "uncertain") + task := batchTask(t, f, out) + p.outcome = outcome + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + want := "timeout" + if outcome == "reject" { + want = "failed" + } + if task.Status != want { + t.Fatal(task) + } + if want == "failed" { + return + } + code, view := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked-single") + expect(t, 409, code, view) + _, _ = f.db.Exec("UPDATE desktops SET power_state='running' WHERE id='desk_demo_windows'") + _, _ = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("batch-device")) + code, view = f.request("POST", "/terminal/connect-ticket", "batch-device", map[string]string{"desktopId": "desk_demo_windows"}, "") + expect(t, 409, code, view) + if view["code"] != "hardware_unconfirmed" { + t.Fatal(view) + } + code, view = f.request("PUT", "/desktops/desk_demo_windows/connection", admin, map[string]any{"endpoint": "192.0.2.90", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "") + expect(t, 409, code, view) + _, _ = f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID) + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + if p.writes != 1 { + t.Fatal("power replayed", p.writes) + } + code, view = f.request("POST", "/tasks/"+task.ID+"/batch-power-reconcile", admin, nil, "") + expect(t, 409, code, view) + p.outcome = "" + f.db.SetMaxOpenConns(1) + code, view = f.request("POST", "/tasks/"+task.ID+"/batch-power-reconcile", admin, nil, "") + if outcome == "poll-failure" || outcome == "failed" { + expect(t, 200, code, view) + } else { + expect(t, 409, code, view) + } + if p.writes != 1 { + t.Fatal("read-only reconcile wrote PVE") + } + }) + } +} +func TestPowerBatchConcurrentReplayAndExclusivity(t *testing.T) { + f := newFixtureWorker(t, false) + fakeBatchPVE(t, f) + admin := f.login("admin") + body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows")) + var wg sync.WaitGroup + results := make(chan string, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + out := queueBatchTest(t, f, admin, body, "concurrent") + results <- out["batch"].(map[string]any)["id"].(string) + }() + } + wg.Wait() + close(results) + first := "" + for id := range results { + if first == "" { + first = id + } + if id != first { + t.Fatal("duplicate batch") + } + } + code, out := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "single-conflict") + expect(t, 409, code, out) + code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}, "hardware-conflict") + expect(t, 409, code, out) + var n int + _ = f.db.Get(&n, "SELECT count(*) FROM tasks WHERE kind='batch-desktop'") + if n != 1 { + t.Fatal(n) + } +} +func TestPowerBatchExpiryAndOrdinaryLane(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeBatchPVE(t, f) + admin := f.login("admin") + body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows")) + out := queueBatchTest(t, f, admin, body, "prepared-expire") + task := batchTask(t, f, out) + f.server.finishBatchPower(task, "", "expired", "") + claimed, err := f.server.claimBatchPower(context.Background(), task) + if err != nil || claimed { + t.Fatal("expired task claimed", err) + } + out = queueBatchTest(t, f, admin, body, "submitting-expire") + task = batchTask(t, f, out) + claimed, err = f.server.claimBatchPower(context.Background(), task) + if err != nil || !claimed { + t.Fatal(err) + } + f.server.finishBatchPower(task, "", "expired", "") + if readSnapshotTask(t, f, task.ID).Status != "timeout" { + t.Fatal("submitted expiry not protected") + } + // Clear only the isolated test job, then verify a queued batch older than 2 minutes remains eligible. + _, _ = f.db.Exec("UPDATE power_batch_items SET stage='failed' WHERE task_id=$1", task.ID) + _, _ = f.db.Exec("UPDATE tasks SET status='failed' WHERE id=$1", task.ID) + out = queueBatchTest(t, f, admin, body, "long") + task = batchTask(t, f, out) + _, _ = f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", task.ID) + p.outcome = "running" + 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(6 * time.Second) + for readSnapshotTask(t, f, task.ID).ProviderRef == "" { + if time.Now().After(deadline) { + t.Fatal("batch failed to start") + } + time.Sleep(25 * time.Millisecond) + } + code, view := f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]string{"action": "start"}, "ordinary") + expect(t, 202, code, view) + for readSnapshotTask(t, f, view["id"].(string)).Status != "success" { + if time.Now().After(deadline) { + t.Fatal("batch blocked normal task lane") + } + time.Sleep(25 * time.Millisecond) + } +} diff --git a/platform/internal/server/provision_worker.go b/platform/internal/server/provision_worker.go new file mode 100644 index 0000000..e00dffd --- /dev/null +++ b/platform/internal/server/provision_worker.go @@ -0,0 +1,203 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "time" + + "github.com/gin-gonic/gin" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +// Reconciliation only polls a known UPID. It never submits another clone request. +func (s *Server) reconcileProvision(c *gin.Context) { + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + var task model.Task + if err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if task.Kind != "provision" || task.Status != "timeout" || task.ProviderRef == "" { + s.fail(c, 409, "reconcile_unavailable", "仅可核对有 PVE 任务标识且结果未确认的克隆;其他情况请在 PVE 人工核查") + return + } + if _, err = tx.ExecContext(ctx, "UPDATE provisioning_jobs SET state='reserved' WHERE task_id=$1 AND state='uncertain'", task.ID); err != nil { + s.dbError(c, err) + return + } + err = tx.GetContext(ctx, &task, "UPDATE tasks SET status='running',message='正在重新核对原 PVE 任务,不会再次克隆',updated_at=now() WHERE id=$1 RETURNING *", task.ID) + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + s.log(c, "reconcile_clone", task.ID, "submitted") + c.JSON(202, task) +} + +func (s *Server) validateTemplate(c *gin.Context) { + pve := s.currentPVE() + var template model.ImageTemplate + ctx := c.Request.Context() + if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + if pve == nil { + s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox") + return + } + config, err := pve.VMConfig(ctx, template.Node, template.VMID) + if err != nil { + s.fail(c, 502, "pve_unavailable", "无法读取模板配置") + return + } + if _, err = proxmox.TemplateDisks(config); err != nil { + s.fail(c, 409, "invalid_template", err.Error()) + return + } + digest, _ := config["digest"].(string) + if digest == "" { + 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 { + s.dbError(c, err) + return + } + s.log(c, "validate_template", template.ID, "success") + c.JSON(200, template) +} + +func (s *Server) executeProvision(parent context.Context, task model.Task) { + if err := s.RefreshCompute(parent); err != nil { + _ = s.complete(parent, task, "failed", "计算平台配置无法加载,未提交操作") + return + } + pve := s.currentPVE() + ctx, cancel := context.WithTimeout(parent, 30*time.Minute) + defer cancel() + var job provisioningJob + err := s.DB.GetContext(ctx, &job, "SELECT * FROM provisioning_jobs WHERE task_id=$1", task.ID) + var in cloneInput + if err == nil { + err = json.Unmarshal([]byte(job.RequestJSON), &in) + } + var template model.ImageTemplate + if err == nil { + err = s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1", job.TemplateID) + } + uncertain := task.ProviderRef != "" + if err == nil && pve == nil { + err = errors.New("Proxmox 尚未配置") + } + if err == nil && task.ProviderRef == "" { + _, err = s.clonePreflight(ctx, in) + if err == nil && in.PoolID != nil { + var enabled bool + err = s.DB.GetContext(ctx, &enabled, "SELECT enabled FROM desktop_pools WHERE id=$1", *in.PoolID) + if err == nil && !enabled { + err = errors.New("桌面池已停用") + } + } + if err == nil { + // After a POST could have reached PVE, never submit it again automatically. + uncertain = true + task.ProviderRef, err = pve.Clone(ctx, template.Node, template.VMID, in.VMID, in.Full, in.Storage) + var rejected *proxmox.HTTPError + if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { + uncertain = false + } + if err == nil { + _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", task.ProviderRef, task.ID) + } + } + } + if err == nil { + err = pve.Wait(ctx, template.Node, task.ProviderRef, time.Second) + } + var power string + if err == nil { + var config map[string]any + config, err = pve.VMConfig(ctx, template.Node, in.VMID) + if err == nil && (config["name"] != fmt.Sprintf("vdi-%d", in.VMID) || fmt.Sprint(config["template"]) == "1" || config["lock"] != nil) { + err = errors.New("克隆目标配置尚未确认,请在 PVE 核对") + } + if err == nil { + power, err = pve.Power(ctx, template.Node, in.VMID) + } + if err == nil && power != "running" && power != "stopped" { + err = errors.New("目标虚拟机状态未确认") + } + } + if err == nil { + err = s.commitProvision(ctx, task, job, template, in, power) + } + if err == nil { + return + } + log.Printf("provision execution error taskId=%s error=%v", task.ID, err) + final, done := context.WithTimeout(context.Background(), 5*time.Second) + defer done() + state, status, message := "failed", "failed", "创建未执行:"+err.Error() + if uncertain { + state = "uncertain" + status = "timeout" + message = "创建结果未确认,请在 PVE 核对目标 VMID 和任务;不会自动重复克隆" + } + if errors.Is(err, proxmox.ErrTaskFailed) { + status = "failed" + message = "PVE 克隆任务失败,请核对目标 VMID 是否有残留资源;未自动删除或重试" + } + _, _ = s.DB.ExecContext(final, "UPDATE provisioning_jobs SET state=$1 WHERE task_id=$2 AND state='reserved'", state, task.ID) + _ = s.complete(final, task, status, message) +} +func (s *Server) commitProvision(ctx context.Context, task model.Task, job provisioningJob, template model.ImageTemplate, in cloneInput, power string) error { + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = catalogLock(ctx, tx); err != nil { + return err + } + var poolName string + if job.PoolID != nil { + if err = tx.GetContext(ctx, &poolName, "SELECT name FROM desktop_pools WHERE id=$1", *job.PoolID); err != nil { + return err + } + } + _, err = tx.ExecContext(ctx, `INSERT INTO desktops(id,name,os,node,vmid,pool,pool_id,power_state,provider,setup_required) VALUES($1,$2,$3,$4,$5,$6,$7,$8,'proxmox',true)`, job.DesktopID, in.Name, template.OS, template.Node, in.VMID, poolName, job.PoolID, power) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, "UPDATE provisioning_jobs SET state='created' WHERE task_id=$1", task.ID); err != nil { + return err + } + result, err := tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='VM 克隆完成并已纳管;尚需配置系统身份、IP、Sunshine 与终端配对',updated_at=now() WHERE id=$1 AND status='running'", task.ID) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n != 1 { + return errors.New("创建任务状态已变化,保留预留资源等待核对") + } + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'clone',$3,'success',$4,now())", store.ID("audit_"), task.Actor, task.TargetName, task.ID) + if err != nil { + return err + } + return tx.Commit() +} diff --git a/platform/internal/server/server.go b/platform/internal/server/server.go index 38ec61e..1addc08 100644 --- a/platform/internal/server/server.go +++ b/platform/internal/server/server.go @@ -12,6 +12,8 @@ import ( "net/http" "strconv" "strings" + "sync" + "sync/atomic" "time" "vdi-cloud/platform/internal/config" "vdi-cloud/platform/internal/model" @@ -20,10 +22,13 @@ import ( ) type Server struct { - Config config.Config - DB *sqlx.DB - PVE *proxmox.Client - Router *gin.Engine + Config config.Config + DB *sqlx.DB + PVE *proxmox.Client + Router *gin.Engine + computeMu sync.Mutex + computeRevision string + computeRuntime atomic.Pointer[proxmox.Client] } type claims struct { AdminID string `json:"adminId"` @@ -34,6 +39,7 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server { gin.SetMode(gin.ReleaseMode) s := &Server{Config: cfg, DB: db, PVE: pve} r := gin.New() + _ = r.SetTrustedProxies(cfg.TrustedProxies) r.Use(gin.Recovery(), func(c *gin.Context) { c.Set("requestId", store.ID("req_")) c.Header("X-Request-ID", c.GetString("requestId")) @@ -56,32 +62,90 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server { t := r.Group("/api/v1/terminal", s.deviceAuth) t.POST("/heartbeat", s.heartbeat) t.GET("/config", s.deviceConfig) + t.POST("/config/ack", s.policyAck) t.GET("/desktops", s.deviceDesktops) t.GET("/commands", s.deviceCommands) t.POST("/commands/:id/ack", s.deviceAck) t.POST("/connect-ticket", s.createTicket) t.POST("/connect-ticket/consume", s.consumeTicket) t.POST("/session", s.deviceSession) - a := r.Group("/api/v1", s.adminAuth) + a := r.Group("/api/v1", s.adminAuth, s.refreshComputeRequest) a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) }) a.POST("/auth/logout", s.logout) + a.POST("/auth/password", s.changeOwnPassword) + a.GET("/administrators", s.allow("super_admin"), s.administratorList) + a.POST("/administrators", s.allow("super_admin"), s.createAdministrator) + a.PUT("/administrators/:id", s.allow("super_admin"), s.updateAdministrator) + a.POST("/administrators/:id/password", s.allow("super_admin"), s.resetAdministratorPassword) a.GET("/overview", s.overview) + a.GET("/sessions/history", s.sessionHistory) + a.GET("/monitor/series", s.resourceSeries) + a.GET("/monitor/rules", s.alertRules) + a.POST("/monitor/rules", s.allow("super_admin"), s.saveAlertRule) + a.PUT("/monitor/rules/:id", s.allow("super_admin"), s.saveAlertRule) + a.GET("/monitor/alerts", s.alerts) + a.POST("/monitor/alerts/:id/acknowledge", s.allow("super_admin", "operator"), s.acknowledgeAlert) + a.POST("/imports/preview", s.allow("super_admin"), s.previewImport) + a.POST("/imports/commit", s.allow("super_admin"), s.commitImport) a.GET("/settings", s.settings) + a.GET("/settings/pve", s.allow("super_admin"), s.getPVEConnection) + a.POST("/settings/pve/check", s.allow("super_admin"), s.checkPVEConnection) + a.PUT("/settings/pve", s.allow("super_admin"), s.savePVEConnection) + a.GET("/desktops/:id/hardware", s.desktopHardware) + a.GET("/desktops/:id/snapshots", s.snapshotView) + a.POST("/desktops/:id/snapshots/check", s.allow("super_admin"), s.checkSnapshot) + a.POST("/desktops/:id/snapshots/actions", s.allow("super_admin"), s.queueSnapshot) + a.POST("/tasks/:id/snapshot-reconcile", s.allow("super_admin"), s.reconcileSnapshot) + a.POST("/desktops/:id/hardware/check", s.allow("super_admin"), s.checkHardware) + a.POST("/desktops/:id/hardware", s.allow("super_admin"), s.queueHardware) a.GET("/resources", s.resources) + a.GET("/resources/gpus", s.gpuInventory) a.GET("/resources/vms", s.discoverVMs) + a.GET("/resources/templates", s.discoverTemplates) + a.GET("/resources/storage", s.storageList) + a.GET("/templates", s.templateList) + a.POST("/templates", s.allow("super_admin"), s.registerTemplate) + a.PATCH("/templates/:id", s.allow("super_admin"), s.updateTemplate) + a.POST("/templates/:id/validate", s.allow("super_admin"), s.validateTemplate) + a.GET("/pools", s.poolList) + a.POST("/pools", s.allow("super_admin"), s.savePool) + a.PUT("/pools/:id", s.allow("super_admin"), s.savePool) + a.PUT("/pools/:id/members", s.allow("super_admin"), s.poolMembers) + a.POST("/desktops/clone/check", s.allow("super_admin"), s.checkClone) + a.POST("/desktops/clone", s.allow("super_admin"), s.cloneDesktop) a.POST("/resources/sync", s.allow("super_admin"), s.syncResources) + a.GET("/desktop-batches", s.powerBatches) + a.GET("/desktop-batches/:id", s.getPowerBatch) + a.POST("/desktop-batches/check", s.allow("super_admin", "operator"), s.checkPowerBatch) + a.POST("/desktop-batches", s.allow("super_admin", "operator"), s.queuePowerBatch) + a.POST("/tasks/:id/batch-power-reconcile", s.allow("super_admin", "operator"), s.reconcileBatchPower) a.GET("/desktops", s.desktops) 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/actions", s.allow("super_admin", "operator"), s.desktopAction) a.GET("/terminals", s.terminals) + a.GET("/terminals/:id/metrics", s.terminalMetrics) + a.GET("/terminal-policy-templates", s.policyTemplates) + a.POST("/terminal-policy-templates", s.allow("super_admin"), s.createPolicyTemplate) + a.GET("/terminal-policy-states", s.policyStates) + a.GET("/terminal-policy-deployments", s.policyDeployments) + a.POST("/terminal-policy-deployments", s.allow("super_admin"), s.deployPolicy) + a.GET("/terminal-policy-deployments/:id/results", s.policyResults) a.POST("/terminals", s.allow("super_admin"), s.enroll) + a.PUT("/terminals/:id", s.allow("super_admin"), s.updateTerminal) + a.POST("/terminals/:id/disable", s.allow("super_admin"), s.disableTerminal) + a.POST("/terminals/:id/reactivate", s.allow("super_admin"), s.reactivateTerminal) a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand) a.GET("/users", s.users) a.POST("/users", s.allow("super_admin"), s.createUser) + a.PUT("/users/:id", s.allow("super_admin"), s.updateUser) + a.POST("/users/batch-bindings", s.allow("super_admin"), s.batchBindings) a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings) a.GET("/tasks", s.tasks) a.GET("/tasks/:id", s.task) + a.POST("/tasks/:id/hardware-reconcile", s.allow("super_admin"), s.reconcileHardware) + a.POST("/tasks/:id/reconcile", s.allow("super_admin"), s.reconcileProvision) a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics) a.GET("/audit-logs", s.audits) r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") }) @@ -139,7 +203,7 @@ func (s *Server) adminAuth(c *gin.Context) { return } var a model.Administrator - err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID) + err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND a.enabled AND x.expires_at>now()`, cl.ID, cl.AdminID) if err != nil { s.fail(c, 401, "unauthorized", "登录已失效,请重新登录") return @@ -156,8 +220,18 @@ func (s *Server) login(c *gin.Context) { if !s.decode(c, &input) { return } + if !s.allowLoginAttempt(c, input.Username) { + return + } var a model.Administrator - err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username) + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + // Serialize session creation against account disable, role changes and password reset. + err = tx.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1 AND enabled FOR SHARE", input.Username) if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil { s.fail(c, 401, "invalid_credentials", "用户名或密码错误") return @@ -170,7 +244,11 @@ func (s *Server) login(c *gin.Context) { s.dbError(c, err) return } - if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil { + if _, err = tx.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil { + s.dbError(c, err) + return + } + if err = tx.Commit(); err != nil { s.dbError(c, err) return } diff --git a/platform/internal/server/server_test.go b/platform/internal/server/server_test.go index f61e9c2..06295d9 100644 --- a/platform/internal/server/server_test.go +++ b/platform/internal/server/server_test.go @@ -24,7 +24,8 @@ type fixture struct { t *testing.T } -func newFixture(t *testing.T) *fixture { +func newFixture(t *testing.T) *fixture { return newFixtureWorker(t, true) } +func newFixtureWorker(t *testing.T, worker bool) *fixture { t.Helper() url := os.Getenv("VDI_TEST_DATABASE_URL") if url == "" { @@ -35,7 +36,7 @@ func newFixture(t *testing.T) *fixture { if err != nil { t.Fatal(err) } - if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil { + 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 { t.Fatal(err) } if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil { @@ -43,8 +44,14 @@ func newFixture(t *testing.T) *fixture { } s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil) ctx, cancel := context.WithCancel(ctx) - go s.RunWorker(ctx) - t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() }) + workerDone := make(chan struct{}) + go func() { + defer close(workerDone) + if worker { + s.RunWorker(ctx) + } + }() + t.Cleanup(func() { cancel(); <-workerDone; db.Close() }) return &fixture{s, db, t} } func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) { @@ -166,6 +173,10 @@ func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) { t.Fatal("missing allowed desktops") } f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'") + f.db.Exec("UPDATE desktops SET setup_required=true WHERE id='desk_demo_windows'") + status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") + expect(t, 409, status, out) + f.db.Exec("UPDATE desktops SET setup_required=false WHERE id='desk_demo_windows'") status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") expect(t, 201, status, out) ticket := out["ticket"].(string) diff --git a/platform/internal/server/session_history.go b/platform/internal/server/session_history.go new file mode 100644 index 0000000..d4675d2 --- /dev/null +++ b/platform/internal/server/session_history.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "vdi-cloud/platform/internal/store" +) + +type sessionRecord struct { + ID string `json:"id" db:"id"` + SessionID string `json:"sessionId" db:"session_id"` + TerminalID string `json:"terminalId" db:"terminal_id"` + TerminalName string `json:"terminalName" db:"terminal_name"` + DesktopID string `json:"desktopId" db:"desktop_id"` + DesktopName string `json:"desktopName" db:"desktop_name"` + Username string `json:"username" db:"username"` + DisplayName string `json:"displayName" db:"display_name"` + State string `json:"state" db:"state"` + Source string `json:"source" db:"source"` + ReservedAt time.Time `json:"reservedAt" db:"reserved_at"` + ConnectedAt *time.Time `json:"connectedAt" db:"connected_at"` + LastReportedAt time.Time `json:"lastReportedAt" db:"last_reported_at"` + EndedAt *time.Time `json:"endedAt" db:"ended_at"` + EndReason string `json:"endReason" db:"end_reason"` +} + +func startSessionRecord(ctx context.Context, tx *sqlx.Tx, terminal, session, desktop, state, source string) error { + _, err := tx.ExecContext(ctx, `INSERT INTO session_history(id,session_id,terminal_id,terminal_name,desktop_id,desktop_name,username,display_name,state,source,connected_at) + SELECT $1,$2,t.id,t.name,d.id,d.name,COALESCE(u.username,''),COALESCE(u.display_name,''),$5,$6,CASE WHEN $5='connected' THEN now() ELSE NULL END + FROM terminals t JOIN desktops d ON d.id=$4 LEFT JOIN users u ON u.terminal_id=t.id WHERE t.id=$3`, store.ID("history_"), session, terminal, desktop, state, source) + return err +} +func closeSessionRecords(ctx context.Context, tx *sqlx.Tx, terminal, reason string) error { + _, err := tx.ExecContext(ctx, "UPDATE session_history SET state='interrupted',ended_at=now(),end_reason=$2 WHERE terminal_id=$1 AND ended_at IS NULL", terminal, reason) + return err +} +func pageParams(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if page < 1 { + page = 1 + } + if page > 1000000 { + page = 1000000 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} +func timeRange(c *gin.Context, maxDays int) (time.Time, time.Time, bool) { + to := time.Now().UTC() + from := to.Add(-24 * time.Hour) + var err error + if v := c.Query("to"); v != "" { + to, err = time.Parse(time.RFC3339, v) + if err != nil { + return from, to, false + } + from = to.Add(-24 * time.Hour) + } + if v := c.Query("from"); v != "" { + from, err = time.Parse(time.RFC3339, v) + if err != nil { + return from, to, false + } + } + return from, to, to.After(from) && to.Sub(from) <= time.Duration(maxDays)*24*time.Hour +} +func (s *Server) sessionHistory(c *gin.Context) { + from, to, ok := timeRange(c, 90) + if !ok { + s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 90 天") + return + } + state := c.Query("status") + if state != "" && state != "connecting" && state != "connected" && state != "ended" && state != "interrupted" { + s.fail(c, 400, "invalid_request", "会话状态无效") + return + } + page, size := pageParams(c) + filter := ` FROM session_history WHERE reserved_at>=$1 AND reserved_at<=$2 AND ($3='' OR state=$3) + AND (terminal_name ILIKE $4 OR desktop_name ILIKE $4 OR username ILIKE $4 OR display_name ILIKE $4)` + args := []any{from, to, state, "%" + c.Query("keyword") + "%"} + var total int + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if err = tx.GetContext(c.Request.Context(), &total, "SELECT count(*)"+filter, args...); err != nil { + s.dbError(c, err) + return + } + items := []sessionRecord{} + args = append(args, size, (page-1)*size) + if err = tx.SelectContext(c.Request.Context(), &items, "SELECT *"+filter+" ORDER BY reserved_at DESC,id LIMIT $5 OFFSET $6", args...); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "requestId": c.GetString("requestId")}) +} diff --git a/platform/internal/server/snapshots.go b/platform/internal/server/snapshots.go new file mode 100644 index 0000000..6c19974 --- /dev/null +++ b/platform/internal/server/snapshots.go @@ -0,0 +1,554 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type snapshotInput struct { + Action string `json:"action" binding:"required,oneof=create delete rollback"` + Name string `json:"name" binding:"required,max=40"` + Description string `json:"description" binding:"max=500"` + Digest string `json:"digest" binding:"required,max=80"` + Revision string `json:"revision" binding:"max=80"` + ConfirmVMID int `json:"confirmVmid"` +} +type snapshotItem struct { + Name string `json:"name"` + Description string `json:"description"` + CreatedAt int64 `json:"createdAt"` + Parent string `json:"parent"` + IncludesMemory bool `json:"includesMemory"` + State string `json:"state"` + Revision string `json:"revision"` +} + +func snapshotRevision(item proxmox.Snapshot) string { + raw, _ := json.Marshal(item) + return store.Hash(string(raw)) +} +func snapshotConfigHash(config map[string]any) string { + // PVE refreshes vmgenid on rollback. Other fields here describe the snapshot + // tree or transient execution state, rather than the guest configuration. + skip := map[string]bool{"digest": true, "parent": true, "snaptime": true, "snapstate": true, "vmstate": true, "description": true, "snapshots": true, "pending": true, "lock": true, "running": true, "vmgenid": true, "runningmachine": true, "runningcpu": true, "running-nets-host-mtu": true} + out := map[string]any{} + for k, v := range config { + if !skip[k] { + out[k] = fmt.Sprint(v) + } + } + raw, _ := json.Marshal(out) + return store.Hash(string(raw)) +} +func snapshotBaseBlockers(h proxmox.VMHardware, config map[string]any) ([]string, []string) { + blockers := append([]string{}, h.Blockers...) + if h.PowerState != "stopped" { + blockers = append(blockers, "请先正常关闭桌面,再管理快照") + } + disks, e := proxmox.SnapshotDisks(config) + scope := []string{} + if e != nil { + blockers = append(blockers, e.Error()) + } else { + for key := range disks { + scope = append(scope, key) + } + sort.Strings(scope) + } + return blockers, scope +} +func (s *Server) snapshotView(c *gin.Context) { + ctx := c.Request.Context() + d, h, config, err := s.loadHardware(ctx, c.Param("id"), s.DB) + if err != nil { + s.fail(c, 502, "snapshots_unavailable", "无法读取真实桌面配置,请检查 PVE 接入和 VM.Audit 权限") + return + } + items, err := s.currentPVE().Snapshots(ctx, d.Node, d.VMID) + if err != nil { + s.fail(c, 502, "snapshots_unavailable", "无法读取 PVE 快照,请检查 VM.Audit 权限") + return + } + blockers, scope := snapshotBaseBlockers(h, config) + blocked, err := maintenanceBlocked(ctx, s.DB, d.ID, "", true) + if err != nil { + s.dbError(c, err) + return + } + if blocked { + blockers = append(blockers, "有桌面维护任务正在执行或结果未确认,请先核对任务") + } + permission := func(name string) bool { + ok, e := s.currentPVE().HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), name) + return e == nil && ok + } + manage, rollback := permission("VM.Snapshot"), permission("VM.Snapshot.Rollback") + out := []snapshotItem{} + for _, item := range items { + out = append(out, snapshotItem{item.Name, item.Description, item.Time, item.Parent, item.Memory != 0, item.State, snapshotRevision(item)}) + } + c.JSON(200, gin.H{"items": out, "digest": h.Digest, "powerState": h.PowerState, "blockers": blockers, "disks": scope, "canCreate": manage, "canDelete": manage, "canRestore": manage || rollback}) +} + +var snapshotName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,39}$`) + +func (s *Server) snapshotPreflight(ctx context.Context, db sqlx.ExtContext, id string, in snapshotInput, own string) (model.Desktop, string, error) { + var d model.Desktop + if !snapshotName.MatchString(in.Name) || strings.EqualFold(in.Name, "current") || strings.EqualFold(in.Name, "pending") { + return d, "", errors.New("快照名称使用字母开头的 1~40 位字母、数字、下划线或短横线,不能使用保留名称") + } + if in.Action != "create" && in.Description != "" || in.Action != "rollback" && in.ConfirmVMID != 0 { + return d, "", errors.New("请求包含不适用于当前操作的参数") + } + d, h, config, err := s.loadHardware(ctx, id, db) + if err != nil { + return d, "", errors.New("无法读取 PVE 当前配置") + } + blockers, _ := snapshotBaseBlockers(h, config) + if len(blockers) > 0 { + return d, "", errors.New(strings.Join(blockers, ";")) + } + if h.Digest != in.Digest { + return d, "", errors.New("桌面配置已变化,请重新读取快照页面") + } + blocked, err := maintenanceBlocked(ctx, db, id, own, true) + if err != nil { + return d, "", err + } + if blocked { + return d, "", errors.New("桌面有执行中任务或未确认维护结果") + } + var active bool + if err = sqlx.GetContext(ctx, db, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil { + return d, "", err + } + if active { + return d, "", errors.New("桌面有活动连接,请先结束会话") + } + pve := s.currentPVE() + permission := "VM.Snapshot" + if in.Action == "rollback" { + if in.ConfirmVMID != d.VMID { + return d, "", errors.New("请填写当前 VMID,确认回退快照内磁盘数据和虚拟机配置") + } + ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), "VM.Snapshot.Rollback") + if e != nil { + return d, "", errors.New("无法核查快照还原权限") + } + if ok { + permission = "VM.Snapshot.Rollback" + } + } + ok, err := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), permission) + if err != nil || !ok { + return d, "", errors.New("缺少 " + permission + " 权限") + } + items, err := pve.Snapshots(ctx, d.Node, d.VMID) + if err != nil { + return d, "", errors.New("无法核对快照列表") + } + var selected *proxmox.Snapshot + for i := range items { + if items[i].Name == in.Name { + selected = &items[i] + } + } + if in.Action == "create" { + if in.Revision != "" { + return d, "", errors.New("创建快照不应携带已有快照版本") + } + if selected != nil { + return d, "", errors.New("该快照名称已存在") + } + ok, err = pve.CanSnapshot(ctx, d.Node, d.VMID) + if err != nil || !ok { + return d, "", errors.New("PVE 当前磁盘或存储不支持快照") + } + return d, snapshotConfigHash(config), nil + } + if selected == nil || in.Revision == "" || in.Revision != snapshotRevision(*selected) { + return d, "", errors.New("快照已删除或发生变化,请重新读取") + } + if selected.State != "" { + return d, "", errors.New("快照状态未完成,请先在 PVE 核查") + } + if in.Action == "delete" { + for _, item := range items { + if item.Parent == in.Name { + return d, "", errors.New("该快照仍有子快照,请先处理子快照") + } + } + } + target, err := pve.SnapshotConfig(ctx, d.Node, d.VMID, in.Name) + if err != nil { + return d, "", errors.New("无法读取快照配置") + } + if in.Action == "rollback" { + if selected.Memory != 0 || fmt.Sprint(target["vmstate"]) != "" && fmt.Sprint(target["vmstate"]) != "" && fmt.Sprint(target["vmstate"]) != "0" { + return d, "", errors.New("带内存状态的快照可能自动启动 VM,本期不支持还原") + } + currentDisks, e := proxmox.SnapshotDisks(config) + if e != nil { + return d, "", e + } + targetDisks, e := proxmox.SnapshotDisks(target) + if e != nil { + return d, "", e + } + a, _ := json.Marshal(currentDisks) + b, _ := json.Marshal(targetDisks) + if string(a) != string(b) { + return d, "", errors.New("当前磁盘布局或容量与快照不同,请在 PVE 人工核查还原范围") + } + if proxmox.ConfigInt(target, "template", 0) != 0 { + return d, "", errors.New("不能将桌面还原为模板") + } + } + return d, snapshotConfigHash(target), nil +} +func (s *Server) checkSnapshot(c *gin.Context) { + var in snapshotInput + if !s.decodeConfiguration(c, &in) { + return + } + _, _, err := s.snapshotPreflight(c.Request.Context(), s.DB, c.Param("id"), in, "") + if err != nil { + s.fail(c, 409, "snapshot_not_ready", err.Error()) + return + } + c.JSON(200, gin.H{"available": true, "message": "快照状态、桌面状态与权限预检通过"}) +} +func (s *Server) queueSnapshot(c *gin.Context) { + var in snapshotInput + if !s.decodeConfiguration(c, &in) { + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if key == "" || len(key) > 100 { + s.fail(c, 400, "idempotency_required", "请携带有效请求标识") + return + } + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + if !s.lockAuthorization(c, tx) { + return + } + ctx := c.Request.Context() + raw, _ := json.Marshal(in) + hash := store.Hash(string(raw)) + var old struct { + TaskID string `db:"task_id"` + Hash string `db:"request_hash"` + } + err := tx.GetContext(ctx, &old, "SELECT j.task_id,j.request_hash FROM snapshot_jobs j JOIN tasks t ON t.id=j.task_id WHERE t.actor=$1 AND t.idempotency_key=$2", actor.Username, key) + if err == nil { + var task model.Task + if old.Hash != hash { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他快照参数") + return + } + if err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND target_id=$2", old.TaskID, c.Param("id")); err != nil { + s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他桌面") + return + } + c.JSON(202, task) + return + } + if !errors.Is(err, sql.ErrNoRows) { + s.dbError(c, err) + return + } + var id string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + d, targetHash, err := s.snapshotPreflight(ctx, tx, id, in, "") + if err != nil { + s.fail(c, 409, "snapshot_not_ready", err.Error()) + return + } + var task model.Task + err = tx.GetContext(ctx, &task, "INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'snapshot',$2,$3,$4,$5,'queued','已提交,执行前会再次预检',$6) RETURNING *", store.ID("task_"), "snapshot-"+in.Action, d.ID, d.Name, actor.Username, key) + if conflict(err) { + s.fail(c, 409, "task_conflict", "桌面已有执行中任务或请求标识已使用") + return + } + if err == nil { + _, err = tx.ExecContext(ctx, "INSERT INTO snapshot_jobs(task_id,request_json,request_hash,target_hash) VALUES($1,$2,$3,$4)", task.ID, string(raw), hash, targetHash) + } + if err == nil { + _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, task.Action, d.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(202, task) +} + +type snapshotJob struct { + Raw string `db:"request_json"` + Stage string `db:"stage"` + TargetHash string `db:"target_hash"` +} + +func (s *Server) claimSnapshot(ctx context.Context, t model.Task, in snapshotInput) (bool, error) { + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var id string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + return false, err + } + result, err := tx.ExecContext(ctx, `UPDATE snapshot_jobs SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND kind='snapshot' AND status IN ('queued','running'))`, t.ID) + if err != nil { + return false, err + } + n, _ := result.RowsAffected() + if n != 1 { + return false, nil + } + if in.Action == "rollback" { + _, err = tx.ExecContext(ctx, "UPDATE desktops SET setup_required=true,service_state='unknown',updated_at=now() WHERE id=$1", t.TargetID) + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE desktop_id=$1", t.TargetID) + } + } + if err == nil { + err = tx.Commit() + } + return err == nil, err +} +func (s *Server) confirmSnapshot(ctx context.Context, db sqlx.ExtContext, t model.Task, in snapshotInput, targetHash string) error { + if t.ProviderRef == "" { + return errors.New("缺失原 PVE 任务标识,需人工核查") + } + pve := s.currentPVE() + if pve == nil { + return errors.New("计算平台未配置") + } + var d model.Desktop + if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID); err != nil { + return err + } + task, err := pve.Task(ctx, d.Node, t.ProviderRef) + if err != nil { + return err + } + if task.Status != "stopped" || task.ExitStatus != "OK" { + return errors.New("原 PVE 任务未成功完成,请在 PVE 核查") + } + h, config, err := pve.Hardware(ctx, d.Node, d.VMID) + if err != nil { + return err + } + if h.PowerState != "stopped" || len(h.Blockers) > 0 { + return errors.New("桌面状态仍未稳定,需核查") + } + items, err := pve.Snapshots(ctx, d.Node, d.VMID) + if err != nil { + return err + } + var found *proxmox.Snapshot + for i := range items { + if items[i].Name == in.Name { + found = &items[i] + } + } + switch in.Action { + case "delete": + if found != nil { + return errors.New("快照尚未删除") + } + case "create": + if found == nil || found.Memory != 0 || found.State != "" || found.Description != in.Description { + return errors.New("新快照尚未确认") + } + snap, e := pve.SnapshotConfig(ctx, d.Node, d.VMID, in.Name) + if e != nil { + return e + } + if snapshotConfigHash(snap) != targetHash { + return errors.New("新快照配置与创建前不一致") + } + case "rollback": + if found == nil || found.Memory != 0 || found.State != "" || snapshotConfigHash(config) != targetHash { + return errors.New("还原后的配置尚未确认") + } + default: + return errors.New("未知快照操作") + } + return nil +} +func (s *Server) executeSnapshot(parent context.Context, t model.Task) { + ctx, cancel := context.WithTimeout(parent, 30*time.Minute) + defer cancel() + var job snapshotJob + err := s.DB.GetContext(ctx, &job, "SELECT request_json,stage,target_hash FROM snapshot_jobs WHERE task_id=$1", t.ID) + var in snapshotInput + if err == nil { + err = json.Unmarshal([]byte(job.Raw), &in) + } + submitted := job.Stage != "prepared" + if e := s.RefreshCompute(ctx); err == nil && e != nil { + err = e + } + if err == nil && !submitted { + var d model.Desktop + var targetHash string + d, targetHash, err = s.snapshotPreflight(ctx, s.DB, t.TargetID, in, t.ID) + if err == nil && targetHash != job.TargetHash { + err = errors.New("快照配置已变化") + } + if err == nil { + var claimed bool + claimed, err = s.claimSnapshot(ctx, t, in) + if err == nil && !claimed { + return + } + if err == nil { + submitted = true + t.ProviderRef, err = s.currentPVE().SnapshotAction(ctx, d.Node, d.VMID, in.Action, in.Name, in.Description) + var rejected *proxmox.HTTPError + if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { + submitted = false + } + if err == nil { + _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", t.ProviderRef, t.ID) + } + } + } + } + if err == nil && t.ProviderRef == "" { + err = errors.New("原 PVE 任务标识缺失") + } + if err == nil { + var d model.Desktop + err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID) + if err == nil { + err = s.currentPVE().Wait(ctx, d.Node, t.ProviderRef, time.Second) + } + } + if err == nil { + err = s.confirmSnapshot(ctx, s.DB, t, in, job.TargetHash) + } + status, stage, message := "success", "completed", "PVE 快照任务及实际状态已确认" + if in.Action == "rollback" { + message = "快照还原已确认,桌面保持关机;请重新确认 IP、系统身份、Sunshine 服务与终端配对后开放连接" + } + if err != nil { + status, stage, message = "failed", "failed", "快照操作未提交,请重新检查关机状态、快照版本、权限和存储能力" + if submitted { + status, stage, message = "timeout", "uncertain", "快照操作结果未确认,已阻止后续桌面修改和新连接;仅核对原任务,不会自动重发" + } + } + s.finishSnapshot(t, status, stage, message) +} +func (s *Server) finishSnapshot(t model.Task, status, stage, message string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return + } + defer tx.Rollback() + var id, current, jobStage string + if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + return + } + if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil || (current != "queued" && current != "running") { + return + } + if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM snapshot_jobs WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { + return + } + if stage == "expired" { + status, stage, message = "failed", "failed", "快照任务提交前过期,未发送修改" + if jobStage != "prepared" { + status, stage, message = "timeout", "uncertain", "快照任务结果未确认,请核对原任务,不会自动重发" + } + } + if _, err = tx.ExecContext(ctx, "UPDATE snapshot_jobs SET stage=$1 WHERE task_id=$2", stage, t.ID); err == nil { + err = s.completeTx(ctx, tx, t, status, message) + } + if err == nil { + _ = tx.Commit() + } +} +func (s *Server) reconcileSnapshot(c *gin.Context) { + tx, actor := s.accountTransaction(c, true) + if tx == nil { + return + } + defer tx.Rollback() + ctx := c.Request.Context() + var t model.Task + if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 AND kind='snapshot'", c.Param("id")); err != nil { + s.dbError(c, err) + return + } + var id string + if err := tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { + s.dbError(c, err) + return + } + if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil { + s.dbError(c, err) + return + } + var job snapshotJob + if err := tx.GetContext(ctx, &job, "SELECT request_json,stage,target_hash FROM snapshot_jobs WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { + s.dbError(c, err) + return + } + if t.Status != "timeout" || job.Stage != "uncertain" { + s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的快照任务") + return + } + var in snapshotInput + if json.Unmarshal([]byte(job.Raw), &in) != nil { + s.fail(c, 500, "invalid_job", "任务记录无法读取") + return + } + if err := s.confirmSnapshot(ctx, tx, t, in, job.TargetHash); err != nil { + s.fail(c, 409, "snapshot_unconfirmed", "原任务或实际状态尚未确认,请在 PVE 核查;未重新发送修改") + return + } + _, err := tx.ExecContext(ctx, "UPDATE snapshot_jobs SET stage='completed' WHERE task_id=$1", t.ID) + if err == nil { + _, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对原 PVE 任务和实际状态通过,未重新发送修改',updated_at=now() WHERE id=$1", t.ID) + } + if err == nil { + err = accountAudit(ctx, tx, actor.Username, "reconcile_snapshot", t.ID, c.GetString("requestId")) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"accepted": true}) +} diff --git a/platform/internal/server/snapshots_test.go b/platform/internal/server/snapshots_test.go new file mode 100644 index 0000000..fff31f2 --- /dev/null +++ b/platform/internal/server/snapshots_test.go @@ -0,0 +1,452 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" + "vdi-cloud/platform/pkg/proxmox" +) + +type snapshotPVE struct { + mu sync.Mutex + config map[string]any + target map[string]any + items []proxmox.Snapshot + state, outcome string + pending, denyManage, denyRestore, unsupported bool + writes int +} + +func copySnapshotConfig(c map[string]any) map[string]any { + o := map[string]any{} + for k, v := range c { + o[k] = v + } + return o +} +func fakeSnapshotPVE(t *testing.T, f *fixture) *snapshotPVE { + p := &snapshotPVE{state: "stopped", config: map[string]any{"digest": "d1", "cores": 2, "memory": 2048, "scsi0": "local:vm-9000,size=4G", "efidisk0": "local:vm-9000-efi,size=4M", "vmgenid": "old"}, items: []proxmox.Snapshot{{Name: "base", Time: 123, Description: "baseline"}}} + p.target = copySnapshotConfig(p.config) + p.target["cores"] = 1 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + var data any + switch { + case strings.Contains(path, "/tasks/"): + if p.outcome == "poll-failure" { + w.WriteHeader(503) + return + } + status, exit := "stopped", "OK" + if p.outcome == "failed" { + exit = "TASK ERROR" + } + if p.outcome == "running" { + status = "running" + } + data = map[string]any{"status": status, "exitstatus": exit} + case path == "/access/permissions": + a, b := 1, 1 + if p.denyManage { + a = 0 + } + if p.denyRestore { + b = 0 + } + data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Snapshot": a, "VM.Snapshot.Rollback": b}} + case strings.HasSuffix(path, "/status/current"): + data = map[string]any{"status": p.state} + case strings.HasSuffix(path, "/pending"): + data = []map[string]any{} + if p.pending { + data = []map[string]any{{"key": "memory", "pending": 4096}} + } + case strings.HasSuffix(path, "/feature"): + n := 1 + if p.unsupported { + n = 0 + } + data = map[string]int{"hasFeature": n} + case strings.HasSuffix(path, "/snapshot") && r.Method == "GET": + data = append([]proxmox.Snapshot{{Name: "current"}}, p.items...) + case strings.Contains(path, "/snapshot/") && strings.HasSuffix(path, "/config"): + data = p.target + case strings.HasSuffix(path, "/config") && r.Method == "GET": + data = p.config + case r.Method != "GET" && strings.Contains(path, "/snapshot"): + p.writes++ + _ = r.ParseForm() + if p.outcome == "reject" { + w.WriteHeader(403) + return + } + if p.outcome == "ambiguous" { + w.WriteHeader(500) + return + } + switch { + case strings.HasSuffix(path, "/rollback"): + if r.Method != "POST" || r.Form.Get("start") != "0" || len(r.Form) != 1 { + t.Error("unsafe rollback", r.Method, r.Form) + } + p.config = copySnapshotConfig(p.target) + p.config["vmgenid"] = "new" + p.config["digest"] = "d2" + case r.Method == "DELETE": + if len(r.Form) != 0 { + t.Error("unexpected deletion flags", r.Form) + } + p.items = []proxmox.Snapshot{} + case r.Method == "POST": + if r.Form.Get("vmstate") != "0" || len(r.Form) != 3 { + t.Error("unsafe snapshot create", r.Form) + } + p.items = append(p.items, proxmox.Snapshot{Name: r.Form.Get("snapname"), Description: r.Form.Get("description"), Time: 456}) + p.target = copySnapshotConfig(p.config) + default: + t.Error("unexpected snapshot write", r.Method, path) + } + data = "UPID:snapshot-test" + if p.outcome == "missing-upid" { + data = "" + } + default: + t.Error("unexpected snapshot request", r.Method, path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + t.Cleanup(srv.Close) + f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} + if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil { + t.Fatal(err) + } + return p +} + +const snapshotEndpoint = "/desktops/desk_demo_windows/snapshots" + +func snapshotBody(p *snapshotPVE, action string) map[string]any { + b := map[string]any{"action": action, "name": "base", "digest": "d1"} + if action == "create" { + b["name"] = "new_point" + b["description"] = "before upgrade" + } else { + b["revision"] = snapshotRevision(p.items[0]) + } + if action == "rollback" { + b["confirmVmid"] = 9000 + } + return b +} +func queueSnapshotTest(t *testing.T, f *fixture, admin string, b map[string]any, key string) model.Task { + code, out := f.request("POST", snapshotEndpoint+"/actions", admin, b, key) + expect(t, 202, code, out) + var task model.Task + if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil { + t.Fatal(err) + } + return task +} +func readSnapshotTask(t *testing.T, f *fixture, id string) model.Task { + var task model.Task + if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", id); err != nil { + t.Fatal(err) + } + return task +} +func TestSnapshotRealTaskLifecycle(t *testing.T) { + for _, action := range []string{"create", "delete", "rollback"} { + t.Run(action, func(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeSnapshotPVE(t, f) + admin := f.login("admin") + b := snapshotBody(p, action) + code, out := f.request("GET", snapshotEndpoint, f.login("viewer"), nil, "") + expect(t, 200, code, out) + if len(out["items"].([]any)) != 1 { + t.Fatal("synthetic current leaked", out) + } + for _, role := range []string{"operator", "viewer"} { + token := f.login(role) + code, out = f.request("POST", snapshotEndpoint+"/check", token, b, "") + expect(t, 403, code, out) + code, out = f.request("POST", snapshotEndpoint+"/actions", token, b, "denied") + expect(t, 403, code, out) + } + code, out = f.request("POST", snapshotEndpoint+"/check", admin, b, "") + expect(t, 200, code, out) + task := queueSnapshotTest(t, f, admin, b, "snapshot-once") + code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "snapshot-once") + expect(t, 202, code, out) + if out["id"] != task.ID { + t.Fatal("duplicate task") + } + changed := copySnapshotConfig(b) + changed["name"] = "different" + code, out = f.request("POST", snapshotEndpoint+"/actions", admin, changed, "snapshot-once") + expect(t, 409, code, out) + code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "power") + expect(t, 409, code, out) + code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}, "hw") + expect(t, 409, code, out) + code, out = f.request("PUT", "/desktops/desk_demo_windows/connection", admin, map[string]any{"endpoint": "192.0.2.90", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "") + expect(t, 409, code, out) + if action == "rollback" { + _, err := f.db.Exec("INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES('stale','term_demo_001','desk_demo_windows',now()+interval '1 minute')") + if err != nil { + t.Fatal(err) + } + } + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + if task.Status != "success" || task.ProviderRef == "" { + t.Fatal(task) + } + var stage string + _ = f.db.Get(&stage, "SELECT stage FROM snapshot_jobs WHERE task_id=$1", task.ID) + if stage != "completed" || p.writes != 1 { + t.Fatal(stage, p.writes) + } + var n int + _ = f.db.Get(&n, "SELECT count(*) FROM audit_logs WHERE action=$1", task.Action) + if n != 2 { + t.Fatal("missing submit or completion audit", n) + } + if action == "rollback" { + var setup bool + _ = f.db.Get(&setup, "SELECT setup_required FROM desktops WHERE id='desk_demo_windows'") + _ = f.db.Get(&n, "SELECT count(*) FROM connection_tickets WHERE desktop_id='desk_demo_windows'") + if !setup || n != 0 { + t.Fatal("rollback did not invalidate initialization and tickets") + } + } + }) + } +} +func TestSnapshotPreflightRestrictions(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeSnapshotPVE(t, f) + admin := f.login("admin") + base := copySnapshotConfig(p.config) + target := copySnapshotConfig(p.target) + for _, condition := range []string{"running", "pending", "lock", "passthrough", "excluded-disk", "physical", "layout", "memory-list", "memory-config", "revision", "digest", "children", "permissions", "unsupported", "confirm", "active-session", "target-template", "invalid-name", "reserved", "extra-description", "extra-confirm", "extra-revision"} { + t.Run(condition, func(t *testing.T) { + p.config = copySnapshotConfig(base) + p.target = copySnapshotConfig(target) + p.items = []proxmox.Snapshot{{Name: "base", Time: 123, Description: "baseline"}} + p.state = "stopped" + p.pending = false + p.denyManage = false + p.denyRestore = false + p.unsupported = false + b := snapshotBody(p, "rollback") + switch condition { + case "running": + p.state = "running" + case "pending": + p.pending = true + case "lock": + p.config["lock"] = "backup" + case "passthrough": + p.config["hostpci0"] = "0000:01:00.0" + case "excluded-disk": + p.config["scsi0"] = "local:vm-9000,size=4G,snapshot=0" + case "physical": + p.config["scsi0"] = "/dev/disk/by-id/test,size=4G" + case "layout": + p.target["scsi0"] = "local:vm-9000,size=8G" + case "memory-list": + p.items[0].Memory = 1 + b["revision"] = snapshotRevision(p.items[0]) + case "memory-config": + p.target["vmstate"] = "local:vm-9000-state" + case "revision": + b["revision"] = "stale" + case "digest": + b["digest"] = "stale" + case "children": + b = snapshotBody(p, "delete") + p.items = append(p.items, proxmox.Snapshot{Name: "child", Parent: "base"}) + case "permissions": + p.denyManage = true + p.denyRestore = true + case "unsupported": + b = snapshotBody(p, "create") + p.unsupported = true + case "confirm": + b["confirmVmid"] = 100 + case "active-session": + _, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'") + case "target-template": + p.target["template"] = 1 + case "invalid-name": + b["name"] = "../base" + case "reserved": + b = snapshotBody(p, "create") + b["name"] = "current" + case "extra-description": + b["description"] = "ignored?" + case "extra-confirm": + b = snapshotBody(p, "create") + b["confirmVmid"] = 9000 + case "extra-revision": + b = snapshotBody(p, "create") + b["revision"] = "extra" + } + code, out := f.request("POST", snapshotEndpoint+"/check", admin, b, "") + expect(t, 409, code, out) + code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "block-"+condition) + expect(t, 409, code, out) + _, _ = f.db.Exec("UPDATE terminals SET session_desktop_id=NULL WHERE id='term_demo_001'") + if p.writes != 0 { + t.Fatal("preflight performed mutation") + } + }) + } + p.config = base + p.target = target + p.state = "stopped" + p.denyManage = true + p.denyRestore = false + b := snapshotBody(p, "rollback") + code, out := f.request("POST", snapshotEndpoint+"/check", admin, b, "") + expect(t, 200, code, out) + p.denyManage = false + p.denyRestore = true + code, out = f.request("POST", snapshotEndpoint+"/check", admin, b, "") + expect(t, 200, code, out) + b["force"] = true + code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "strict") + expect(t, 400, code, out) +} +func TestSnapshotUnknownNeverReplayed(t *testing.T) { + for _, outcome := range []string{"reject", "ambiguous", "missing-upid", "poll-failure", "failed"} { + t.Run(outcome, func(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeSnapshotPVE(t, f) + admin := f.login("admin") + b := snapshotBody(p, "rollback") + p.outcome = outcome + task := queueSnapshotTest(t, f, admin, b, "unknown") + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + want := "timeout" + if outcome == "reject" { + want = "failed" + } + if task.Status != want { + t.Fatal(task) + } + if want == "failed" { + return + } + _, _ = f.db.Exec("UPDATE desktops SET power_state='running',setup_required=false WHERE id='desk_demo_windows'") + _, _ = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("snapshot-device")) + code, out := f.request("POST", "/terminal/connect-ticket", "snapshot-device", map[string]string{"desktopId": "desk_demo_windows"}, "") + expect(t, 409, code, out) + if out["code"] != "hardware_unconfirmed" { + t.Fatal(out) + } + code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked") + expect(t, 409, code, out) + _, _ = f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID) + f.server.execute(context.Background(), task) + task = readSnapshotTask(t, f, task.ID) + if p.writes != 1 { + t.Fatal("snapshot replayed", p.writes) + } + code, out = f.request("POST", "/tasks/"+task.ID+"/snapshot-reconcile", admin, nil, "") + expect(t, 409, code, out) + p.outcome = "" + // Reconciliation must use its own SQL transaction, even with a single connection. + f.db.SetMaxOpenConns(1) + code, out = f.request("POST", "/tasks/"+task.ID+"/snapshot-reconcile", admin, nil, "") + if outcome == "poll-failure" || outcome == "failed" { + expect(t, 200, code, out) + } else { + expect(t, 409, code, out) + } + if p.writes != 1 { + t.Fatal("reconcile mutated PVE") + } + }) + } +} +func TestSnapshotRechecksAndExpiry(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeSnapshotPVE(t, f) + admin := f.login("admin") + b := snapshotBody(p, "rollback") + task := queueSnapshotTest(t, f, admin, b, "changed") + p.target["memory"] = 4096 + f.server.execute(context.Background(), task) + if readSnapshotTask(t, f, task.ID).Status != "failed" || p.writes != 0 { + t.Fatal("changed target submitted") + } + task = queueSnapshotTest(t, f, admin, b, "prepared-expire") + f.server.finishSnapshot(task, "", "expired", "") + claimed, err := f.server.claimSnapshot(context.Background(), task, snapshotInput{Action: "rollback"}) + if err != nil || claimed { + t.Fatal("expired task could submit", err) + } + task = queueSnapshotTest(t, f, admin, b, "submitting-expire") + claimed, err = f.server.claimSnapshot(context.Background(), task, snapshotInput{Action: "rollback"}) + if err != nil || !claimed { + t.Fatal(err) + } + f.server.finishSnapshot(task, "", "expired", "") + if readSnapshotTask(t, f, task.ID).Status != "timeout" { + t.Fatal("submitting expiry released protection") + } +} +func TestSnapshotWorkerDoesNotBlockOrdinaryTasks(t *testing.T) { + f := newFixtureWorker(t, false) + p := fakeSnapshotPVE(t, f) + admin := f.login("admin") + p.outcome = "running" + task := queueSnapshotTest(t, f, admin, snapshotBody(p, "create"), "long") + 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 readSnapshotTask(t, f, task.ID).ProviderRef == "" { + if time.Now().After(deadline) { + t.Fatal("snapshot never started") + } + time.Sleep(25 * time.Millisecond) + } + code, out := f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]string{"action": "start"}, "normal") + expect(t, 202, code, out) + for readSnapshotTask(t, f, out["id"].(string)).Status != "success" { + if time.Now().After(deadline) { + t.Fatal("long snapshot blocked ordinary task") + } + time.Sleep(25 * time.Millisecond) + } + if readSnapshotTask(t, f, task.ID).Status != "running" { + t.Fatal("snapshot unexpectedly completed") + } +} +func TestSnapshotConfigHash(t *testing.T) { + a := map[string]any{"cores": 2, "memory": 2048, "vmgenid": "one", "digest": "first", "parent": "old"} + b := map[string]any{"cores": "2", "memory": "2048", "vmgenid": "two", "digest": "second", "parent": "new", "snaptime": 4} + if snapshotConfigHash(a) != snapshotConfigHash(b) { + t.Fatal("transient fields or scalar representation affect hash") + } + b["memory"] = 4096 + if snapshotConfigHash(a) == snapshotConfigHash(b) { + t.Fatal("material config change ignored") + } +} diff --git a/platform/internal/server/terminal.go b/platform/internal/server/terminal.go index bf2e4fc..ec2bbcc 100644 --- a/platform/internal/server/terminal.go +++ b/platform/internal/server/terminal.go @@ -2,8 +2,10 @@ package server import ( "crypto/subtle" + "database/sql" "encoding/json" "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" "strings" "time" "vdi-cloud/platform/internal/model" @@ -49,64 +51,142 @@ func (s *Server) register(c *gin.Context) { } token := store.ID("device_") var id string - err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret)) + err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE enabled AND sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret)) if err != nil { s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员") return } c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30}) } + +// Keep identity validation and device work under the same row lock. A revoked +// token cannot resume an in-flight request after reactivation changes the row. func (s *Server) deviceAuth(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") - var id string - if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil { + if token == "" { s.fail(c, 401, "device_unauthorized", "终端身份无效") return } + tx, err := s.DB.BeginTxx(c.Request.Context(), nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + if strings.HasSuffix(c.FullPath(), "/connect-ticket") || strings.HasSuffix(c.FullPath(), "/connect-ticket/consume") { + if !s.lockAuthorization(c, tx) { + return + } + } + var id string + if tx.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1 AND enabled FOR UPDATE", store.Hash(token)) != nil { + s.fail(c, 401, "device_unauthorized", "终端身份无效或已停用") + return + } c.Set("terminalId", id) + c.Set("deviceTx", tx) c.Next() } +func deviceTx(c *gin.Context) *sqlx.Tx { return c.MustGet("deviceTx").(*sqlx.Tx) } +func (s *Server) commitDevice(c *gin.Context) bool { + if err := deviceTx(c).Commit(); err != nil { + s.dbError(c, err) + return false + } + return true +} +func (s *Server) deviceJSON(c *gin.Context, status int, value any) { + if s.commitDevice(c) { + c.JSON(status, value) + } +} func (s *Server) heartbeat(c *gin.Context) { var in struct { - AgentVersion string `json:"agentVersion" binding:"required,max=80"` - FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"` - RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"` + AgentVersion string `json:"agentVersion" binding:"required,max=80"` + PolicyProtocol int `json:"policyProtocol" binding:"min=0,max=1"` + FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"` + RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"` + SystemMetrics *terminalSystemMetrics `json:"systemMetrics"` } if !s.decode(c, &in) { return } - _, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId")) + _, err := deviceTx(c).ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId")) if err != nil { s.dbError(c, err) return } - c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()}) + if err = saveTerminalMetrics(c.Request.Context(), deviceTx(c), c.GetString("terminalId"), in.SystemMetrics); err != nil { + s.dbError(c, err) + return + } + if _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO terminal_policy_capabilities(terminal_id,protocol) VALUES($1,$2) ON CONFLICT(terminal_id) DO UPDATE SET protocol=EXCLUDED.protocol", c.GetString("terminalId"), in.PolicyProtocol); err != nil { + s.dbError(c, err) + return + } + if _, err = deviceTx(c).ExecContext(c.Request.Context(), "UPDATE session_history SET last_reported_at=now() WHERE terminal_id=$1 AND ended_at IS NULL", c.GetString("terminalId")); err != nil { + s.dbError(c, err) + return + } + // An upgraded or temporarily unreachable device may already have a ready + // session. Start a new observed segment without inventing its original start. + if _, err = deviceTx(c).ExecContext(c.Request.Context(), `INSERT INTO session_history(id,session_id,terminal_id,terminal_name,desktop_id,desktop_name,username,display_name,state,source,connected_at) + SELECT $1,t.session_id,t.id,t.name,d.id,d.name,COALESCE(u.username,''),COALESCE(u.display_name,''),'connected','observed',now() + FROM terminals t JOIN desktops d ON d.id=t.session_desktop_id LEFT JOIN users u ON u.terminal_id=t.id + WHERE t.id=$2 AND t.session_state='connected' AND t.session_id!='' + AND NOT EXISTS(SELECT 1 FROM session_history h WHERE h.terminal_id=t.id AND h.ended_at IS NULL)`, store.ID("history_"), c.GetString("terminalId")); err != nil { + s.dbError(c, err) + return + } + s.deviceJSON(c, 200, gin.H{"accepted": true, "serverTime": time.Now().UTC()}) } func (s *Server) deviceConfig(c *gin.Context) { var version int - if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil { + if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil { s.dbError(c, err) return } - c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30}) + var item struct { + DeploymentID string `db:"deployment_id"` + HeartbeatInterval int `db:"heartbeat_interval"` + CommandPollInterval int `db:"command_poll_interval"` + DesktopRefreshInterval int `db:"desktop_refresh_interval"` + } + err := deviceTx(c).GetContext(c.Request.Context(), &item, `SELECT r.deployment_id,p.heartbeat_interval,p.command_poll_interval,p.desktop_refresh_interval + FROM terminal_policy_deliveries r JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id + WHERE r.terminal_id=$1 AND r.config_version=$2`, c.GetString("terminalId"), version) + if err != nil && err != sql.ErrNoRows { + s.dbError(c, err) + return + } + if err == sql.ErrNoRows { + item.HeartbeatInterval = 30 + item.CommandPollInterval = 3 + item.DesktopRefreshInterval = 15 + } + s.deviceJSON(c, 200, gin.H{"configVersion": version, "deploymentId": item.DeploymentID, "policyProtocol": 1, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": item.HeartbeatInterval, "commandPollInterval": item.CommandPollInterval, "desktopRefreshInterval": item.DesktopRefreshInterval}) } func (s *Server) deviceDesktops(c *gin.Context) { items := []model.Desktop{} - err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId")) + err := deviceTx(c).SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId")) if err != nil { s.dbError(c, err) return } - list(c, items) + if s.commitDevice(c) { + list(c, items) + } } func (s *Server) deviceCommands(c *gin.Context) { items := []model.Task{} - err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId")) + err := deviceTx(c).SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId")) if err != nil { s.dbError(c, err) return } - list(c, items) + if s.commitDevice(c) { + list(c, items) + } } func (s *Server) deviceAck(c *gin.Context) { var in struct { @@ -118,13 +198,13 @@ func (s *Server) deviceAck(c *gin.Context) { return } var task model.Task - err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId")) + err := deviceTx(c).GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId")) if err != nil { s.dbError(c, err) return } if task.Status == in.Status { - c.JSON(200, gin.H{"accepted": true}) + s.deviceJSON(c, 200, gin.H{"accepted": true}) return } if task.Status != "running" { @@ -141,17 +221,17 @@ func (s *Server) deviceAck(c *gin.Context) { s.dbError(c, marshalErr) return } - if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil { + if _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil { s.dbError(c, err) return } } // Agent text can contain sensitive device details. Keep the public audit message fixed. - if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil { + if err = s.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil { s.dbError(c, err) return } - c.JSON(200, gin.H{"accepted": true}) + s.deviceJSON(c, 200, gin.H{"accepted": true}) } func (s *Server) createTicket(c *gin.Context) { var in struct { @@ -161,7 +241,7 @@ func (s *Server) createTicket(c *gin.Context) { return } var allowed bool - err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId")) + err := deviceTx(c).GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId")) if err != nil { s.dbError(c, err) return @@ -171,22 +251,25 @@ func (s *Server) createTicket(c *gin.Context) { return } var d model.Desktop - if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil { + if err = deviceTx(c).GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil { s.dbError(c, err) return } - if d.PowerState != "running" || d.Endpoint == "" { + if !s.hardwareAllowsConnection(c, d.ID) { + return + } + if d.SetupRequired || d.PowerState != "running" || d.Endpoint == "" { s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址") return } token := store.ID("ticket_") expires := time.Now().Add(45 * time.Second) - _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires) + _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires) if err != nil { s.dbError(c, err) return } - c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"}) + s.deviceJSON(c, 201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"}) } func (s *Server) consumeTicket(c *gin.Context) { var in struct { @@ -196,12 +279,8 @@ func (s *Server) consumeTicket(c *gin.Context) { return } ctx := c.Request.Context() - tx, err := s.DB.BeginTxx(ctx, nil) - if err != nil { - s.dbError(c, err) - return - } - defer tx.Rollback() + tx := deviceTx(c) + var err error var row struct { TokenHash string `db:"token_hash"` TerminalID string `db:"terminal_id"` @@ -227,7 +306,10 @@ func (s *Server) consumeTicket(c *gin.Context) { s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销") return } - if d.PowerState != "running" || d.Endpoint == "" { + if !s.hardwareAllowsConnection(c, d.ID) { + return + } + if d.SetupRequired || d.PowerState != "running" || d.Endpoint == "" { s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接") return } @@ -246,13 +328,16 @@ func (s *Server) consumeTicket(c *gin.Context) { _, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID) } if err == nil { - err = tx.Commit() + err = closeSessionRecords(ctx, tx, row.TerminalID, "replaced") + } + if err == nil { + err = startSessionRecord(ctx, tx, row.TerminalID, sessionID, d.ID, "connecting", "ticket") } if err != nil { s.dbError(c, err) return } - c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"}) + s.deviceJSON(c, 200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"}) } func (s *Server) deviceSession(c *gin.Context) { @@ -267,7 +352,7 @@ func (s *Server) deviceSession(c *gin.Context) { if in.State == "ended" { query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2" } - result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID) + result, err := deviceTx(c).ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID) if err != nil { s.dbError(c, err) return @@ -277,7 +362,30 @@ func (s *Server) deviceSession(c *gin.Context) { s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代") return } - c.JSON(200, gin.H{"accepted": true}) + tx := deviceTx(c) + ctx := c.Request.Context() + if in.State == "ended" { + _, err = tx.ExecContext(ctx, "UPDATE session_history SET state='ended',ended_at=now(),last_reported_at=now(),end_reason='client_ended' WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID) + } else { + var result sql.Result + result, err = tx.ExecContext(ctx, "UPDATE session_history SET state='connected',connected_at=COALESCE(connected_at,now()),last_reported_at=now() WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID) + if err == nil { + var count int64 + count, err = result.RowsAffected() + if err == nil && count == 0 { + var desktop string + err = tx.GetContext(ctx, &desktop, "SELECT session_desktop_id FROM terminals WHERE id=$1", c.GetString("terminalId")) + if err == nil { + err = startSessionRecord(ctx, tx, c.GetString("terminalId"), in.SessionID, desktop, "connected", "observed") + } + } + } + } + if err != nil { + s.dbError(c, err) + return + } + s.deviceJSON(c, 200, gin.H{"accepted": true}) } func (s *Server) taskDiagnostics(c *gin.Context) { diff --git a/platform/internal/server/terminal_metrics.go b/platform/internal/server/terminal_metrics.go new file mode 100644 index 0000000..03a69e2 --- /dev/null +++ b/platform/internal/server/terminal_metrics.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// Values are optional: unavailable sensors are null, never fabricated zeroes. +type terminalSystemMetrics struct { + CPUPercent *float64 `json:"cpuPercent" db:"cpu_percent" binding:"omitempty,min=0,max=100"` + MemoryPercent *float64 `json:"memoryPercent" db:"memory_percent" binding:"omitempty,min=0,max=100"` + SoCTemperatureC *float64 `json:"socTemperatureC" db:"soc_temperature_c" binding:"omitempty,min=-40,max=150"` + UptimeSeconds *float64 `json:"uptimeSeconds" db:"uptime_seconds" binding:"omitempty,min=0,max=3155760000"` +} + +func saveTerminalMetrics(ctx context.Context, tx *sqlx.Tx, id string, input *terminalSystemMetrics) error { + metrics := terminalSystemMetrics{} + if input != nil { + metrics = *input + } + // Device auth already holds the terminal row lock, serializing concurrent heartbeats. + _, err := tx.ExecContext(ctx, `INSERT INTO terminal_metrics_current(terminal_id,sampled_at,supported,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds) + VALUES($1,now(),$2,$3,$4,$5,$6) ON CONFLICT(terminal_id) DO UPDATE SET + sampled_at=EXCLUDED.sampled_at,supported=EXCLUDED.supported,cpu_percent=EXCLUDED.cpu_percent, + memory_percent=EXCLUDED.memory_percent,soc_temperature_c=EXCLUDED.soc_temperature_c,uptime_seconds=EXCLUDED.uptime_seconds`, + id, input != nil, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds) + if err != nil { + return err + } + // Bound history growth even if an agent retries aggressively. Preserve null samples on downgrade. + _, err = tx.ExecContext(ctx, `INSERT INTO terminal_metric_samples(terminal_id,sampled_at,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds) + SELECT $1,now(),$2,$3,$4,$5 WHERE NOT EXISTS(SELECT 1 FROM terminal_metric_samples + WHERE terminal_id=$1 AND sampled_at>now()-interval '10 seconds')`, + id, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds) + return err +} + +type terminalMetricPoint struct { + terminalSystemMetrics + Time time.Time `json:"time" db:"time"` + Samples int `json:"samples" db:"samples"` +} + +func (s *Server) terminalMetrics(c *gin.Context) { + from, to, ok := timeRange(c, 7) + if !ok { + s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 7 天") + return + } + var latest struct { + terminalSystemMetrics + SampledAt *time.Time `json:"sampledAt" db:"sampled_at"` + Supported bool `json:"supported" db:"supported"` + Online bool `json:"online" db:"online"` + Fresh bool `json:"fresh" db:"fresh"` + } + // Query through the terminal identity so unknown devices return 404. A stopped, + // revoked or stale device keeps history but cannot present an old value as live. + err := s.DB.GetContext(c.Request.Context(), &latest, `SELECT m.sampled_at,COALESCE(m.supported,false) supported, + COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds',false) online, + COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds',false) fresh, + CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.cpu_percent END cpu_percent, + CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.memory_percent END memory_percent, + CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.soc_temperature_c END soc_temperature_c, + CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.uptime_seconds END uptime_seconds + FROM terminals t LEFT JOIN terminal_metrics_current m ON m.terminal_id=t.id WHERE t.id=$1`, c.Param("id")) + if err != nil { + s.dbError(c, err) + return + } + bucket := 60 + if to.Sub(from) > 6*time.Hour { + bucket = 300 + } + if to.Sub(from) > 24*time.Hour { + bucket = 1800 + } + points := []terminalMetricPoint{} + // Explicit empty buckets break the chart across missing heartbeats. + err = s.DB.SelectContext(c.Request.Context(), &points, `WITH data AS ( + SELECT date_bin(make_interval(secs=>$4),sampled_at,'2000-01-01'::timestamptz) time, + avg(cpu_percent) cpu_percent,avg(memory_percent) memory_percent,avg(soc_temperature_c) soc_temperature_c, + max(uptime_seconds) uptime_seconds,count(*) samples FROM terminal_metric_samples + WHERE terminal_id=$1 AND sampled_at>=$2 AND sampled_at<=$3 GROUP BY 1) + SELECT b.time,d.cpu_percent,d.memory_percent,d.soc_temperature_c,d.uptime_seconds,COALESCE(d.samples,0) samples + FROM generate_series(date_bin(make_interval(secs=>$4),$2::timestamptz,'2000-01-01'::timestamptz), + date_bin(make_interval(secs=>$4),$3::timestamptz,'2000-01-01'::timestamptz),make_interval(secs=>$4)) b(time) + LEFT JOIN data d ON d.time=b.time ORDER BY b.time`, c.Param("id"), from, to, bucket) + if err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"latest": latest, "items": points, "from": from, "to": to, "bucketSeconds": bucket, "retentionDays": 30}) +} diff --git a/platform/internal/server/terminal_metrics_test.go b/platform/internal/server/terminal_metrics_test.go new file mode 100644 index 0000000..3b80178 --- /dev/null +++ b/platform/internal/server/terminal_metrics_test.go @@ -0,0 +1,144 @@ +package server + +import ( + "fmt" + "testing" + "time" + "vdi-cloud/platform/internal/store" +) + +func TestTerminalMetricsIdentityValidationAndCompatibility(t *testing.T) { + f := newFixtureWorker(t, false) + viewer := f.login("viewer") + for _, id := range []string{"metric-a", "metric-b"} { + _, err := f.db.Exec(`INSERT INTO terminals(id,sn,name,token_hash,bootstrap_hash,last_seen,status) VALUES($1,$1,$1,$2,'',now(),'online')`, id, store.Hash(id+"-token")) + if err != nil { + t.Fatal(err) + } + } + body := map[string]any{"agentVersion": "qt-platform/0.4.0", "terminalId": "metric-b", "systemMetrics": map[string]any{"cpuPercent": 0, "memoryPercent": 30, "socTemperatureC": 44.37, "uptimeSeconds": 120}} + status, out := f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "") + expect(t, 200, status, out) + latest := out["latest"].(map[string]any) + if latest["supported"] != true || latest["fresh"] != true || latest["cpuPercent"] != float64(0) || latest["socTemperatureC"] != 44.37 { + t.Fatal(latest) + } + status, out = f.request("GET", "/terminals/metric-b/metrics", viewer, nil, "") + expect(t, 200, status, out) + if out["latest"].(map[string]any)["supported"] != false { + t.Fatal("forged terminal identity accepted", out) + } + // Repeated uploads update current state but cannot grow history without limit. + status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "") + expect(t, 200, status, out) + var count int + f.db.Get(&count, "SELECT count(*) FROM terminal_metric_samples WHERE terminal_id='metric-a'") + if count != 1 { + t.Fatal("unbounded duplicate samples", count) + } + // Session reports also advance last_seen; they must not hide a recent metric. + f.db.Exec("UPDATE terminals SET last_seen=now() WHERE id='metric-a'") + status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "") + expect(t, 200, status, out) + if out["latest"].(map[string]any)["fresh"] != true { + t.Fatal("session report hid metrics", out) + } + for _, invalid := range []map[string]any{{"cpuPercent": 101}, {"memoryPercent": -1}, {"socTemperatureC": 151}, {"uptimeSeconds": -0.1}, {"cpuPercent": "nan"}} { + status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", map[string]any{"agentVersion": "test", "systemMetrics": invalid}, "") + expect(t, 400, status, out) + } + f.db.Exec("UPDATE terminals SET last_seen=now()-interval '91 seconds' WHERE id='metric-a'") + status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "") + expect(t, 200, status, out) + latest = out["latest"].(map[string]any) + if latest["fresh"] != false || latest["online"] != false || latest["cpuPercent"] != nil || latest["socTemperatureC"] != nil { + t.Fatal("stale live value", latest) + } + status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", map[string]any{"agentVersion": "legacy"}, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "") + expect(t, 200, status, out) + latest = out["latest"].(map[string]any) + if latest["supported"] != false || latest["cpuPercent"] != nil { + t.Fatal("legacy values retained", latest) + } + admin := f.login("admin") + status, out = f.request("POST", "/terminals/metric-a/disable", admin, nil, "") + expect(t, 200, status, out) + status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "") + expect(t, 401, status, out) + status, out = f.request("POST", "/terminals/metric-a/reactivate", admin, nil, "") + expect(t, 200, status, out) + secret := out["activationSecret"] + status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "metric-a", "activationSecret": secret, "agentVersion": "test"}, "") + expect(t, 200, status, out) + status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "") + expect(t, 200, status, out) + if out["latest"].(map[string]any)["supported"] != false || out["latest"].(map[string]any)["fresh"] != false { + t.Fatal("old identity metrics retained", out) + } + status, out = f.request("GET", "/terminals/missing/metrics", viewer, nil, "") + expect(t, 404, status, out) + status, out = f.request("GET", "/terminals/metric-a/metrics", "metric-b-token", nil, "") + expect(t, 401, status, out) + status, out = f.request("GET", "/terminals/metric-a/metrics", "", nil, "") + expect(t, 401, status, out) + // A never-registered terminal has null last_seen and no current metrics. + f.db.Exec("INSERT INTO terminals(id,sn,name,bootstrap_hash) VALUES('metric-new','metric-new','new','')") + status, out = f.request("GET", "/terminals/metric-new/metrics", viewer, nil, "") + expect(t, 200, status, out) + if out["latest"].(map[string]any)["online"] != false { + t.Fatal(out) + } +} + +func TestTerminalMetricsAggregationGapsAndRetention(t *testing.T) { + f := newFixtureWorker(t, false) + viewer := f.login("viewer") + if _, err := f.db.Exec("INSERT INTO terminals(id,sn,name,bootstrap_hash) VALUES('metrics-series','metrics-series','series','')"); err != nil { + t.Fatal(err) + } + start := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute) + for _, p := range []struct{ seconds, cpu int }{{10, 20}, {30, 40}, {130, 60}} { + if _, err := f.db.Exec(`INSERT INTO terminal_metric_samples(terminal_id,sampled_at,cpu_percent) VALUES('metrics-series',$1,$2)`, start.Add(time.Duration(p.seconds)*time.Second), p.cpu); err != nil { + t.Fatal(err) + } + } + path := fmt.Sprintf("/terminals/metrics-series/metrics?from=%s&to=%s", start.Format(time.RFC3339), start.Add(3*time.Minute).Format(time.RFC3339)) + status, out := f.request("GET", path, viewer, nil, "") + expect(t, 200, status, out) + points := out["items"].([]any) + if len(points) != 4 { + t.Fatal(out) + } + first, gap, third := points[0].(map[string]any), points[1].(map[string]any), points[2].(map[string]any) + if first["cpuPercent"] != float64(30) || first["samples"] != float64(2) || first["memoryPercent"] != nil || gap["cpuPercent"] != nil || gap["samples"] != float64(0) || third["cpuPercent"] != float64(60) { + t.Fatal(points) + } + status, out = f.request("GET", "/terminals/metrics-series/metrics?from=2026-01-01T00:00:00Z&to=2026-01-09T00:00:00Z", viewer, nil, "") + expect(t, 400, status, out) + status, out = f.request("GET", "/terminals/metrics-series/metrics?from=bad", viewer, nil, "") + expect(t, 400, status, out) + for _, hours := range []int{24, 168} { + status, out = f.request("GET", fmt.Sprintf("/terminals/metrics-series/metrics?from=%s&to=%s", start.Add(-time.Duration(hours)*time.Hour).Format(time.RFC3339), start.Format(time.RFC3339)), viewer, nil, "") + expect(t, 200, status, out) + want := float64(300) + if hours == 168 { + want = 1800 + } + if out["bucketSeconds"] != want || len(out["items"].([]any)) > 400 { + t.Fatal(out) + } + } + f.db.Exec("INSERT INTO terminal_metric_samples(terminal_id,sampled_at) VALUES('metrics-series',now()-interval '31 days')") + if err := f.server.observe(t.Context()); err != nil { + t.Fatal(err) + } + var old int + f.db.Get(&old, "SELECT count(*) FROM terminal_metric_samples WHERE sampled_at=100), digest text NOT NULL, + enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(node,vmid), UNIQUE(name,version) +); +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), + template_id text REFERENCES image_templates(id), enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE desktops ADD COLUMN IF NOT EXISTS pool_id text REFERENCES desktop_pools(id); +ALTER TABLE desktops ADD COLUMN IF NOT EXISTS setup_required boolean NOT NULL DEFAULT false; +CREATE TABLE IF NOT EXISTS provisioning_jobs ( + task_id text PRIMARY KEY REFERENCES tasks(id), template_id text NOT NULL REFERENCES image_templates(id), + pool_id text REFERENCES desktop_pools(id), desktop_id text NOT NULL UNIQUE, vmid integer NOT NULL UNIQUE, + request_json text NOT NULL, request_hash text NOT NULL, state text NOT NULL DEFAULT 'reserved' + CHECK(state IN ('reserved','created','failed','uncertain')), created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS desktops_pool ON desktops(pool_id); +CREATE TABLE IF NOT EXISTS import_batches ( + id text PRIMARY KEY, actor_id text NOT NULL REFERENCES administrators(id), kind text NOT NULL, + row_count integer NOT NULL, idempotency_key text NOT NULL, request_hash text NOT NULL, result jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(actor_id,idempotency_key) +); +CREATE TABLE IF NOT EXISTS session_history ( + id text PRIMARY KEY, session_id text NOT NULL, terminal_id text NOT NULL REFERENCES terminals(id), + terminal_name text NOT NULL, desktop_id text NOT NULL REFERENCES desktops(id), desktop_name text NOT NULL, + username text NOT NULL DEFAULT '', display_name text NOT NULL DEFAULT '', + state text NOT NULL CHECK(state IN ('connecting','connected','ended','interrupted')), + source text NOT NULL CHECK(source IN ('ticket','observed')), + reserved_at timestamptz NOT NULL DEFAULT now(), connected_at timestamptz, + last_reported_at timestamptz NOT NULL DEFAULT now(), ended_at timestamptz, end_reason text NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX IF NOT EXISTS session_history_active ON session_history(terminal_id) WHERE ended_at IS NULL; +CREATE INDEX IF NOT EXISTS session_history_time ON session_history(reserved_at DESC,id); +CREATE TABLE IF NOT EXISTS resource_samples ( + resource_id text NOT NULL, sampled_at timestamptz NOT NULL DEFAULT now(), cpu double precision, + memory double precision, storage double precision, PRIMARY KEY(resource_id,sampled_at) +); +CREATE INDEX IF NOT EXISTS resource_samples_time ON resource_samples(sampled_at); +CREATE 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 +); +INSERT INTO alert_rules(id,name,metric,threshold,hold_seconds) VALUES + ('rule_cpu','节点 CPU 高水位','node_cpu',90,120),('rule_memory','节点内存高水位','node_memory',90,120), + ('rule_storage','节点存储高水位','node_storage',90,120),('rule_node','节点状态或采集异常','node_unavailable',120,0), + ('rule_terminal','终端离线','terminal_offline',180,0) ON CONFLICT(id) DO NOTHING; +CREATE TABLE IF NOT EXISTS alerts ( + id text PRIMARY KEY, rule_id text NOT NULL REFERENCES alert_rules(id), rule_name text NOT NULL, + metric text NOT NULL, target_id text NOT NULL, target_name text NOT NULL, value double precision NOT NULL, + threshold double precision NOT NULL, status text NOT NULL DEFAULT 'firing', started_at timestamptz NOT NULL, + opened_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz, resolve_reason text NOT NULL DEFAULT '', + acknowledged_at timestamptz, acknowledged_by text NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX IF NOT EXISTS alerts_open ON alerts(rule_id,target_id) WHERE status='firing'; +CREATE INDEX IF NOT EXISTS alerts_time ON alerts(opened_at DESC,id); +CREATE TABLE IF NOT EXISTS alert_states ( + rule_id text NOT NULL REFERENCES alert_rules(id), target_id text NOT NULL, first_seen timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(rule_id,target_id) +); +CREATE TABLE IF NOT EXISTS terminal_policy_templates ( + id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, + heartbeat_interval integer NOT NULL CHECK(heartbeat_interval BETWEEN 15 AND 60), + command_poll_interval integer NOT NULL CHECK(command_poll_interval BETWEEN 3 AND 30), + desktop_refresh_interval integer NOT NULL CHECK(desktop_refresh_interval BETWEEN 5 AND 120), + created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(name,version) +); +CREATE TABLE IF NOT EXISTS terminal_policy_capabilities ( + terminal_id text PRIMARY KEY REFERENCES terminals(id) ON DELETE CASCADE, + protocol integer NOT NULL DEFAULT 0 CHECK(protocol IN (0,1)) +); +CREATE TABLE IF NOT EXISTS terminal_policy_deployments ( + id text PRIMARY KEY, template_id text NOT NULL REFERENCES terminal_policy_templates(id), actor text NOT NULL, + idempotency_key text NOT NULL, request_hash text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(actor,idempotency_key) +); +CREATE TABLE IF NOT EXISTS terminal_policy_deliveries ( + id text PRIMARY KEY, deployment_id text NOT NULL REFERENCES terminal_policy_deployments(id), + terminal_id text NOT NULL REFERENCES terminals(id), config_version integer NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','applied','failed','superseded')), + error_code text NOT NULL DEFAULT '', acknowledged_at timestamptz, + UNIQUE(terminal_id,config_version), UNIQUE(deployment_id,terminal_id) +); + +CREATE TABLE IF NOT EXISTS pve_connection ( + singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), revision text NOT NULL, + encrypted_config text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS login_limits ( + key text PRIMARY KEY, window_started timestamptz NOT NULL DEFAULT now(), attempts integer NOT NULL +); +CREATE INDEX IF NOT EXISTS login_limits_time ON login_limits(window_started); +CREATE TABLE IF NOT EXISTS hardware_jobs ( + task_id text PRIMARY KEY REFERENCES tasks(id), request_json text NOT NULL, request_hash text NOT NULL, + stage text NOT NULL DEFAULT 'prepared' CHECK(stage IN ('prepared','submitting','completed','failed','uncertain')) +); +CREATE UNIQUE INDEX IF NOT EXISTS tasks_compute_target ON tasks(target_id) WHERE kind IN ('desktop','hardware') AND status IN ('queued','running'); + +CREATE TABLE IF NOT EXISTS snapshot_jobs ( + task_id text PRIMARY KEY REFERENCES tasks(id), request_json text NOT NULL, request_hash text NOT NULL, + target_hash text NOT NULL, + stage text NOT NULL DEFAULT 'prepared' CHECK(stage IN ('prepared','submitting','completed','failed','uncertain')) +); +CREATE UNIQUE INDEX IF NOT EXISTS tasks_vm_maintenance_target ON tasks(target_id) WHERE kind IN ('desktop','hardware','snapshot') AND status IN ('queued','running'); + +CREATE TABLE IF NOT EXISTS power_batches ( + id text PRIMARY KEY, actor text NOT NULL, action text NOT NULL CHECK(action IN ('start','shutdown','reboot')), + idempotency_key text NOT NULL, request_hash text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(actor,idempotency_key) +); +CREATE TABLE IF NOT EXISTS power_batch_items ( + batch_id text NOT NULL REFERENCES power_batches(id), desktop_id text NOT NULL, desktop_name text NOT NULL, + vmid integer NOT NULL, task_id text UNIQUE REFERENCES tasks(id), code text NOT NULL, message text NOT NULL, + fingerprint text NOT NULL, stage text NOT NULL CHECK(stage IN ('skipped','prepared','submitting','completed','failed','uncertain')), + PRIMARY KEY(batch_id,desktop_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS tasks_all_vm_actions_target ON tasks(target_id) WHERE kind IN ('desktop','hardware','snapshot','batch-desktop') AND status IN ('queued','running'); + +CREATE TABLE IF NOT EXISTS terminal_metrics_current ( + terminal_id text PRIMARY KEY REFERENCES terminals(id) ON DELETE CASCADE, + sampled_at timestamptz NOT NULL, supported boolean NOT NULL, + cpu_percent double precision CHECK(cpu_percent BETWEEN 0 AND 100), + memory_percent double precision CHECK(memory_percent BETWEEN 0 AND 100), + soc_temperature_c double precision CHECK(soc_temperature_c BETWEEN -40 AND 150), + uptime_seconds double precision CHECK(uptime_seconds BETWEEN 0 AND 3155760000) +); +CREATE TABLE IF NOT EXISTS terminal_metric_samples ( + terminal_id text NOT NULL REFERENCES terminals(id) ON DELETE CASCADE, + sampled_at timestamptz NOT NULL, + cpu_percent double precision CHECK(cpu_percent BETWEEN 0 AND 100), + memory_percent double precision CHECK(memory_percent BETWEEN 0 AND 100), + soc_temperature_c double precision CHECK(soc_temperature_c BETWEEN -40 AND 150), + uptime_seconds double precision CHECK(uptime_seconds BETWEEN 0 AND 3155760000), + PRIMARY KEY(terminal_id,sampled_at) +); +CREATE INDEX IF NOT EXISTS terminal_metric_samples_expiry ON terminal_metric_samples(sampled_at); diff --git a/platform/internal/store/store.go b/platform/internal/store/store.go index 87478da..92af2b1 100644 --- a/platform/internal/store/store.go +++ b/platform/internal/store/store.go @@ -69,7 +69,7 @@ func Bootstrap(ctx context.Context, db *sqlx.DB, mode, username, password string if err != nil { return err } - if _, err = db.ExecContext(ctx, "INSERT INTO administrators VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil { + if _, err = db.ExecContext(ctx, "INSERT INTO administrators(id,username,display_name,role,password_hash) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil { return err } } diff --git a/platform/pkg/proxmox/client.go b/platform/pkg/proxmox/client.go index 8fe5e6e..7ad5205 100644 --- a/platform/pkg/proxmox/client.go +++ b/platform/pkg/proxmox/client.go @@ -51,18 +51,28 @@ func New(base, id, secret, caFile string) (*Client, error) { if u.Path != "" && u.Path != "/" { return nil, errors.New("PVE_URL must not include a path") } - pool, err := x509.SystemCertPool() - if err != nil { - pool = x509.NewCertPool() - } + pem := "" if caFile != "" { data, err := os.ReadFile(caFile) if err != nil { return nil, err } - if !pool.AppendCertsFromPEM(data) { - return nil, errors.New("invalid PVE CA file") - } + pem = string(data) + } + return NewWithCA(base, id, secret, pem) +} + +func NewWithCA(base, id, secret, pem string) (*Client, error) { + u, err := url.Parse(base) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return nil, errors.New("invalid HTTPS origin") + } + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if pem != "" && !pool.AppendCertsFromPEM([]byte(pem)) { + return nil, errors.New("invalid CA certificate") } return &Client{BaseURL: strings.TrimRight(base, "/"), TokenID: id, TokenSecret: secret, HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil } diff --git a/platform/pkg/proxmox/gpu.go b/platform/pkg/proxmox/gpu.go new file mode 100644 index 0000000..c5c6e3d --- /dev/null +++ b/platform/pkg/proxmox/gpu.go @@ -0,0 +1,128 @@ +package proxmox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" +) + +type PCIDevice struct { + ID string `json:"id"` + Class string `json:"class"` + Vendor string `json:"vendor"` + VendorName string `json:"vendor_name"` + Device string `json:"device"` + DeviceName string `json:"device_name"` + IOMMUGroup *int `json:"iommugroup"` +} + +type PCIMapping struct { + ID string `json:"id"` + Map []string `json:"map"` +} + +type PendingOption struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Pending json.RawMessage `json:"pending"` + Delete json.RawMessage `json:"delete"` +} + +func (c *Client) PCIDevices(ctx context.Context, node string) ([]PCIDevice, error) { + var items []PCIDevice + err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/hardware/pci?verbose=1", nil, &items) + return items, err +} + +func (c *Client) PCIMappings(ctx context.Context) ([]PCIMapping, error) { + var items []PCIMapping + err := c.request(ctx, "GET", "/cluster/mapping/pci", nil, &items) + return items, err +} + +func (c *Client) VMPending(ctx context.Context, node string, vmid int) ([]PendingOption, error) { + var items []PendingOption + err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/pending", url.PathEscape(node), vmid), nil, &items) + return items, err +} + +var pciAddressPattern = regexp.MustCompile(`^(?:[0-9a-f]{4}:)?[0-9a-f]{2}:[0-1][0-9a-f](?:\.[0-7])?$`) +var hostPCIKeyPattern = regexp.MustCompile(`^hostpci[0-9]+$`) + +// A functionless address selects every function in that PCI slot. +func NormalizePCIAddress(value string) (string, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + if !pciAddressPattern.MatchString(value) { + return "", false + } + if strings.Count(value, ":") == 1 { + value = "0000:" + value + } + return value, true +} + +func PCIAddressMatches(selector, address string) bool { + selector, ok := NormalizePCIAddress(selector) + if !ok { + return false + } + address, ok = NormalizePCIAddress(address) + return ok && (selector == address || !strings.Contains(selector, ".") && strings.HasPrefix(address, selector+".")) +} + +func (d PCIDevice) IsGPU() bool { + class := strings.TrimPrefix(strings.ToLower(d.Class), "0x") + return len(class) == 6 && strings.HasPrefix(class, "03") +} + +type HostPCI struct { + Addresses []string + Mapping string + Mediated bool +} + +func IsHostPCIKey(key string) bool { return hostPCIKeyPattern.MatchString(key) } + +func PCIProperties(value string) (map[string]string, error) { + properties := map[string]string{} + for i, part := range strings.Split(value, ",") { + key, val, found := strings.Cut(strings.TrimSpace(part), "=") + if !found && i == 0 { + key, val = "host", key + } else if !found { + return nil, errors.New("invalid PCI property") + } + if _, duplicate := properties[key]; key == "" || val == "" || duplicate { + return nil, errors.New("invalid PCI property") + } + properties[key] = val + } + return properties, nil +} + +func ParseHostPCI(value string) (HostPCI, error) { + var out HostPCI + properties, err := PCIProperties(value) + if err != nil { + return out, err + } + out.Mapping = properties["mapping"] + out.Mediated = properties["mdev"] != "" + if (properties["host"] == "") == (out.Mapping == "") { + return out, errors.New("expected a host or mapping") + } + if properties["host"] != "" { + for _, address := range strings.Split(properties["host"], ";") { + normalized, ok := NormalizePCIAddress(address) + if !ok { + return out, errors.New("invalid PCI address") + } + out.Addresses = append(out.Addresses, normalized) + } + } + return out, nil +} diff --git a/platform/pkg/proxmox/gpu_test.go b/platform/pkg/proxmox/gpu_test.go new file mode 100644 index 0000000..f895cc4 --- /dev/null +++ b/platform/pkg/proxmox/gpu_test.go @@ -0,0 +1,78 @@ +package proxmox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPCIAddressAndHostPCIParsing(t *testing.T) { + for _, value := range []string{"01:00.0", "0000:01:00.0", " 0000:01:00.0 "} { + got, ok := NormalizePCIAddress(value) + if !ok || got != "0000:01:00.0" { + t.Fatalf("normalization %q: %q %v", value, got, ok) + } + } + for _, value := range []string{"01:20.0", "01:00.8", "../../config", "01:00.0,pcie=1", "000000:01:00.0"} { + if _, ok := NormalizePCIAddress(value); ok { + t.Fatalf("accepted invalid address %q", value) + } + } + if !PCIAddressMatches("01:00", "0000:01:00.1") || PCIAddressMatches("01:00.0", "0000:01:00.1") || PCIAddressMatches("0001:01:00", "0000:01:00.0") { + t.Fatal("function/domain matching incorrect") + } + parsed, err := ParseHostPCI("host=01:00;0001:02:00.0,pcie=1,x-vga=1") + if err != nil || len(parsed.Addresses) != 2 || parsed.Addresses[0] != "0000:01:00" { + t.Fatalf("parse multi-function PCI: %+v %v", parsed, err) + } + parsed, err = ParseHostPCI("mapping=gpu-pool,mdev=nvidia-1") + if err != nil || parsed.Mapping != "gpu-pool" || !parsed.Mediated { + t.Fatalf("parse mediated mapping: %+v %v", parsed, err) + } + for _, value := range []string{"", "mapping=x,host=01:00", "host=01:00,host=02:00", "host=invalid", "mapping=", "pcie=1"} { + if _, err := ParseHostPCI(value); err == nil { + t.Fatalf("accepted invalid hostpci %q", value) + } + } +} + +func TestGPUReadOnlyPVEContract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("unexpected write: %s", r.Method) + } + var data any + switch r.URL.Path { + case "/api2/json/nodes/node-a/hardware/pci": + if r.URL.Query().Get("verbose") != "1" { + t.Error("missing verbose PCI properties") + } + data = []map[string]any{{"id": "0000:00:02.0", "class": "0x030000", "iommugroup": 0}, {"id": "0000:01:00.1", "class": "0x040300", "iommugroup": 2}} + case "/api2/json/nodes/node-a/qemu/8000/pending": + data = []map[string]any{{"key": "hostpci0", "value": "01:00.0", "pending": "02:00.0", "delete": 1}} + case "/api2/json/cluster/mapping/pci": + data = []map[string]any{{"id": "gpu-map", "map": []string{"node=node-a,path=0000:01:00.0,id=10de:1234"}}} + default: + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + devices, err := c.PCIDevices(context.Background(), "node-a") + if err != nil || len(devices) != 2 || !devices[0].IsGPU() || devices[1].IsGPU() || devices[0].IOMMUGroup == nil || *devices[0].IOMMUGroup != 0 { + t.Fatalf("PCI decode: %+v %v", devices, err) + } + pending, err := c.VMPending(context.Background(), "node-a", 8000) + if err != nil || len(pending) != 1 || string(pending[0].Delete) != "1" { + t.Fatalf("pending decode: %+v %v", pending, err) + } + mappings, err := c.PCIMappings(context.Background()) + if err != nil || len(mappings) != 1 || len(mappings[0].Map) != 1 { + t.Fatalf("mapping decode: %+v %v", mappings, err) + } +} diff --git a/platform/pkg/proxmox/hardware.go b/platform/pkg/proxmox/hardware.go new file mode 100644 index 0000000..f0dad84 --- /dev/null +++ b/platform/pkg/proxmox/hardware.go @@ -0,0 +1,147 @@ +package proxmox + +import ( + "context" + "errors" + "fmt" + "math" + "net/url" + "regexp" + "sort" + "strconv" + "strings" +) + +type VMDisk struct { + Key string `json:"key"` + Storage string `json:"storage"` + SizeGiB float64 `json:"sizeGiB"` +} +type VMHardware struct { + Cores int `json:"cores"` + Sockets int `json:"sockets"` + MemoryMiB int `json:"memoryMiB"` + Digest string `json:"digest"` + Disks []VMDisk `json:"disks"` + PowerState string `json:"powerState"` + Blockers []string `json:"blockers"` + Balloon int `json:"-"` +} + +func ConfigInt(config map[string]any, key string, fallback int) int { + value, ok := config[key] + if !ok { + return fallback + } + n, err := strconv.Atoi(fmt.Sprint(value)) + if err != nil { + return fallback + } + return n +} +func ReadHardware(config map[string]any) (VMHardware, error) { + out := VMHardware{Cores: ConfigInt(config, "cores", 1), Sockets: ConfigInt(config, "sockets", 1), MemoryMiB: ConfigInt(config, "memory", 512), Balloon: ConfigInt(config, "balloon", 0), Disks: []VMDisk{}, Blockers: []string{}} + out.Digest, _ = config["digest"].(string) + if out.Digest == "" { + return out, errors.New("配置摘要缺失") + } + if ConfigInt(config, "template", 0) == 1 { + out.Blockers = append(out.Blockers, "模板不能在桌面规格页修改") + } + if lock := config["lock"]; lock != nil && fmt.Sprint(lock) != "" { + out.Blockers = append(out.Blockers, "虚拟机处于 PVE 锁定状态") + } + for key, value := range config { + if strings.HasPrefix(key, "numa") && key != "numa" { + out.Blockers = append(out.Blockers, "自定义 NUMA 拓扑请在 PVE 维护") + } + if !regexp.MustCompile(`^(scsi|sata|virtio|ide)\d+$`).MatchString(key) { + continue + } + text := fmt.Sprint(value) + if strings.Contains(text, "media=cdrom") || strings.Contains(text, "cloudinit") { + continue + } + volume := strings.Split(text, ",")[0] + storage, _, ok := strings.Cut(volume, ":") + if !ok || storage == "" { + continue + } + for _, part := range strings.Split(text, ",") { + if !strings.HasPrefix(part, "size=") { + continue + } + size := strings.TrimPrefix(part, "size=") + if len(size) == 0 { + continue + } + factor := float64(1) + if i := strings.IndexByte("KMGT", size[len(size)-1]); i >= 0 { + factor = math.Pow(1024, float64(i+1)) + size = size[:len(size)-1] + } + n, e := strconv.ParseFloat(size, 64) + if e == nil && n > 0 && !math.IsInf(n, 0) && !math.IsNaN(n) { + out.Disks = append(out.Disks, VMDisk{key, storage, n * factor / (1 << 30)}) + } + } + } + sort.Slice(out.Disks, func(i, j int) bool { return out.Disks[i].Key < out.Disks[j].Key }) + return out, nil +} +func (c *Client) Hardware(ctx context.Context, node string, vmid int) (VMHardware, map[string]any, error) { + var config map[string]any + path := fmt.Sprintf("/nodes/%s/qemu/%d", url.PathEscape(node), vmid) + err := c.request(ctx, "GET", path+"/config?current=1", nil, &config) + if err != nil { + return VMHardware{}, nil, err + } + hardware, err := ReadHardware(config) + if err != nil { + return hardware, config, err + } + hardware.PowerState, err = c.Power(ctx, node, vmid) + if err != nil { + return hardware, config, err + } + var pending []map[string]any + err = c.request(ctx, "GET", path+"/pending", nil, &pending) + if err != nil { + return hardware, config, err + } + for _, item := range pending { + if _, ok := item["pending"]; ok || ConfigInt(item, "delete", 0) > 0 { + hardware.Blockers = append(hardware.Blockers, "存在待应用配置,请先在 PVE 处理") + break + } + } + return hardware, config, nil +} +func (c *Client) ConfigureHardware(ctx context.Context, node string, vmid, cores, sockets, memory int, digest string, current map[string]any) (string, error) { + form := url.Values{"digest": {digest}} + cpuChanged := cores != ConfigInt(current, "cores", 1) || sockets != ConfigInt(current, "sockets", 1) + if cpuChanged { + form.Set("cores", strconv.Itoa(cores)) + form.Set("sockets", strconv.Itoa(sockets)) + } + if memory != ConfigInt(current, "memory", 512) { + form.Set("memory", strconv.Itoa(memory)) + } + if _, ok := current["vcpus"]; ok && cpuChanged { + form.Set("vcpus", strconv.Itoa(cores*sockets)) + } + var upid string + err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/config", url.PathEscape(node), vmid), form, &upid) + if err == nil && upid == "" { + err = errors.New("PVE did not return a configuration task ID") + } + return upid, err +} +func (c *Client) ResizeDisk(ctx context.Context, node string, vmid int, disk string, sizeGiB int, digest string) (string, error) { + var upid string + err := c.request(ctx, "PUT", fmt.Sprintf("/nodes/%s/qemu/%d/resize", url.PathEscape(node), vmid), url.Values{"disk": {disk}, "size": {strconv.Itoa(sizeGiB) + "G"}, "digest": {digest}}, &upid) + if err == nil && upid == "" { + err = errors.New("PVE did not return a resize task ID") + } + return upid, err +} diff --git a/platform/pkg/proxmox/hardware_test.go b/platform/pkg/proxmox/hardware_test.go new file mode 100644 index 0000000..b4c700c --- /dev/null +++ b/platform/pkg/proxmox/hardware_test.go @@ -0,0 +1,35 @@ +package proxmox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHardwareDisksExcludeDevicesAndDetectUnsafeTopology(t *testing.T) { + h, err := ReadHardware(map[string]any{"digest": "abc", "cores": 4, "memory": 4096, "scsi0": "local:vm-1,size=60G", "virtio1": "fast:vm-1,size=1T", "sata0": "/dev/disk/by-id/physical,size=60G", "ide2": "local:iso/boot.iso,media=cdrom,size=2G", "ide0": "local:cloudinit,size=4M", "efidisk0": "local:efi,size=4M", "tpmstate0": "local:tpm,size=4M", "numa0": "cpus=0-3,hostnodes=0", "lock": "backup"}) + if err != nil || len(h.Disks) != 2 || h.Disks[0].SizeGiB != 60 || h.Disks[1].SizeGiB != 1024 || len(h.Blockers) != 2 { + t.Fatal(h, err) + } + if _, err = ReadHardware(map[string]any{}); err == nil { + t.Fatal("missing digest accepted") + } +} + +func TestHardwareMemoryOnlyDoesNotRequireCPUPermissions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.Method != "POST" || len(r.Form) != 2 || r.Form.Get("memory") != "4096" || r.Form.Get("digest") != "original" { + t.Error("unexpected mutation", r.Method, r.Form) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": "UPID:memory-only"}) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL, HTTP: srv.Client()} + _, err := c.ConfigureHardware(context.Background(), "node", 1, 2, 1, 4096, "original", map[string]any{"cores": 2, "sockets": 1, "memory": 2048, "vcpus": 2}) + if err != nil { + t.Fatal(err) + } +} diff --git a/platform/pkg/proxmox/provision.go b/platform/pkg/proxmox/provision.go new file mode 100644 index 0000000..48afc11 --- /dev/null +++ b/platform/pkg/proxmox/provision.go @@ -0,0 +1,123 @@ +package proxmox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/url" + "regexp" + "strconv" + "strings" +) + +type Storage struct { + Name string `json:"storage"` + Available float64 `json:"avail"` + Active int `json:"active"` + Enabled int `json:"enabled"` + Content string `json:"content"` +} + +func (c *Client) VMConfig(ctx context.Context, node string, vmid int) (map[string]any, error) { + var config map[string]any + err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/config", url.PathEscape(node), vmid), nil, &config) + return config, err +} +func (c *Client) Storages(ctx context.Context, node string) ([]Storage, error) { + var items []Storage + err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/storage", nil, &items) + return items, err +} +func (c *Client) HasPermission(ctx context.Context, path, permission string) (bool, error) { + var permissions map[string]map[string]int + err := c.request(ctx, "GET", "/access/permissions?path="+url.QueryEscape(path), nil, &permissions) + return permissions[path][permission] == 1, err +} +func (c *Client) CanClone(ctx context.Context, node string, vmid int, full bool) (bool, error) { + feature := "clone" + if full { + feature = "copy" + } + var data struct { + HasFeature json.RawMessage `json:"hasFeature"` + } + err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/feature?feature=%s", url.PathEscape(node), vmid, feature), nil, &data) + return string(data.HasFeature) == "1" || string(data.HasFeature) == "true", err +} +func (c *Client) Clone(ctx context.Context, node string, source, target int, full bool, storage string) (string, error) { + form := url.Values{"newid": {strconv.Itoa(target)}, "name": {fmt.Sprintf("vdi-%d", target)}, "full": {"0"}} + if full { + form.Set("full", "1") + if storage != "" { + form.Set("storage", storage) + } + } + var upid string + err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/clone", url.PathEscape(node), source), form, &upid) + if err == nil && upid == "" { + err = errors.New("PVE did not return a clone task ID") + } + return upid, err +} + +// TemplateDisks rejects passthrough and unmeasurable disks before a clone is queued. +// It returns per-storage virtual disk sizes for a conservative capacity check. +func TemplateDisks(config map[string]any) (map[string]float64, error) { + if fmt.Sprint(config["template"]) != "1" { + return nil, errors.New("所选 VM 不是 Proxmox 模板") + } + if lock, ok := config["lock"]; ok && fmt.Sprint(lock) != "" { + return nil, errors.New("模板处于锁定状态,请等待 PVE 操作结束") + } + disks := map[string]float64{} + pattern := regexp.MustCompile(`^((scsi|sata|ide|virtio)\d+|efidisk0|tpmstate0)$`) + for key, value := range config { + if strings.HasPrefix(key, "hostpci") || strings.HasPrefix(key, "usb") || key == "args" || key == "hookscript" { + return nil, errors.New("模板包含直通设备或宿主自定义脚本,请先制作通用模板") + } + if !pattern.MatchString(key) { + continue + } + disk := fmt.Sprint(value) + if strings.Contains(disk, "media=cdrom") || strings.Contains(disk, "cloudinit") { + continue + } + volume := strings.Split(disk, ",")[0] + storage, _, ok := strings.Cut(volume, ":") + if !ok || storage == "" { + return nil, errors.New("模板包含非存储卷磁盘,不能自动克隆") + } + size := float64(0) + for _, part := range strings.Split(disk, ",") { + if !strings.HasPrefix(part, "size=") { + continue + } + text := strings.TrimPrefix(part, "size=") + if len(text) == 0 { + continue + } + multiplier := float64(1) + if index := strings.IndexByte("KMGT", text[len(text)-1]); index >= 0 { + for n := 0; n <= index; n++ { + multiplier *= 1024 + } + text = text[:len(text)-1] + } + amount, err := strconv.ParseFloat(text, 64) + if err != nil || amount <= 0 || math.IsNaN(amount) || math.IsInf(amount, 0) { + return nil, errors.New("无法识别模板磁盘容量") + } + size = amount * multiplier + } + if size <= 0 { + return nil, errors.New("模板磁盘缺少容量信息") + } + disks[storage] += size + } + if len(disks) == 0 { + return nil, errors.New("模板没有可克隆的系统磁盘") + } + return disks, nil +} diff --git a/platform/pkg/proxmox/provision_test.go b/platform/pkg/proxmox/provision_test.go new file mode 100644 index 0000000..563489a --- /dev/null +++ b/platform/pkg/proxmox/provision_test.go @@ -0,0 +1,28 @@ +package proxmox + +import "testing" + +func TestTemplateDisksRejectUnsafeSources(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]any + }{ + {"ordinary-vm", map[string]any{"template": 0, "scsi0": "local:disk,size=32G"}}, + {"locked", map[string]any{"template": 1, "lock": "clone", "scsi0": "local:disk,size=32G"}}, + {"gpu", map[string]any{"template": 1, "hostpci0": "0000:01:00.0", "scsi0": "local:disk,size=32G"}}, + {"host-disk", map[string]any{"template": 1, "scsi0": "/dev/sda,size=32G"}}, + {"unknown-size", map[string]any{"template": 1, "scsi0": "local:disk"}}, + {"nan-size", map[string]any{"template": 1, "scsi0": "local:disk,size=NaNG"}}, + {"inf-size", map[string]any{"template": 1, "scsi0": "local:disk,size=+InfG"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := TemplateDisks(tc.config); err == nil { + t.Fatal("unsafe template accepted") + } + }) + } + disks, err := TemplateDisks(map[string]any{"template": 1, "scsi0": "local:disk,size=32G", "efidisk0": "local:efi,size=4M", "tpmstate0": "local:tpm,size=4M", "ide2": "none,media=cdrom"}) + if err != nil || disks["local"] != 32*(1<<30)+8*(1<<20) { + t.Fatalf("incorrect capacity including firmware disks: %v %v", disks, err) + } +} diff --git a/platform/pkg/proxmox/snapshots.go b/platform/pkg/proxmox/snapshots.go new file mode 100644 index 0000000..785ba29 --- /dev/null +++ b/platform/pkg/proxmox/snapshots.go @@ -0,0 +1,114 @@ +package proxmox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" +) + +type Snapshot struct { + Name string `json:"name"` + Description string `json:"description"` + Time int64 `json:"snaptime"` + Parent string `json:"parent"` + Memory int `json:"vmstate"` + State string `json:"snapstate"` +} + +func vmPath(node string, vmid int) string { + return fmt.Sprintf("/nodes/%s/qemu/%d", url.PathEscape(node), vmid) +} +func (c *Client) Snapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) { + var items []Snapshot + err := c.request(ctx, "GET", vmPath(node, vmid)+"/snapshot", nil, &items) + out := []Snapshot{} + for _, s := range items { + if s.Name != "current" { + out = append(out, s) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Time == out[j].Time { + return out[i].Name < out[j].Name + } + return out[i].Time > out[j].Time + }) + return out, err +} +func (c *Client) SnapshotConfig(ctx context.Context, node string, vmid int, name string) (map[string]any, error) { + var out map[string]any + err := c.request(ctx, "GET", vmPath(node, vmid)+"/snapshot/"+url.PathEscape(name)+"/config", nil, &out) + return out, err +} +func (c *Client) CanSnapshot(ctx context.Context, node string, vmid int) (bool, error) { + var out struct { + HasFeature json.RawMessage `json:"hasFeature"` + } + err := c.request(ctx, "GET", vmPath(node, vmid)+"/feature?feature=snapshot", nil, &out) + return string(out.HasFeature) == "1" || string(out.HasFeature) == "true", err +} +func (c *Client) SnapshotAction(ctx context.Context, node string, vmid int, action, name, description string) (string, error) { + path := vmPath(node, vmid) + "/snapshot" + method := "POST" + form := url.Values{} + switch action { + case "create": + form.Set("snapname", name) + form.Set("description", description) + form.Set("vmstate", "0") + case "delete": + path += "/" + url.PathEscape(name) + method = "DELETE" + case "rollback": + path += "/" + url.PathEscape(name) + "/rollback" + form.Set("start", "0") + default: + return "", errors.New("unsupported snapshot action") + } + var upid string + err := c.request(ctx, method, path, form, &upid) + if err == nil && upid == "" { + err = errors.New("PVE did not return a snapshot task ID") + } + return upid, err +} + +// Limit automatic rollback to ordinary virtual disks. No memory, external device +// state, custom host scripts or omitted/shared disks can be restored safely here. +var snapshotDiskPattern = regexp.MustCompile(`^((scsi|sata|virtio|ide)[0-9]+|efidisk0|tpmstate0)$`) + +func SnapshotDisks(config map[string]any) (map[string]string, error) { + out := map[string]string{} + for key, value := range config { + if strings.HasPrefix(key, "hostpci") || strings.HasPrefix(key, "usb") || strings.HasPrefix(key, "unused") || key == "args" || key == "hookscript" { + return nil, errors.New("包含直通设备、未使用磁盘或宿主自定义配置,请在 PVE 人工管理快照") + } + if !snapshotDiskPattern.MatchString(key) { + continue + } + disk := fmt.Sprint(value) + if strings.Contains(disk, "media=cdrom") || strings.Contains(disk, "cloudinit") { + continue + } + volume := strings.Split(disk, ",")[0] + storage, _, ok := strings.Cut(volume, ":") + if !ok || storage == "" || strings.ContainsAny(storage, "/\\") { + return nil, errors.New("包含物理磁盘或无法识别的卷,请在 PVE 人工管理快照") + } + for _, option := range strings.Split(disk, ",")[1:] { + if option == "snapshot=0" || option == "shared=1" { + return nil, errors.New("存在不参与快照或共享的磁盘,不能保证完整还原") + } + } + out[key] = disk + } + if len(out) == 0 { + return nil, errors.New("没有可管理快照的虚拟磁盘") + } + return out, nil +} diff --git a/platform/pkg/proxmox/snapshots_test.go b/platform/pkg/proxmox/snapshots_test.go new file mode 100644 index 0000000..910caff --- /dev/null +++ b/platform/pkg/proxmox/snapshots_test.go @@ -0,0 +1,27 @@ +package proxmox + +import "testing" + +func TestSnapshotDiskScope(t *testing.T) { + base := map[string]any{"scsi0": "local:vm-9000,size=4G", "efidisk0": "local:vm-9000-efi,size=4M", "tpmstate0": "local:vm-9000-tpm,size=4M", "ide2": "local:iso/setup.iso,media=cdrom", "ide0": "local:vm-9000-cloudinit,media=cdrom"} + disks, err := SnapshotDisks(base) + if err != nil || len(disks) != 3 { + t.Fatal(disks, err) + } + for _, key := range []string{"hostpci0", "usb0", "unused0", "args", "hookscript"} { + base[key] = "test" + if _, err = SnapshotDisks(base); err == nil { + t.Fatal("accepted external state", key) + } + delete(base, key) + } + for _, disk := range []string{"/dev/disk/by-id/data,size=4G", "local:vm-9000,size=4G,snapshot=0", "local:vm-9000,size=4G,shared=1"} { + base["scsi0"] = disk + if _, err = SnapshotDisks(base); err == nil { + t.Fatal("accepted excluded disk", disk) + } + } + if _, err = SnapshotDisks(map[string]any{"ide2": "none,media=cdrom"}); err == nil { + t.Fatal("accepted no disks") + } +} diff --git a/tools/generate-platform-openapi.mjs b/tools/generate-platform-openapi.mjs index 42734f2..bbf695d 100644 --- a/tools/generate-platform-openapi.mjs +++ b/tools/generate-platform-openapi.mjs @@ -14,6 +14,10 @@ const schemas = { 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 }), + GPUReference: object({ vmid: integer, vmName: string, vmStatus: string, template: boolean, desktopId: string, desktopName: string, slot: string, phase: { type: 'string', enum: ['current', 'pending'] }, source: { type: 'string', enum: ['direct', 'mapping'] }, mapping: string, mediated: boolean, pendingRemoval: boolean }), + GPUItem: object({ id: string, address: string, name: string, vendor: string, vendorId: string, deviceId: string, class: string, iommuGroup: { ...integer, nullable: true }, state: { type: 'string', enum: ['unreferenced', 'unknown', 'referenced', 'multiple'] }, references: array(ref('GPUReference')) }), + 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')) }), 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 }), @@ -22,6 +26,10 @@ const schemas = { DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }), EnrollmentInput: object({ sn: string, name: string, group: string }), Enrollment: object({ id: string, activationSecret: string }), + TerminalUpdate: object({ name: string, group: string }), + UserUpdate: object({ displayName: string, enabled: boolean }), + BatchBindings: object({ userIds: { ...array(string), minItems: 1, maxItems: 100 }, desktopIds: { ...array(string), minItems: 1, maxItems: 100 }, action: { type: 'string', enum: ['grant', 'revoke'] } }), + BatchBindingsResult: object({ accepted: boolean, userCount: integer }), CreateUserInput: object({ username: string, displayName: string }), BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }), DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }), @@ -29,6 +37,10 @@ const schemas = { RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }), RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }), HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']), + TerminalSystemMetrics: object({ cpuPercent: { ...number, nullable: true, minimum: 0, maximum: 100 }, memoryPercent: { ...number, nullable: true, minimum: 0, maximum: 100 }, socTemperatureC: { ...number, nullable: true, minimum: -40, maximum: 150 }, uptimeSeconds: { ...number, nullable: true, minimum: 0, maximum: 3155760000 } }, []), + TerminalMetricLatest: object({ cpuPercent: { ...number, nullable: true }, memoryPercent: { ...number, nullable: true }, socTemperatureC: { ...number, nullable: true }, uptimeSeconds: { ...number, nullable: true }, sampledAt: { ...date, nullable: true }, supported: boolean, online: boolean, fresh: boolean }), + TerminalMetricPoint: object({ time: date, cpuPercent: { ...number, nullable: true }, memoryPercent: { ...number, nullable: true }, socTemperatureC: { ...number, nullable: true }, uptimeSeconds: { ...number, nullable: true }, samples: integer }), + TerminalMetrics: object({ latest: ref('TerminalMetricLatest'), items: array(ref('TerminalMetricPoint')), from: date, to: date, bucketSeconds: integer, retentionDays: integer }), Accepted: object({ accepted: boolean }), HeartbeatResult: object({ accepted: boolean, serverTime: date }), DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }), @@ -40,45 +52,156 @@ const schemas = { SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }), AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']), } +Object.assign(schemas, { + PveConnectionInput: object({ url: string, tokenId: string, tokenSecret: { ...string, format: 'password', writeOnly: true }, caPem: string, revision: string }, ['url', 'tokenId', 'revision']), + PveConnection: object({ url: string, tokenId: string, caPem: string, revision: string, tokenConfigured: boolean, source: { type: 'string', enum: ['environment', 'database'] } }), + PveConnectionCheck: object({ available: boolean, nodes: integer, vms: integer, message: string }), + VMDisk: object({ key: string, storage: string, sizeGiB: number }), + VMHardware: object({ cores: integer, sockets: integer, memoryMiB: integer, digest: string, disks: array(ref('VMDisk')), powerState: string, blockers: array(string) }), + HardwareInput: object({ kind: { type: 'string', enum: ['compute', 'disk'] }, digest: string, cores: integer, sockets: integer, memoryMiB: integer, disk: string, sizeGiB: integer }, ['kind', 'digest']), + PowerBatchInput: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] }, desktopIds: { ...array(string), minItems: 1, maxItems: 50 }, previewToken: string }, ['action', 'desktopIds']), + PowerBatchCheckItem: object({ desktopId: string, name: string, vmid: integer, powerState: string, available: boolean, code: string, message: string }), + PowerBatchPreview: object({ items: array(ref('PowerBatchCheckItem')), eligibleCount: integer, skippedCount: integer, previewToken: string, expiresAt: date }), + PowerBatch: object({ id: string, action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] }, actor: string, createdAt: date, totalCount: integer, submittedCount: integer, skippedCount: integer, activeCount: integer, successCount: integer, failedCount: integer, uncertainCount: integer }), + PowerBatchItem: object({ desktopId: string, name: string, vmid: integer, taskId: string, status: string, code: string, message: string }), + PowerBatchDetail: object({ batch: ref('PowerBatch'), items: array(ref('PowerBatchItem')) }), + SnapshotItem: object({ name: string, description: string, createdAt: integer, parent: string, includesMemory: boolean, state: string, revision: string }), + SnapshotView: object({ items: array(ref('SnapshotItem')), digest: string, powerState: string, blockers: array(string), disks: array(string), canCreate: boolean, canDelete: boolean, canRestore: boolean }), + SnapshotInput: object({ action: { type: 'string', enum: ['create', 'delete', 'rollback'] }, name: { ...string, maxLength: 40 }, description: { ...string, maxLength: 500 }, digest: string, revision: string, confirmVmid: integer }, ['action', 'name', 'digest']), + HardwareCheck: object({ available: boolean, message: string }), + CsvImportInput: object({ kind: { type: 'string', enum: ['users', 'terminals', 'bindings'] }, csv: { ...string, maxLength: 524288 }, previewToken: string }, ['kind', 'csv']), + CsvImportRow: object({ line: integer, key: string, values: { type: 'object', additionalProperties: string }, errors: array(string) }), + 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 }), + 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 } }), + TerminalPolicy: object({ id: string, name: string, version: string, heartbeatInterval: integer, commandPollInterval: integer, desktopRefreshInterval: integer, createdAt: date }), + PolicyDeployment: object({ id: string, templateId: string, templateName: string, templateVersion: string, actor: string, targetCount: integer, createdAt: date }), + PolicyDeploymentInput: object({ templateId: string, targetMode: { type: 'string', enum: ['devices', 'group'] }, terminalIds: { ...array(string), maxItems: 200 }, group: string }, ['templateId', 'targetMode']), + 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 }), + 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 } }), + 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 }), + 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) }), + CloneInput: object({ templateId: string, poolId: { ...string, nullable: true }, name: string, vmid: integer, full: boolean, storage: string }), + CloneCheck: object({ available: boolean, message: string }), + Storage: object({ storage: string, avail: number, active: integer, enabled: integer, content: string }), + ConnectionInput: object({ endpoint: string, width: integer, height: integer, fps: integer, initialized: boolean }), +}) +schemas.Desktop.properties.poolId = { ...string, nullable: true } +schemas.Desktop.properties.setupRequired = boolean +schemas.Administrator.properties.enabled = boolean +schemas.HeartbeatInput.properties.policyProtocol = { ...integer, minimum: 0, maximum: 1 } +schemas.HeartbeatInput.properties.systemMetrics = { ...ref('TerminalSystemMetrics'), nullable: true } +Object.assign(schemas.DeviceConfig.properties, { deploymentId: string, policyProtocol: integer, commandPollInterval: integer, desktopRefreshInterval: integer }) +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 -for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) { +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']) { 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 }) +schemas.AlertRecordList.required.push('firingCount', 'unacknowledgedCount') const json = schema => ({ 'application/json': { schema } }) -const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({ +const operation = (id, output, { input, status = 200, list = false, param = false, command = false, node = false, period = false, resource = false, security = 'adminBearer' } = {}) => ({ operationId: id, security: security ? [{ [security]: [] }] : [], ...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}), parameters: [ + ...(period ? ['from', 'to'].map(name => ({ name, in: 'query', schema: date })) : []), + ...(resource ? [{ name: 'resourceId', in: 'query', required: true, schema: string }] : []), + ...(node ? [{ name: 'node', in: 'query', required: true, schema: string }] : []), ...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []), ...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []), ...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []), ], responses: { [status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) }, - ...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])), + ...Object.fromEntries([400, 401, 403, 404, 409, 429, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])), }, }) const paths = { + '/settings/pve': { get: operation('pveConnection', 'PveConnection'), put: operation('savePveConnection', 'Accepted', { input: 'PveConnectionInput' }) }, + '/settings/pve/check': { post: operation('checkPveConnection', 'PveConnectionCheck', { input: 'PveConnectionInput' }) }, + '/desktops/{id}/hardware': { get: operation('desktopHardware', 'VMHardware', { param: true }), post: operation('changeDesktopHardware', 'Task', { param: true, input: 'HardwareInput', command: true, status: 202 }) }, + '/desktops/{id}/hardware/check': { post: operation('checkDesktopHardware', 'HardwareCheck', { param: true, input: 'HardwareInput' }) }, + '/desktop-batches': { get: operation('desktopPowerBatches', 'PowerBatchList', { list: true }), post: operation('submitDesktopPowerBatch', 'PowerBatchDetail', { input: 'PowerBatchInput', command: true, status: 202 }) }, + '/desktop-batches/check': { post: operation('checkDesktopPowerBatch', 'PowerBatchPreview', { input: 'PowerBatchInput' }) }, + '/desktop-batches/{id}': { get: operation('desktopPowerBatch', 'PowerBatchDetail', { param: true }) }, + '/tasks/{id}/batch-power-reconcile': { post: operation('reconcileBatchPower', 'Accepted', { param: true }) }, + '/desktops/{id}/snapshots': { get: operation('desktopSnapshots', 'SnapshotView', { param: true }) }, + '/desktops/{id}/snapshots/check': { post: operation('checkDesktopSnapshot', 'HardwareCheck', { param: true, input: 'SnapshotInput' }) }, + '/desktops/{id}/snapshots/actions': { post: operation('changeDesktopSnapshot', 'Task', { param: true, input: 'SnapshotInput', command: true, status: 202 }) }, + '/tasks/{id}/snapshot-reconcile': { post: operation('reconcileSnapshot', 'Accepted', { param: true }) }, + '/tasks/{id}/hardware-reconcile': { post: operation('reconcileHardware', 'Accepted', { param: true }) }, + '/imports/preview': { post: operation('previewCsvImport', 'CsvImportPreview', { input: 'CsvImportInput' }) }, + '/imports/commit': { post: operation('commitCsvImport', 'CsvImportResult', { input: 'CsvImportInput', command: true }) }, + '/sessions/history': { get: operation('sessionHistory', 'SessionRecordList', { list: true, period: true }) }, + '/monitor/series': { get: operation('resourceSeries', 'ResourceSeries', { period: true, resource: true }) }, + '/monitor/rules': { get: operation('alertRules', 'AlertRuleList', { list: true }), post: operation('createAlertRule', 'AlertRule', { input: 'AlertRuleInput' }) }, + '/monitor/rules/{id}': { put: operation('updateAlertRule', 'AlertRule', { input: 'AlertRuleInput', param: true }) }, + '/monitor/alerts': { get: operation('alerts', 'AlertRecordList', { list: true }) }, + '/monitor/alerts/{id}/acknowledge': { post: operation('acknowledgeAlert', 'Accepted', { param: true }) }, + '/terminal-policy-templates': { get: operation('terminalPolicyTemplates', 'TerminalPolicyList', { list: true }), post: operation('createTerminalPolicy', 'TerminalPolicy', { input: 'TerminalPolicyInput', status: 201 }) }, + '/terminal-policy-states': { get: operation('terminalPolicyStates', 'PolicyResultList', { list: true }) }, + '/terminal-policy-deployments': { get: operation('policyDeployments', 'PolicyDeploymentList', { list: true }), post: operation('deployPolicy', 'PolicyDeployment', { input: 'PolicyDeploymentInput', command: true, status: 202 }) }, + '/terminal-policy-deployments/{id}/results': { get: operation('policyResults', 'PolicyResultList', { list: true, param: true }) }, + '/terminal/config/ack': { post: operation('policyAck', 'Accepted', { input: 'PolicyAck', security: 'deviceBearer' }) }, '/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) }, '/auth/me': { get: operation('me', 'Administrator') }, '/auth/logout': { post: operation('logout', null, { status: 204 }) }, + '/auth/password': { post: operation('changeOwnPassword', null, { input: 'ChangePassword', status: 204 }) }, + '/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 }) }, '/overview': { get: operation('overview', 'Overview') }, '/settings': { get: operation('settings', 'Settings') }, '/resources': { get: operation('resources', 'ResourceList', { list: true }) }, + '/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) }, '/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) }, + '/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) }, + '/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) }, + '/templates': { get: operation('templates', 'ImageTemplateList', { list: true }), post: operation('registerTemplate', 'ImageTemplate', { input: 'TemplateInput', status: 201 }) }, + '/templates/{id}': { patch: operation('updateTemplate', 'ImageTemplate', { param: true, input: 'TemplateUpdate' }) }, + '/templates/{id}/validate': { post: operation('validateTemplate', 'ImageTemplate', { param: true }) }, + '/pools': { get: operation('pools', 'DesktopPoolList', { list: true }), post: operation('createPool', 'DesktopPool', { input: 'PoolInput' }) }, + '/pools/{id}': { put: operation('updatePool', 'DesktopPool', { param: true, input: 'PoolInput' }) }, + '/pools/{id}/members': { put: operation('poolMembers', 'Accepted', { param: true, input: 'PoolMembers' }) }, + '/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' }) }, '/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' }) }, '/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) }, '/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) }, '/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) }, + '/terminals/{id}': { put: operation('updateTerminal', 'Accepted', { input: 'TerminalUpdate', param: true }) }, + '/terminals/{id}/metrics': { get: operation('terminalMetrics', 'TerminalMetrics', { param: true, period: true }) }, + '/terminals/{id}/disable': { post: operation('disableTerminal', 'Accepted', { param: true }) }, + '/terminals/{id}/reactivate': { post: operation('reactivateTerminal', 'Enrollment', { param: true }) }, '/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) }, '/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) }, + '/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/{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 }) }, '/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },