功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
宋一鸣 2026-11-10 17:52:52 +08:00
parent eef485306f
commit 6134d49c16
87 changed files with 29498 additions and 126 deletions

View File

@ -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<string, unknown> }[] = []
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<string, unknown> : {}; 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()
})

View File

@ -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<string, unknown>; 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')
})

106
console/e2e/catalog.spec.ts Normal file
View File

@ -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<string, unknown>; 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<string, unknown> : {}
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()
})

View File

@ -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<string, unknown>; 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)
})

85
console/e2e/gpus.spec.ts Normal file
View File

@ -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([])
})

View File

@ -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<string, unknown> }[] = []
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<string, unknown> : {}; 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' }])
})

View File

@ -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<string, unknown> }[] = []
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<string, unknown> : {}; 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)
})

View File

@ -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<string, unknown>; 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<string, unknown>; 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)
}
})

View File

@ -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<string, unknown>; 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')
})

View File

@ -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 小时')
})

View File

@ -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<T>(promise: Promise<Result<T>>): Promise<T> {
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 })),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { api, allPages, errorMessage, type AdministratorAccount, type UpdateAdministrator } from '../api'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
const auth = useAuth(), { confirm } = useAction()
const items = ref<AdministratorAccount[]>([]), loading = ref(false), busy = ref(false), error = ref(''), formError = ref(''), keyword = ref('')
const visible = ref(false), editing = ref<AdministratorAccount>(), resetTarget = ref<AdministratorAccount>()
const form = reactive({ username: '', displayName: '', role: 'viewer' as AdministratorAccount['role'], enabled: true, password: '', confirmation: '' })
const reset = reactive({ password: '', confirmation: '' })
const roles = { super_admin: '超级管理员', operator: '运维人员', viewer: '只读人员' }
const filtered = computed(() => items.value.filter(a => `${a.username} ${a.displayName}`.toLowerCase().includes(keyword.value.toLowerCase())))
const passwordValid = (password: string, confirmation: string) => [...password].length >= 12 && new TextEncoder().encode(password).length <= 72 && password === confirmation
const canSave = computed(() => !!form.displayName.trim() && (!!editing.value || (/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$/.test(form.username) && passwordValid(form.password, form.confirmation))))
async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.administrators); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value && !visible.value && !resetTarget.value) void load(true) }, 5000) })
onUnmounted(() => clearInterval(timer))
function edit(item?: AdministratorAccount) { editing.value = item; formError.value = ''; Object.assign(form, { username: item?.username || '', displayName: item?.displayName || '', role: item?.role || 'viewer', enabled: item?.enabled ?? true, password: '', confirmation: '' }); visible.value = true }
watch(visible, open => { if (!open) { form.password = ''; form.confirmation = ''; formError.value = '' } })
watch(resetTarget, () => { reset.password = ''; reset.confirmation = ''; formError.value = '' })
async function save() {
if (!canSave.value || busy.value) return
if (editing.value && (editing.value.role !== form.role || editing.value.enabled !== form.enabled) && !await confirm('更新管理员权限', '修改角色或停用账号会使该账号的所有登录失效。')) return
busy.value = true; formError.value = ''
try {
const update: UpdateAdministrator = { displayName: form.displayName, role: form.role, enabled: form.enabled }
const item = editing.value ? await api.updateAdministrator(editing.value.id, update) : await api.createAdministrator({ username: form.username, displayName: form.displayName, role: form.role, password: form.password })
if (item.id === auth.user?.id) auth.user = item
visible.value = false; ElMessage.success('管理员账号已保存'); await load()
} catch (e) { formError.value = errorMessage(e) } finally { busy.value = false }
}
async function resetPassword() {
if (!resetTarget.value || busy.value || !passwordValid(reset.password, reset.confirmation)) return
if (!await confirm(`重置密码 · ${resetTarget.value.username}`, '该账号需要使用新密码重新登录,现有登录会立即失效。')) return
busy.value = true; formError.value = ''
try { await api.resetAdministratorPassword(resetTarget.value.id, reset.password); resetTarget.value = undefined; ElMessage.success('密码已重置,旧登录已失效') }
catch (e) { formError.value = errorMessage(e) } finally { busy.value = false }
}
</script>
<template>
<div class="account-heading"><div><div class="section-kicker">ADMINISTRATOR ACCOUNTS</div><h2>管理员账号</h2><p class="subtext">维护控制台账号、角色与启用状态</p></div><div class="actions"><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button type="primary" :icon="Plus" @click="edit()">新增管理员</el-button></div></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon style="margin:20px 0" />
<div class="toolbar" style="margin-top:24px"><el-input v-model="keyword" placeholder="搜索账号 / 显示名称" aria-label="搜索管理员" clearable /></div>
<el-table v-loading="loading" :data="filtered" empty-text="暂无符合条件的管理员"><el-table-column label="账号" min-width="150"><template #default="{ row }"><strong>{{ row.username }}</strong><el-tag v-if="row.id === auth.user?.id" size="small" type="info" style="margin-left:8px">本人</el-tag></template></el-table-column><el-table-column prop="displayName" label="显示名称" min-width="135" /><el-table-column label="角色" min-width="130"><template #default="{ row }">{{ roles[row.role as keyof typeof roles] }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column label="操作" min-width="180"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑</el-button><el-button v-if="row.id !== auth.user?.id" link :disabled="busy" @click="resetTarget = row">重置密码</el-button></template></el-table-column></el-table>
<p class="account-note">本人密码通过右上角钥匙按钮修改。不能停用自己或修改自己的角色;至少保留一个启用的超级管理员。停用账号会保留历史审计。</p>
<el-dialog v-model="visible" :title="editing ? '编辑管理员' : '新增管理员'" width="520px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy"><el-alert v-if="formError" type="error" :title="formError" :closable="false" show-icon /><el-form label-position="top" :disabled="busy"><el-form-item label="账号"><el-input v-model="form.username" :disabled="!!editing" maxlength="80" autocomplete="off" aria-label="管理员账号" placeholder="3~80 位字母、数字、点、下划线或短横线" /></el-form-item><el-form-item label="显示名称"><el-input v-model="form.displayName" maxlength="80" aria-label="管理员显示名称" /></el-form-item><el-form-item label="角色"><el-select v-model="form.role" :disabled="editing?.id === auth.user?.id" aria-label="管理员角色"><el-option v-for="(label, value) in roles" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item v-if="editing" label="启用"><el-switch v-model="form.enabled" :disabled="editing.id === auth.user?.id" aria-label="管理员启用状态" /></el-form-item><template v-else><el-form-item label="初始密码"><el-input v-model="form.password" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="管理员初始密码" /></el-form-item><el-form-item label="确认初始密码"><el-input v-model="form.confirmation" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="确认管理员初始密码" /></el-form-item><p class="subtext">密码至少 12 个字符,UTF-8 编码不超过 72 字节。</p></template></el-form><template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!canSave" @click="save">保存管理员</el-button></template></el-dialog>
<el-dialog :model-value="!!resetTarget" :title="`重置密码 · ${resetTarget?.username || ''}`" width="480px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @close="resetTarget = undefined"><el-alert v-if="formError" type="error" :title="formError" :closable="false" /><el-form label-position="top" :disabled="busy"><el-form-item label="新密码"><el-input v-model="reset.password" type="password" show-password autocomplete="new-password" aria-label="重置后的新密码" maxlength="72" /></el-form-item><el-form-item label="确认新密码"><el-input v-model="reset.confirmation" type="password" show-password autocomplete="new-password" aria-label="确认重置后的新密码" maxlength="72" /></el-form-item><p class="subtext">至少 12 个字符,UTF-8 编码不超过 72 字节。保存后该账号所有登录失效。</p></el-form><template #footer><el-button :disabled="busy" @click="resetTarget = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!passwordValid(reset.password, reset.confirmation)" @click="resetPassword">重置密码</el-button></template></el-dialog>
</template>
<style scoped>
.account-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;flex-wrap:wrap}.account-heading h2{margin-top:6px}.account-note{margin-top:22px;font-size:12px;line-height:1.8;color:var(--vdi-muted)}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api, errorMessage } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const auth = useAuth(), data = usePlatform(), router = useRouter(), busy = ref(false), error = ref('')
const form = reactive({ currentPassword: '', newPassword: '', confirmation: '' })
const valid = computed(() => !!form.currentPassword && [...form.newPassword].length >= 12 && new TextEncoder().encode(form.newPassword).length <= 72 && form.newPassword === form.confirmation)
watch(() => props.modelValue, () => { Object.assign(form, { currentPassword: '', newPassword: '', confirmation: '' }); error.value = '' })
async function save() {
if (!valid.value || busy.value) return
busy.value = true; error.value = ''
try {
await api.changeOwnPassword(form.currentPassword, form.newPassword)
emit('update:modelValue', false); auth.clear(); data.clear()
ElMessage.success('密码已更新,请使用新密码重新登录')
await router.replace('/login')
} catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
</script>
<template>
<el-dialog :model-value="modelValue" title="修改本人密码" width="480px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @update:model-value="emit('update:modelValue', $event)">
<p class="subtext">修改后,本账号的所有登录会失效。</p>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon style="margin-top:16px" />
<el-form label-position="top" :disabled="busy" style="margin-top:20px" @submit.prevent="valid && save()"><el-form-item label="原密码"><el-input v-model="form.currentPassword" type="password" show-password autocomplete="current-password" aria-label="原密码" /></el-form-item><el-form-item label="新密码"><el-input v-model="form.newPassword" type="password" show-password autocomplete="new-password" aria-label="新密码" maxlength="72" /></el-form-item><el-form-item label="确认新密码"><el-input v-model="form.confirmation" type="password" show-password autocomplete="new-password" aria-label="确认新密码" maxlength="72" /></el-form-item><p class="subtext">新密码至少 12 个字符,UTF-8 编码不超过 72 字节。</p></el-form>
<template #footer><el-button :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :disabled="!valid" :loading="busy" @click="save">保存并重新登录</el-button></template>
</el-dialog>
</template>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { api, allPages, errorMessage, type ImageTemplate, type DesktopPool, type Storage, type CloneInput } from '../api'
import { usePlatform } from '../stores/platform'
const props = defineProps<{ modelValue: boolean; templateId?: string; poolId?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; submitted: [] }>()
const data = usePlatform(), templates = ref<ImageTemplate[]>([]), pools = ref<DesktopPool[]>([]), storages = ref<Storage[]>([])
const busy = ref(false), loading = ref(false), loadError = ref(''), message = ref(''), available = ref(false), taskId = ref(''), key = ref('')
const form = reactive<CloneInput>({ templateId: '', poolId: null, name: '', vmid: 9000, full: true, storage: '' })
const selected = computed(() => templates.value.find(t => t.id === form.templateId))
const choices = computed(() => pools.value.filter(p => p.enabled && (!p.templateId || p.templateId === form.templateId)))
const eligibleStorages = computed(() => storages.value.filter(s => s.active && s.enabled && s.content.split(',').includes('images')))
watch(() => props.modelValue, async open => {
if (!open) return
taskId.value = ''; message.value = ''; available.value = false; loadError.value = ''; key.value = crypto.randomUUID()
Object.assign(form, { templateId: props.templateId || '', poolId: props.poolId || null, name: '', vmid: 9000, full: true, storage: '' })
loading.value = true
try { [templates.value, pools.value] = await Promise.all([allPages(api.templates), allPages(api.pools)]); await loadStorages() }
catch (e) { loadError.value = errorMessage(e) }
finally { loading.value = false }
})
let storageRequest = 0
async function loadStorages() {
const request = ++storageRequest, node = selected.value?.node
storages.value = []; form.storage = ''
if (!node) return
try { const result = await allPages(page => api.storages(node, page)); if (request === storageRequest) storages.value = result }
catch (e) { if (request === storageRequest) loadError.value = errorMessage(e) }
}
watch(() => form.templateId, () => { if (form.poolId && !choices.value.some(p => p.id === form.poolId)) form.poolId = null; void loadStorages() })
watch(() => form.full, full => { if (!full) form.storage = '' })
watch(form, () => { available.value = false; message.value = ''; key.value = crypto.randomUUID() })
async function check() {
busy.value = true
try { const r = await api.checkClone({ ...form }); available.value = r.available; message.value = r.message }
catch (e) { available.value = false; message.value = errorMessage(e) }
finally { busy.value = false }
}
async function submit() {
busy.value = true
try { const task = await api.cloneDesktop({ ...form }, key.value); taskId.value = task.id; ElMessage.success('创建任务已提交,请查看最终结果'); await data.refresh(); emit('submitted') }
catch (e) { message.value = errorMessage(e); available.value = false }
finally { busy.value = false }
}
</script>
<template>
<el-dialog :model-value="modelValue" title="从模板创建桌面" width="620px" :close-on-click-modal="false" @update:model-value="emit('update:modelValue', $event)">
<el-alert v-if="loadError" type="error" :title="loadError" :closable="false" show-icon />
<template v-if="taskId"><el-result icon="success" title="创建任务已提交" sub-title="提交成功后仍需等待 PVE 克隆完成。请在任务页面查看结果。" /><p class="mono">{{ taskId }}</p><router-link to="/tasks" @click="emit('update:modelValue', false)">查看任务与审计 →</router-link></template>
<el-form v-else v-loading="loading" label-position="top" :disabled="busy">
<el-alert v-if="!loading && !templates.some(t => t.enabled)" title="尚无已启用模板,请先在镜像模板页面登记 PVE 专用模板。" type="info" :closable="false" />
<el-form-item label="镜像模板"><el-select v-model="form.templateId" placeholder="选择模板" filterable><el-option v-for="t in templates.filter(t => t.enabled)" :key="t.id" :value="t.id" :label="`${t.name} · ${t.version} · ${t.os}`" /></el-select></el-form-item>
<p v-if="selected" class="subtext">源节点 {{ selected.node }} · 模板 VMID {{ selected.vmid }} · 桌面将在同节点创建</p>
<el-form-item label="桌面名称"><el-input v-model="form.name" maxlength="80" placeholder="例如:研发桌面 01" /></el-form-item>
<el-form-item label="目标 VMID"><el-input-number v-model="form.vmid" :min="100" :max="999999999" :precision="0" /></el-form-item>
<el-form-item label="桌面池(可选)"><el-select v-model="form.poolId" clearable placeholder="不加入桌面池"><el-option v-for="p in choices" :key="p.id" :label="`${p.name} · 已用 ${p.desktopCount + p.reservedCount}/${p.maxCount}`" :value="p.id" :disabled="p.desktopCount + p.reservedCount >= p.maxCount" /></el-select></el-form-item>
<el-form-item label="克隆方式"><el-radio-group v-model="form.full"><el-radio :value="true">完整克隆</el-radio><el-radio :value="false">链接克隆</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.full" label="目标存储"><el-select v-model="form.storage" placeholder="选择存储"><el-option v-for="s in eligibleStorages" :key="s.storage" :value="s.storage" :label="`${s.storage} · 可用 ${(s.avail / 1073741824).toFixed(1)} GiB`" /></el-select></el-form-item>
<p class="subtext">创建后需单独完成系统身份、IP、Sunshine 服务与终端配对,然后在桌面详情配置连接。公共池当前由管理员分配与回收。</p>
<el-alert v-if="message" :title="message" :type="available ? 'success' : 'warning'" :closable="false" show-icon />
</el-form>
<template #footer><el-button :disabled="busy" @click="emit('update:modelValue', false)">关闭</el-button><template v-if="!taskId"><el-button :loading="busy" :disabled="loading || !form.templateId || !form.name.trim() || (form.full && !form.storage)" @click="check">检查创建条件</el-button><el-button type="primary" :loading="busy" :disabled="!available" @click="submit">提交创建任务</el-button></template></template>
</el-dialog>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, errorMessage, type ImportPreview, type ImportResult } from '../api'
const visible = defineModel<boolean>({ required: true })
const props = defineProps<{ kind: 'users' | 'terminals' | 'bindings' }>()
const emit = defineEmits<{ completed: [] }>()
const csv = ref(''), filename = ref(''), busy = ref(false), error = ref(''), preview = ref<ImportPreview>(), result = ref<ImportResult>(), saved = ref(false)
const titles = { users: '批量新增用户', terminals: '批量登记终端', bindings: '批量导入授权' }
const headers = { users: ['username', 'displayName'], terminals: ['sn', 'name', 'group'], bindings: ['username', 'terminalSn', 'desktopIds'] }
const descriptions = { users: '只新增用户,不修改已有账号。姓名和账号必填。', terminals: '只登记新 SN,不修改现有设备。激活密钥在本次结果中下载,请妥善保存。', bindings: '用户和终端须已存在。空终端 SN 保持原绑定;桌面 ID 用英文分号分隔,仅追加授权。已有其他终端的用户需单独换绑。' }
const columns = computed(() => headers[props.kind])
let requestKey = '', readGeneration = 0
watch(visible, open => { if (open) { csv.value = ''; filename.value = ''; preview.value = undefined; result.value = undefined; error.value = ''; saved.value = false; requestKey = crypto.randomUUID(); ++readGeneration } })
watch(csv, () => { preview.value = undefined })
function download(name: string, rows: string[][]) {
// Neutralize spreadsheet formulas while preserving CSV quoting and Unicode.
const cell = (s: string) => `"${(/^[\s]*[=+@-]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`
const data = '\ufeff' + rows.map(row => row.map(cell).join(',')).join('\r\n')
const url = URL.createObjectURL(new Blob([data], { type: 'text/csv;charset=utf-8' })), a = document.createElement('a')
a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function template() { download(`${props.kind}-template.csv`, [columns.value]) }
async function choose(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0], generation = ++readGeneration
input.value = ''; if (!file) return
if (file.size > 524288) { ElMessage.error('文件不能超过 512 KiB'); return }
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
if (generation !== readGeneration) return
csv.value = text; filename.value = file.name; error.value = ''; result.value = undefined
} catch { ElMessage.error('请将 CSV 保存为 UTF-8 编码') }
}
async function check() {
busy.value = true; error.value = ''; result.value = undefined
try { preview.value = await api.previewImport({ kind: props.kind, csv: csv.value }) } catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function commit() {
if (!preview.value?.previewToken || busy.value) return
busy.value = true; error.value = ''
try {
await ElMessageBox.confirm(`确认导入 ${preview.value.totalRows} 行?提交时会再次核对数据;有变化时整批取消。`, '确认批量导入', { type: 'warning', confirmButtonText: '确认导入', cancelButtonText: '取消' })
result.value = await api.commitImport({ kind: props.kind, csv: csv.value, previewToken: preview.value.previewToken }, requestKey)
emit('completed'); ElMessage.success(`已导入 ${result.value.count} 行`)
} catch (e) { if (e !== 'cancel' && e !== 'close') error.value = errorMessage(e) } finally { busy.value = false }
}
function exportErrors() { if (preview.value) download('import-errors.csv', [['line', ...columns.value, 'errors'], ...preview.value.rows.filter(r => r.errors.length).map(r => [String(r.line), ...columns.value.map(c => r.values[c] || ''), r.errors.join(';')])]) }
function exportSecrets() { if (result.value) { download('terminal-activation.csv', [['sn', 'activationSecret'], ...result.value.activationSecrets.map(s => [s.sn, s.activationSecret])]); saved.value = true } }
async function close(done: () => void) {
if (busy.value) return
if (result.value?.activationSecrets.length && !saved.value) {
try { await ElMessageBox.confirm('本次激活密钥尚未下载。关闭后无法重新查看;需要在终端管理中重新激活。', '尚未保存激活信息', { confirmButtonText: '仍然关闭', cancelButtonText: '返回保存', type: 'warning' }) } catch { return }
}
++readGeneration; done()
}
</script>
<template>
<el-dialog v-model="visible" :title="titles[kind]" width="850px" :close-on-click-modal="false" :before-close="close" :show-close="!busy" @closed="csv = ''; result = undefined; preview = undefined">
<p class="subtext">{{ descriptions[kind] }}每批最多 200 行,文件不超过 512 KiB。</p>
<template v-if="!result">
<div class="import-actions"><el-button :disabled="busy" @click="template">下载空白模板</el-button><label class="file-picker"><span>选择 CSV 文件</span><input type="file" accept=".csv,text/csv" aria-label="选择 CSV 文件" :disabled="busy" @change="choose" /></label><span class="subtext">{{ filename }}</span></div>
<el-input v-model="csv" type="textarea" :rows="6" :disabled="busy" aria-label="CSV 内容" placeholder="也可粘贴 UTF-8 CSV 内容,第一行为表头" />
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<div v-if="preview" class="preview"><p>共 {{ preview.totalRows }} 行 · 通过 {{ preview.validRows }} 行 · 错误 {{ preview.errorRows }} 行 <el-button v-if="preview.errorRows" link type="primary" @click="exportErrors">下载错误行</el-button></p><el-table :data="preview.rows" max-height="320" empty-text="没有数据"><el-table-column prop="line" label="行号" width="65" /><el-table-column v-for="col in columns" :key="col" :label="col" min-width="130"><template #default="{ row }">{{ row.values[col] }}</template></el-table-column><el-table-column label="预检结果" min-width="230"><template #default="{ row }"><span :class="row.errors.length ? 'tone-danger' : 'tone-ok'">{{ row.errors.length ? row.errors.join(';') : '通过' }}</span></template></el-table-column></el-table><p class="subtext">预检通过后才能提交;任何错误都会阻止整批导入。</p></div>
</template>
<template v-else><el-result icon="success" title="导入完成" :sub-title="`${result.count} 行已提交${result.replayed ? '(重复请求,未重复写入)' : ''}`" /><el-alert v-if="kind === 'terminals'" :closable="false" :type="result.activationSecrets.length ? 'warning' : 'info'" :title="result.activationSecrets.length ? '激活密钥仅在本次响应提供,请立即下载并妥善保管。' : '此次为重复请求,激活密钥不再返回;如未保存,请在终端管理中重新激活。'" /><el-button v-if="result.activationSecrets.length" type="primary" class="download-secret" @click="exportSecrets">下载终端激活信息</el-button><el-table :data="result.items" max-height="260"><el-table-column prop="line" label="行号" width="70" /><el-table-column prop="key" label="账号 / SN" min-width="180" /><el-table-column prop="id" label="平台标识" min-width="220" /></el-table></template>
<template #footer><el-button :disabled="busy" @click="close(() => visible = false)">{{ result ? '关闭' : '取消' }}</el-button><template v-if="!result"><el-button :disabled="!csv.trim() || busy" :loading="busy" @click="check">预检文件</el-button><el-button type="primary" :disabled="!preview?.previewToken || !!preview?.errorRows || busy" :loading="busy" @click="commit">提交导入</el-button></template></template>
</el-dialog>
</template>
<style scoped>
.import-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0}.file-picker{position:relative;padding:9px 14px;border:1px solid var(--vdi-border);border-radius:8px;font-size:12px;cursor:pointer}.file-picker input{position:absolute;inset:0;opacity:0;width:100%;cursor:pointer}.preview{margin-top:20px}.preview>p{margin:12px 0;font-size:12px}.download-secret{margin:18px 0}.subtext{line-height:1.8}
</style>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { api, errorMessage, allPages, type DesktopAction, type PowerBatch, type PowerBatchDetail, type PowerBatchPreview } from '../api'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import StatusTag from './StatusTag.vue'
const mode = defineModel<DesktopAction | 'history' | undefined>({ required: true })
const props = defineProps<{ desktopIds: string[] }>()
const auth = useAuth(), platform = usePlatform(), busy = ref(false), error = ref(''), preview = ref<PowerBatchPreview>(), detail = ref<PowerBatchDetail>(), history = ref<PowerBatch[]>([])
const labels = { start: '开机', shutdown: '正常关机', reboot: '重启' }
const ids = ref<string[]>([]), now = ref(Date.now())
const action = computed(() => mode.value && mode.value !== 'history' ? mode.value : undefined)
const title = computed(() => detail.value ? '批量操作结果' : mode.value === 'history' ? '桌面批量记录' : `批量${labels[action.value || 'start']}`)
const canSubmit = computed(() => !!action.value && !!preview.value?.eligibleCount && Date.parse(preview.value.expiresAt) > now.value)
let key = '', timer: ReturnType<typeof setInterval> | undefined, generation = 0
watch(mode, async value => {
generation++; if (timer) clearInterval(timer); timer = undefined
if (!value) return
ids.value = [...props.desktopIds]; preview.value = undefined; detail.value = undefined; history.value = []; error.value = ''; key = crypto.randomUUID()
if (value === 'history') await loadHistory()
timer = setInterval(() => { now.value = Date.now(); if (detail.value?.batch.activeCount) void loadDetail(detail.value.batch.id); else if (mode.value === 'history' && !detail.value) void loadHistory() }, 5000)
})
onUnmounted(() => { generation++; if (timer) clearInterval(timer) })
async function loadHistory() {
if (busy.value) return
const version = generation; busy.value = true; error.value = ''
try { const items = await allPages(api.desktopPowerBatches); if (version === generation) history.value = items }
catch (e) { if (version === generation) error.value = errorMessage(e) } finally { busy.value = false }
}
async function loadDetail(id: string) {
if (busy.value) return
const version = generation; busy.value = true; error.value = ''
try { const value = await api.desktopPowerBatch(id); if (version === generation) detail.value = value }
catch (e) { if (version === generation) error.value = errorMessage(e) } finally { busy.value = false }
}
async function check() {
if (!action.value || busy.value) return
busy.value = true; error.value = ''; preview.value = undefined; key = crypto.randomUUID()
try { preview.value = await api.checkDesktopPowerBatch({ action: action.value, desktopIds: ids.value }) }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function submit() {
if (!action.value || !canSubmit.value || !preview.value || busy.value) return
busy.value = true; error.value = ''
try {
const warning = action.value === 'start' ? '开机不代表桌面连接服务已经就绪。' : '会使所列桌面关机或重启,请确认使用者已保存工作。'
await ElMessageBox.confirm(`本次仅向预检通过的 ${preview.value.eligibleCount} 台桌面提交${labels[action.value]},其余 ${preview.value.skippedCount} 台跳过。${warning} 提交和执行前会再次检查,结果逐台记录。`, '确认批量操作', { type: 'warning', confirmButtonText: '提交批量任务', cancelButtonText: '取消' })
detail.value = await api.submitDesktopPowerBatch({ action: action.value, desktopIds: ids.value, previewToken: preview.value.previewToken }, key)
await platform.refresh()
} catch (e) { if (e !== 'cancel' && e !== 'close') error.value = errorMessage(e) } finally { busy.value = false }
}
async function reconcile(id: string) {
if (busy.value || !detail.value) return
busy.value = true; error.value = ''
try { await api.reconcileBatchPower(id); detail.value = await api.desktopPowerBatch(detail.value.batch.id); await platform.refresh() }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
function close(done: () => void) { if (!busy.value) done() }
function visibility(value: boolean) { if (!value) mode.value = undefined }
</script>
<template>
<el-dialog class="desktop-batch-dialog" :model-value="!!mode" :title="title" width="900px" :close-on-click-modal="false" :show-close="!busy" :before-close="close" @update:model-value="visibility">
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<template v-if="detail">
<p class="subtext">批次 {{ detail.batch.id }} · {{ labels[detail.batch.action] }} · {{ detail.batch.actor }}</p>
<div class="batch-counts"><span>已提交 <b>{{ detail.batch.submittedCount }}</b></span><span>执行中 <b>{{ detail.batch.activeCount }}</b></span><span>成功 <b>{{ detail.batch.successCount }}</b></span><span>失败 <b>{{ detail.batch.failedCount }}</b></span><span>未确认 <b>{{ detail.batch.uncertainCount }}</b></span><span>跳过 <b>{{ detail.batch.skippedCount }}</b></span></div>
<el-alert type="info" title="已提交不等于已成功;执行中的结果每 5 秒刷新。结果未确认的任务不会自动重发。" :closable="false" class="notice" />
<el-table :data="detail.items" row-key="desktopId"><el-table-column label="桌面" min-width="145"><template #default="{ row }">{{ row.name || row.desktopId }}<div class="secondary">VM {{ row.vmid || '未知' }}</div></template></el-table-column><el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag v-if="row.status !== 'skipped'" :value="row.status === 'running' ? '执行中' : row.status" /><el-tag v-else type="info">已跳过</el-tag></template></el-table-column><el-table-column prop="message" label="结果" min-width="230" /><el-table-column label="任务" min-width="130"><template #default="{ row }"><span v-if="row.taskId" class="mono secondary">{{ row.taskId.slice(0, 18) }}…</span><el-button v-if="auth.canOperate && row.status === 'timeout'" link type="primary" :disabled="busy" @click="reconcile(row.taskId)">核对电源结果</el-button></template></el-table-column></el-table>
</template>
<template v-else-if="mode === 'history'">
<p class="subtext">最近 100 个批次,可查看逐台任务和跳过原因。历史失败批次不会自动重新执行。</p>
<el-table :data="history" empty-text="暂无批量操作记录"><el-table-column label="操作 / 操作者" min-width="150"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="loadDetail(row.id)">{{ labels[row.action as DesktopAction] }} · {{ row.actor }}</el-button><div class="secondary">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</div></template></el-table-column><el-table-column prop="totalCount" label="选择" width="65" /><el-table-column prop="successCount" label="成功" width="65" /><el-table-column prop="activeCount" label="执行中" width="80" /><el-table-column prop="failedCount" label="失败" width="65" /><el-table-column prop="uncertainCount" label="未确认" width="80" /><el-table-column prop="skippedCount" label="跳过" width="65" /></el-table>
</template>
<template v-else>
<p class="subtext">已选择 {{ ids.length }} 台桌面,每批最多 50 台。批量操作会跳过活动会话、维护中、权限不足和状态不适用的桌面。</p>
<template v-if="preview"><div class="batch-counts"><span>可执行 <b>{{ preview.eligibleCount }}</b></span><span>跳过 <b>{{ preview.skippedCount }}</b></span><span>预检有效至 {{ new Date(preview.expiresAt).toLocaleTimeString('zh-CN') }}</span></div><el-table :data="preview.items" row-key="desktopId"><el-table-column label="桌面 / VMID" min-width="150"><template #default="{ row }">{{ row.name || row.desktopId }}<div class="secondary">VM {{ row.vmid || '未知' }}</div></template></el-table-column><el-table-column label="预检" width="100"><template #default="{ row }"><el-tag :type="row.available ? 'success' : 'info'">{{ row.available ? '可执行' : '跳过' }}</el-tag></template></el-table-column><el-table-column prop="message" label="说明" min-width="230" /></el-table></template>
<el-table v-else :data="ids.map(id => platform.desktops.find(d => d.id === id)).filter(Boolean)"><el-table-column prop="name" label="所选桌面" min-width="160" /><el-table-column prop="vmid" label="VMID" width="100" /><el-table-column label="当前状态" min-width="100"><template #default="{ row }"><StatusTag :value="row.powerState" /></template></el-table-column></el-table>
</template>
<template #footer><div class="batch-footer"><el-button :disabled="busy" @click="mode = undefined">关闭</el-button><template v-if="detail"><el-button v-if="mode === 'history'" :disabled="busy" @click="detail = undefined; loadHistory()">返回批次列表</el-button><el-button :loading="busy" @click="loadDetail(detail.batch.id)">刷新结果</el-button></template><el-button v-else-if="mode === 'history'" :loading="busy" @click="loadHistory">刷新批次</el-button><template v-else><el-button :loading="busy" :disabled="busy || !ids.length" @click="check">预检批量操作</el-button><el-button type="primary" :loading="busy" :disabled="busy || !canSubmit" @click="submit">提交批量操作</el-button></template></div></template>
</el-dialog>
</template>
<style scoped>
.subtext{line-height:1.8;margin:10px 0 20px;overflow-wrap:anywhere}.batch-counts{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0;color:var(--vdi-muted);font-size:12px}.batch-counts span{padding:9px 12px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:9px}.batch-counts b{color:var(--vdi-text);margin-left:5px}.batch-footer{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}.batch-footer .el-button{margin:0}@media(max-width:600px){.batch-footer .el-button{flex:1;min-width:105px}}
</style>
<style>.desktop-batch-dialog{display:flex;flex-direction:column;max-height:90dvh;margin:5dvh auto!important}.desktop-batch-dialog .el-dialog__body{min-height:0;overflow-y:auto;overscroll-behavior:contain}.desktop-batch-dialog .el-dialog__header,.desktop-batch-dialog .el-dialog__footer{flex-shrink:0}</style>

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, errorMessage, type Desktop, type VMHardware, type HardwareInput } from '../api'
import { usePlatform } from '../stores/platform'
const desktop = defineModel<Desktop | undefined>({ required: true })
const snapshot = ref<VMHardware>(), busy = ref(false), error = ref(''), tab = ref('compute'), cores = ref(1), sockets = ref(1), memory = ref(2048), disk = ref(''), size = ref(1), checked = ref(false)
let key = ''
const diskInfo = computed(() => snapshot.value?.disks.find(d => d.key === disk.value))
const input = computed<HardwareInput>(() => tab.value === 'compute' ? { kind: 'compute', cores: cores.value, sockets: sockets.value, memoryMiB: memory.value, digest: snapshot.value?.digest || '' } : { kind: 'disk', disk: disk.value, sizeGiB: size.value, digest: snapshot.value?.digest || '' })
const valid = computed(() => !!snapshot.value && !snapshot.value.blockers.length && snapshot.value.powerState === 'stopped' && (tab.value === 'compute' ? (cores.value !== snapshot.value.cores || sockets.value !== snapshot.value.sockets || memory.value !== snapshot.value.memoryMiB) && cores.value * sockets.value <= 1024 && memory.value % 128 === 0 : !!diskInfo.value && size.value > diskInfo.value.sizeGiB))
watch(input, () => { checked.value = false; key = crypto.randomUUID() })
watch(disk, () => { size.value = Math.ceil(diskInfo.value?.sizeGiB || 0) + 1 })
watch(desktop, async value => { if (value) { tab.value = 'compute'; snapshot.value = undefined; await load() } })
async function load() {
if (!desktop.value || busy.value) return
busy.value = true; error.value = ''; checked.value = false
try { snapshot.value = await api.desktopHardware(desktop.value.id); cores.value = snapshot.value.cores; sockets.value = snapshot.value.sockets; memory.value = snapshot.value.memoryMiB; disk.value = snapshot.value.disks[0]?.key || ''; size.value = Math.ceil(diskInfo.value?.sizeGiB || 0) + 1; key = crypto.randomUUID() }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function check() {
if (!desktop.value) return
busy.value = true; error.value = ''; checked.value = false
try { const result = await api.checkDesktopHardware(desktop.value.id, { ...input.value }); checked.value = result.available } catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function submit() {
if (!desktop.value || !checked.value) return
busy.value = true; error.value = ''
try {
const text = tab.value === 'compute' ? `CPU ${sockets.value} 插槽 × ${cores.value} 核,内存 ${memory.value} MiB` : `${disk.value} 扩容至 ${size.value} GiB;完成后需在客体内扩展分区和文件系统`
await ElMessageBox.confirm(`${desktop.value.name}:${text}。执行前会重新检查关机状态和配置。`, '确认规格变更', { confirmButtonText: '提交规格任务', cancelButtonText: '取消', type: 'warning' })
await api.changeDesktopHardware(desktop.value.id, { ...input.value }, key); ElMessage.success('规格任务已提交,请查看任务与审计中的最终结果'); desktop.value = undefined; await usePlatform().refresh()
} catch (e) { if (e !== 'cancel' && e !== 'close') error.value = errorMessage(e) } finally { busy.value = false }
}
function updateVisibility(value: boolean) { if (!value) desktop.value = undefined }
function close(done: () => void) { if (!busy.value) done() }
</script>
<template>
<el-dialog :model-value="!!desktop" title="桌面规格管理" width="680px" :close-on-click-modal="false" :show-close="!busy" :before-close="close" @update:model-value="updateVisibility">
<p class="subtext">{{ desktop?.name }} · VM {{ desktop?.vmid }} · 操作只在正常关机后执行。</p>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" /><el-skeleton v-if="busy && !snapshot" :rows="6" animated />
<template v-if="snapshot"><el-alert v-if="snapshot.blockers.length" type="warning" :title="snapshot.blockers.join(';')" :closable="false" class="notice" /><el-alert v-if="checked" type="success" title="预检通过,提交和执行时会再次核对" :closable="false" class="notice" /><el-tabs v-model="tab"><el-tab-pane label="CPU 与内存" name="compute" :disabled="busy" /><el-tab-pane label="磁盘扩容" name="disk" :disabled="busy" /></el-tabs><el-form label-position="top" :disabled="busy || !!snapshot.blockers.length"><template v-if="tab === 'compute'"><p class="subtext">当前:{{ snapshot.sockets }} 插槽 × {{ snapshot.cores }} 核 · {{ snapshot.memoryMiB }} MiB 内存</p><div class="compute-fields"><el-form-item label="每插槽核心数"><el-input-number v-model="cores" :min="1" :max="256" :precision="0" aria-label="每插槽核心数" /></el-form-item><el-form-item label="CPU 插槽数"><el-input-number v-model="sockets" :min="1" :max="8" :precision="0" aria-label="CPU 插槽数" /></el-form-item><el-form-item label="内存(MiB)"><el-input-number v-model="memory" :min="512" :max="1048576" :step="128" :precision="0" aria-label="目标内存" /></el-form-item></div><p class="subtext">目标共 {{ cores * sockets }} 个 vCPU。CPU 型号、GPU 和其他硬件保持现有配置。</p></template><template v-else><el-empty v-if="!snapshot.disks.length" description="没有可由平台扩容的虚拟磁盘" /><template v-else><el-form-item label="虚拟磁盘"><el-select v-model="disk" aria-label="扩容磁盘"><el-option v-for="d in snapshot.disks" :key="d.key" :value="d.key" :label="`${d.key} · ${d.storage} · ${d.sizeGiB} GiB`" /></el-select></el-form-item><el-form-item label="扩容后总容量(GiB)"><el-input-number v-model="size" :min="Math.floor(diskInfo?.sizeGiB || 0) + 1" :max="65536" :precision="0" aria-label="磁盘目标容量" /></el-form-item><el-alert title="仅扩容虚拟磁盘,不缩容;客体分区和文件系统需后续扩展。请确认已有可用备份。" type="warning" :closable="false" /></template></template></el-form></template>
<template #footer><el-button :disabled="busy" @click="desktop = undefined">关闭</el-button><el-button :disabled="busy" @click="load">重新读取</el-button><el-button :loading="busy" :disabled="busy || !valid" @click="check">预检规格</el-button><el-button type="primary" :loading="busy" :disabled="busy || !valid || !checked" @click="submit">提交变更</el-button></template>
</el-dialog>
</template>
<style scoped>.subtext{line-height:1.8;margin:12px 0 20px}.compute-fields{display:flex;gap:18px;flex-wrap:wrap}.el-form-item{margin:18px 0}.compute-fields .el-input-number{width:170px}</style>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, errorMessage, type Desktop, type SnapshotView, type SnapshotInput, type SnapshotItem } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
const desktop = defineModel<Desktop | undefined>({ required: true })
const auth = useAuth(), view = ref<SnapshotView>(), busy = ref(false), error = ref(''), checked = ref(false)
const action = ref<SnapshotInput['action']>('create'), name = ref(''), description = ref(''), confirm = ref('')
let key = ''
const selected = computed(() => view.value?.items.find(s => s.name === name.value))
const input = computed<SnapshotInput>(() => ({ action: action.value, name: name.value, digest: view.value?.digest || '', ...(action.value === 'create' ? { description: description.value } : { revision: selected.value?.revision || '' }), ...(action.value === 'rollback' ? { confirmVmid: Number(confirm.value) || 0 } : {}) }))
const hasChildren = (item: SnapshotItem) => !!view.value?.items.some(s => s.parent === item.name)
const permitted = computed(() => action.value === 'create' ? view.value?.canCreate : action.value === 'delete' ? view.value?.canDelete : view.value?.canRestore)
const valid = computed(() => auth.canAdmin && view.value && !view.value.blockers.length && permitted.value && (action.value === 'create' ? /^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(name.value) && !/^(current|pending)$/i.test(name.value) && !selected.value : !!selected.value && !selected.value.state && (action.value === 'delete' ? !hasChildren(selected.value) : !selected.value.includesMemory && confirm.value.trim() === String(desktop.value?.vmid))))
const actionLabel = computed(() => ({ create: '创建快照', delete: '删除快照', rollback: '还原快照' })[action.value])
watch(input, () => { checked.value = false; key = crypto.randomUUID() })
watch(desktop, async value => { if (value) { view.value = undefined; action.value = 'create'; name.value = ''; description.value = ''; confirm.value = ''; await load() } })
async function load() {
if (!desktop.value || busy.value) return
busy.value = true; error.value = ''; checked.value = false
try { view.value = await api.desktopSnapshots(desktop.value.id); key = crypto.randomUUID() }
catch (e) { view.value = undefined; error.value = errorMessage(e) } finally { busy.value = false }
}
function choose(kind: SnapshotInput['action'], item?: SnapshotItem) { action.value = kind; name.value = item?.name || ''; description.value = ''; confirm.value = ''; error.value = '' }
async function check() {
if (!desktop.value || !valid.value) return
busy.value = true; error.value = ''; checked.value = false
try { checked.value = (await api.checkDesktopSnapshot(desktop.value.id, { ...input.value })).available }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function submit() {
if (!desktop.value || !valid.value || !checked.value) return
busy.value = true; error.value = ''
try {
const detail = action.value === 'rollback' ? '将回退虚拟机配置以及快照内所有磁盘的数据(包括其中的数据盘),当前更改会丢失。完成后保持关机,需重新确认连接初始化、系统身份与配对。' : action.value === 'delete' ? '将永久删除此恢复点,无法通过它还原。' : '创建当前关机配置及磁盘的快照,不保存内存。'
await ElMessageBox.confirm(`${desktop.value.name} / VM ${desktop.value.vmid} / ${name.value}:${detail}`, `确认${actionLabel.value}`, { type: 'warning', confirmButtonText: `提交${actionLabel.value}任务`, cancelButtonText: '取消' })
await api.changeDesktopSnapshot(desktop.value.id, { ...input.value }, key)
ElMessage.success('快照任务已提交,请在任务与审计查看最终结果'); desktop.value = undefined; await usePlatform().refresh()
} catch (e) { if (e !== 'cancel' && e !== 'close') error.value = errorMessage(e) } finally { busy.value = false }
}
function updateVisibility(value: boolean) { if (!value) desktop.value = undefined }
function close(done: () => void) { if (!busy.value) done() }
</script>
<template>
<el-dialog class="snapshot-dialog" top="5vh" :model-value="!!desktop" title="快照与还原" width="780px" :close-on-click-modal="false" :show-close="!busy" :before-close="close" @update:model-value="updateVisibility">
<p class="subtext">{{ desktop?.name }} · VM {{ desktop?.vmid }} · 快照操作须先正常关机。</p>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<el-skeleton v-if="busy && !view" :rows="6" animated />
<template v-if="view">
<el-alert v-if="view.blockers.length" type="warning" :title="view.blockers.join(';')" :closable="false" class="notice" />
<p class="subtext">磁盘范围:{{ view.disks.join('、') || '请先处理配置限制' }}。还原覆盖快照内磁盘及配置,系统盘与数据盘不会自动分开。</p>
<div class="snapshot-heading"><h3>已有快照 <span class="subtext">{{ view.items.length }}</span></h3><el-button v-if="auth.canAdmin" :disabled="busy || !view.canCreate" @click="choose('create')">新建快照</el-button></div>
<el-empty v-if="!view.items.length" description="尚无快照,可在关机后创建恢复点" :image-size="65" />
<div v-for="item in view.items" :key="item.name" class="snapshot-item" :class="{ selected: action !== 'create' && name === item.name }">
<div class="snapshot-info"><strong>{{ item.name }}</strong><p>{{ item.description || '无描述' }}</p><p>{{ item.createdAt ? new Date(item.createdAt * 1000).toLocaleString('zh-CN') : '时间未知' }} · {{ item.includesMemory ? '含内存状态,暂不能还原' : '无内存状态' }}{{ item.state ? ' · 状态未完成' : '' }}</p><p v-if="hasChildren(item)">含子快照,请先处理子快照再删除</p></div>
<div v-if="auth.canAdmin" class="snapshot-actions"><el-button :disabled="busy || !!view.blockers.length || !view.canRestore || item.includesMemory || !!item.state" @click="choose('rollback', item)">还原</el-button><el-button type="danger" plain :disabled="busy || !!view.blockers.length || !view.canDelete || hasChildren(item) || !!item.state" @click="choose('delete', item)">删除</el-button></div>
</div>
<p v-if="!auth.canAdmin" class="subtext">当前角色可查看快照;管理快照需要超级管理员。</p>
<template v-else>
<p v-if="!permitted" class="subtext">PVE Token 缺少当前操作的快照权限,请联系 PVE 管理员配置。</p>
<el-form label-position="top" :disabled="busy || !!view.blockers.length || !permitted" class="snapshot-form">
<h3>{{ actionLabel }}</h3>
<el-form-item label="快照名称"><el-input v-model="name" :readonly="action !== 'create'" maxlength="40" aria-label="快照名称" placeholder="字母开头,1~40 位字母、数字、下划线或短横线" /></el-form-item>
<el-form-item v-if="action === 'create'" label="描述"><el-input v-model="description" type="textarea" :rows="2" maxlength="500" show-word-limit aria-label="快照描述" /></el-form-item>
<template v-if="action === 'rollback'"><el-alert type="warning" title="还原将丢弃快照之后的磁盘及配置更改,包括快照中的数据盘。还原成功后需重新确认连接初始化。" :closable="false" /><el-form-item :label="`输入 VMID ${desktop?.vmid} 确认还原范围`"><el-input v-model="confirm" inputmode="numeric" aria-label="还原确认 VMID" /></el-form-item></template>
</el-form>
<el-alert v-if="checked" type="success" title="预检通过,提交和执行前会再次核对" :closable="false" class="notice" />
</template>
</template>
<template #footer><div class="snapshot-footer"><el-button :disabled="busy" @click="desktop = undefined">关闭</el-button><el-button :disabled="busy" @click="load">重新读取</el-button><template v-if="auth.canAdmin"><el-button :loading="busy" :disabled="busy || !valid" @click="check">预检快照操作</el-button><el-button type="primary" :loading="busy" :disabled="busy || !valid || !checked" @click="submit">提交{{ actionLabel }}</el-button></template></div></template>
</el-dialog>
</template>
<style scoped>
.subtext{line-height:1.8;margin:12px 0}.snapshot-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 12px}.snapshot-item{display:flex;justify-content:space-between;gap:16px;padding:16px 0;border-bottom:1px solid var(--vdi-border)}.snapshot-info{min-width:0;overflow-wrap:anywhere}.snapshot-info p{color:var(--vdi-muted);font-size:12px;margin:8px 0;line-height:1.7;white-space:pre-wrap}.snapshot-actions{display:flex;align-items:center;flex-shrink:0}.snapshot-item.selected strong{color:var(--vdi-accent)}.snapshot-form{padding-top:22px}.snapshot-form h3{margin-bottom:18px}.snapshot-footer{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}.snapshot-footer .el-button{margin:0}@media(max-width:600px){.snapshot-item{flex-direction:column}.snapshot-actions{justify-content:flex-end}.snapshot-footer .el-button{flex:1;min-width:105px}}
</style>
<style>
.snapshot-dialog{display:flex;flex-direction:column;max-height:90dvh;margin:5dvh auto!important}.snapshot-dialog .el-dialog__body{min-height:0;overflow-y:auto;overscroll-behavior:contain}.snapshot-dialog .el-dialog__header,.snapshot-dialog .el-dialog__footer{flex-shrink:0}
</style>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, errorMessage, type PveConnectionInput } from '../api'
import { usePlatform } from '../stores/platform'
const visible = defineModel<boolean>({ required: true })
const form = reactive<PveConnectionInput>({ url: '', tokenId: '', tokenSecret: '', caPem: '', revision: '' })
const busy = ref(false), loaded = ref(false), error = ref(''), checked = ref(''), configured = ref(false), source = ref('')
watch(visible, async open => {
if (!open) return
busy.value = true; loaded.value = false; error.value = ''; checked.value = ''
try { const value = await api.pveConnection(); Object.assign(form, { url: value.url, tokenId: value.tokenId, caPem: value.caPem, tokenSecret: '', revision: value.revision }); configured.value = value.tokenConfigured; source.value = value.source; loaded.value = true }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
})
const canSubmit = computed(() => loaded.value && !busy.value && !!form.url && !!form.tokenId)
watch(form, () => { checked.value = '' })
async function check() {
busy.value = true; error.value = ''; checked.value = ''
try { const result = await api.checkPveConnection({ ...form }); checked.value = `${result.message}:${result.nodes} 个节点、${result.vms} 台 VM` } catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function save() {
busy.value = true; error.value = ''
try {
await ElMessageBox.confirm('保存时会再次检查连接,通过后立即生效。首次接入后仅允许维护同一接入地址。', '保存计算平台接入', { confirmButtonText: '确认保存', cancelButtonText: '取消', type: 'warning' })
await api.savePveConnection({ ...form }); ElMessage.success('接入配置已保存并生效'); visible.value = false; await usePlatform().refresh()
} catch (e) { if (e !== 'cancel' && e !== 'close') error.value = errorMessage(e) } finally { busy.value = false }
}
function close(done: () => void) { if (!busy.value) done() }
</script>
<template>
<el-dialog v-model="visible" title="Proxmox 接入配置" width="660px" :close-on-click-modal="false" :show-close="!busy" :before-close="close" @closed="form.tokenSecret = ''; form.caPem = ''; loaded = false">
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" /><el-alert v-if="checked" type="success" :title="checked" :closable="false" class="notice" />
<el-skeleton v-if="busy && !loaded" :rows="6" animated />
<el-form v-if="loaded" label-position="top" :disabled="busy"><p class="subtext">当前来源:{{ source === 'database' ? '控制台配置' : '服务端环境' }}。密钥只接收输入,留空可保留同一地址和 Token ID 的原密钥。</p><el-form-item label="HTTPS 接入地址"><el-input v-model="form.url" aria-label="PVE 接入地址" placeholder="https://pve.example.com:8006" maxlength="250" /></el-form-item><el-form-item label="API Token ID"><el-input v-model="form.tokenId" aria-label="PVE Token ID" placeholder="user@pve!token-name" maxlength="200" autocomplete="off" /></el-form-item><el-form-item label="API Token 密钥"><el-input v-model="form.tokenSecret" aria-label="PVE Token 密钥" type="password" :placeholder="configured ? '已配置,留空保留原密钥' : '请输入 Token 密钥'" autocomplete="new-password" maxlength="200" /></el-form-item><el-form-item label="CA 证书(PEM,可留空使用系统信任)"><el-input v-model="form.caPem" aria-label="PVE CA 证书" type="textarea" :rows="5" maxlength="32768" placeholder="-----BEGIN CERTIFICATE-----" /></el-form-item><p class="subtext">始终验证 TLS 证书。保存失败时原配置继续使用;本期更换集群地址需离线迁移。</p></el-form>
<template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button :disabled="!canSubmit" :loading="busy" @click="check">检查连接</el-button><el-button type="primary" :disabled="!canSubmit" :loading="busy" @click="save">保存并生效</el-button></template>
</el-dialog>
</template>
<style scoped>.subtext{line-height:1.8;margin:8px 0 18px}.el-form-item{margin-top:20px}</style>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ResourcePoint } from '../api'
const props = defineProps<{ points: ResourcePoint[]; metric: 'cpu' | 'memory' | 'storage'; title: string; bucket: number; from: number; to: number }>()
const valid = computed(() => props.points.filter(p => p[props.metric] != null))
const x = (v: string) => 44 + (new Date(v).getTime() - props.from) / Math.max(1, props.to - props.from) * 590
const y = (v: number) => 150 - Math.min(100, Math.max(0, v)) * 1.25
const path = computed(() => {
let last = 0
return props.points.map(p => {
const value = p[props.metric], current = new Date(p.time).getTime()
if (value == null) { last = 0; return '' }
const command = last && current - last <= props.bucket * 1800 ? 'L' : 'M'
last = current
return `${command}${x(p.time).toFixed(2)},${y(value).toFixed(2)}`
}).join(' ')
})
const stamp = (v: number) => new Date(v).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
</script>
<template>
<article class="trend">
<div class="trend-heading"><h3>{{ title }}</h3><strong>{{ valid.length ? `${valid[valid.length - 1]![metric]!.toFixed(1)}%` : '暂无采样' }}</strong></div>
<svg v-if="valid.length" viewBox="0 0 650 176" role="img" :aria-label="`${title}历史趋势`"><g v-for="v in [0, 50, 100]" :key="v"><line x1="44" x2="634" :y1="y(v)" :y2="y(v)" class="grid" /><text x="4" :y="y(v) + 4">{{ v }}%</text></g><path :d="path" class="series" /><circle v-for="p in valid" :key="p.time" :cx="x(p.time)" :cy="y(p[metric]!)" r="2" class="point"><title>{{ stamp(new Date(p.time).getTime()) }} · {{ p[metric]!.toFixed(1) }}%</title></circle></svg>
<div v-else class="empty-trend">此范围暂无有效采样</div><div class="time-axis"><span>{{ stamp(from) }}</span><span>{{ stamp(to) }}</span></div>
</article>
</template>
<style scoped>
.trend{border:1px solid var(--vdi-border);border-radius:14px;padding:18px;min-width:0}.trend-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.trend h3{font-size:13px}.trend strong{font-size:16px;color:var(--vdi-accent)}svg{width:100%;display:block;margin-top:16px;overflow:visible}.grid{stroke:var(--vdi-border);stroke-dasharray:4 4}text{fill:var(--vdi-muted);font-size:10px}.series{stroke:var(--vdi-accent);stroke-width:2;fill:none}.point{fill:var(--vdi-accent)}.time-axis{display:flex;justify-content:space-between;gap:10px;color:var(--vdi-muted);font-size:10px}.empty-trend{height:156px;display:grid;place-items:center;color:var(--vdi-muted);font-size:12px}
</style>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, errorMessage, type SessionRecord } from '../api'
const rows = ref<SessionRecord[]>([]), total = ref(0), page = ref(1), keyword = ref(''), status = ref(''), days = ref(1), busy = ref(false), error = ref('')
const labels: Record<string, string> = { connecting: '连接中', connected: '已连接', ended: '正常结束', interrupted: '连接中断' }
const reasons: Record<string, string> = { client_ended: '终端结束', replaced: '新连接替代', heartbeat_timeout: '心跳超时', identity_reset: '设备身份重置' }
const time = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'
function duration(row: SessionRecord) {
if (!row.connectedAt) return '未就绪'
const seconds = Math.max(0, Math.floor((new Date(row.endedAt || row.lastReportedAt).getTime() - new Date(row.connectedAt).getTime()) / 1000))
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`
}
async function load(reset = false) {
if (busy.value) return
if (reset) page.value = 1
busy.value = true; error.value = ''
try {
const to = new Date(), from = new Date(to.getTime() - days.value * 86400000)
const result = await api.sessionHistory({ page: page.value, keyword: keyword.value, status: status.value, from: from.toISOString(), to: to.toISOString() })
rows.value = result.items; total.value = result.total
} catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
onMounted(() => { void load() })
</script>
<template>
<section class="panel">
<div class="panel-heading"><div><h2>会话历史</h2><p class="subtext">保留 90 天。连接时长从画面就绪算起,活动会话统计到最近心跳。</p></div></div>
<div class="history-filters"><el-input v-model="keyword" aria-label="搜索历史会话" placeholder="终端、桌面或用户" clearable @keyup.enter="load(true)" /><el-select v-model="status" aria-label="历史会话状态"><el-option label="全部状态" value="" /><el-option v-for="(label, value) in labels" :key="value" :label="label" :value="value" /></el-select><el-select v-model="days" aria-label="历史时间范围"><el-option :value="1" label="最近 24 小时" /><el-option :value="7" label="最近 7 天" /><el-option :value="30" label="最近 30 天" /><el-option :value="90" label="最近 90 天" /></el-select><el-button :loading="busy" @click="load(true)">查询历史</el-button></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<el-table v-loading="busy" :data="rows" empty-text="所选时间范围内没有会话记录">
<el-table-column label="终端 / 用户" min-width="170"><template #default="{ row }"><strong>{{ row.terminalName }}</strong><div class="subtext">{{ row.displayName || row.username || '未关联用户' }}</div></template></el-table-column>
<el-table-column prop="desktopName" label="桌面" min-width="150" /><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.state === 'connected' ? 'success' : row.state === 'interrupted' ? 'danger' : 'info'">{{ labels[row.state] }}</el-tag></template></el-table-column>
<el-table-column label="开始 / 就绪" min-width="185"><template #default="{ row }"><div>{{ time(row.reservedAt) }}</div><div class="subtext">就绪 {{ time(row.connectedAt) }}</div><div v-if="row.source === 'observed'" class="subtext">从首次观测开始记录</div></template></el-table-column>
<el-table-column label="结束 / 原因" min-width="185"><template #default="{ row }"><div>{{ time(row.endedAt) }}</div><div class="subtext">{{ reasons[row.endReason] || (row.endedAt ? row.endReason : '会话进行中') }}</div></template></el-table-column>
<el-table-column label="连接时长" min-width="125"><template #default="{ row }">{{ duration(row) }}</template></el-table-column>
</el-table><el-pagination v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load()" />
</section>
</template>
<style scoped>
.history-filters{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0}.history-filters .el-input{width:240px}.history-filters .el-select{width:160px}.el-pagination{margin-top:20px;flex-wrap:wrap}.subtext{line-height:1.7}
@media(max-width:600px){.history-filters>*{flex:1;min-width:130px}.history-filters .el-input{width:100%;flex-basis:100%}}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TerminalMetricPoint } from '../api'
const props = defineProps<{ points: TerminalMetricPoint[]; metric: 'cpuPercent' | 'memoryPercent' | 'socTemperatureC'; title: string; unit: string; bucket: number; from: number; to: number }>()
const valid = computed(() => props.points.filter(p => p[props.metric] != null))
const bounds = computed(() => props.metric === 'socTemperatureC' ? { min: -40, max: 150 } : { min: 0, max: 100 })
const x = (v: string) => 44 + Math.max(0, Math.min(1, (new Date(v).getTime() - props.from) / Math.max(1, props.to - props.from))) * 590
const y = (v: number) => 150 - (v - bounds.value.min) / (bounds.value.max - bounds.value.min) * 125
const path = computed(() => {
let last = 0
return props.points.map(p => {
const value = p[props.metric], current = new Date(p.time).getTime()
if (value == null) { last = 0; return '' }
const command = last && current - last <= props.bucket * 1500 ? 'L' : 'M'
last = current
return `${command}${x(p.time).toFixed(2)},${y(value).toFixed(2)}`
}).join(' ')
})
const ticks = computed(() => [bounds.value.min, (bounds.value.min + bounds.value.max) / 2, bounds.value.max])
const stamp = (v: number) => new Date(v).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
</script>
<template>
<article class="metric-trend">
<h4>{{ title }}</h4>
<svg v-if="valid.length" viewBox="0 0 650 176" role="img" :aria-label="`${title}历史趋势`"><g v-for="v in ticks" :key="v"><line x1="44" x2="634" :y1="y(v)" :y2="y(v)" class="grid" /><text x="1" :y="y(v) + 4">{{ v }}{{ unit }}</text></g><path :d="path" class="series" /><circle v-for="p in valid" :key="p.time" :cx="x(p.time)" :cy="y(p[metric]!)" r="2" class="point"><title>{{ stamp(new Date(p.time).getTime()) }} · {{ p[metric]!.toFixed(1) }}{{ unit }}</title></circle></svg>
<div v-else class="empty-trend">此范围暂无有效采样</div>
<div class="time-axis"><span>{{ stamp(from) }}</span><span>{{ stamp(to) }}</span></div>
</article>
</template>
<style scoped>
.metric-trend{border:1px solid var(--vdi-border);border-radius:12px;padding:14px;min-width:0;margin-top:12px}h4{font-size:13px}svg{width:100%;display:block;margin-top:12px}.grid{stroke:var(--vdi-border);stroke-dasharray:4 4}text{fill:var(--vdi-muted);font-size:10px}.series{stroke:var(--vdi-accent);stroke-width:2;fill:none}.point{fill:var(--vdi-accent)}.time-axis{display:flex;justify-content:space-between;gap:10px;color:var(--vdi-muted);font-size:10px}.empty-trend{height:100px;display:grid;place-items:center;color:var(--vdi-muted);font-size:12px}
</style>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, computed } from 'vue'
import { api, type TerminalMetrics } from '../api'
import TerminalMetricTrend from './TerminalMetricTrend.vue'
const props = defineProps<{ terminalId: string }>()
const hours = ref(1), loading = ref(false), error = ref(''), result = ref<TerminalMetrics>()
let generation = 0
async function load() {
const request = ++generation, id = props.terminalId, to = new Date(), from = new Date(to.getTime() - hours.value * 3600000)
loading.value = true; error.value = ''; result.value = undefined
try {
const response = await api.terminalMetrics(id, from.toISOString(), to.toISOString())
if (request === generation) result.value = response
} catch (e) { if (request === generation) error.value = e instanceof Error ? e.message : '指标读取失败' }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.terminalId, hours.value], load, { immediate: true })
const timer = window.setInterval(() => { if (!loading.value) void load() }, 30000)
onBeforeUnmount(() => { generation++; window.clearInterval(timer) })
const state = computed(() => {
if (!result.value) return ''
const latest = result.value.latest
if (!latest.supported) return '设备尚未上报系统指标,请更新 Agent'
if (!latest.fresh) return '设备离线或采样已过期,当前数值已隐藏;历史仍可查看'
return '系统指标已接入 · 每 30 秒刷新'
})
const value = (n: number | null, unit: string) => n == null || !result.value?.latest.fresh ? '未采集' : `${n.toFixed(1)} ${unit}`
const uptime = computed(() => {
const n = result.value?.latest.uptimeSeconds
if (n == null || !result.value?.latest.fresh) return '未采集'
return `${Math.floor(n / 86400)} 天 ${Math.floor(n % 86400 / 3600)} 小时 ${Math.floor(n % 3600 / 60)} 分钟`
})
</script>
<template>
<section class="terminal-metrics" aria-label="终端运行指标">
<div class="metric-toolbar"><h3>运行指标</h3><el-select v-model="hours" aria-label="终端指标时间范围" style="width:130px"><el-option label="最近 1 小时" :value="1" /><el-option label="最近 24 小时" :value="24" /><el-option label="最近 7 天" :value="168" /></el-select><el-button :loading="loading" @click="load">刷新指标</el-button></div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<p v-else-if="loading" class="subtext" role="status">正在读取终端指标…</p>
<template v-else-if="result">
<p class="subtext metric-state">{{ state }}</p>
<div class="metric-values">
<div><span>CPU 使用率</span><strong>{{ value(result.latest.cpuPercent, '%') }}</strong></div>
<div><span>内存使用率</span><strong>{{ value(result.latest.memoryPercent, '%') }}</strong></div>
<div><span>SoC 温度</span><strong>{{ value(result.latest.socTemperatureC, '°C') }}</strong></div>
<div><span>设备运行时长</span><strong>{{ uptime }}</strong></div>
</div>
<p class="subtext metric-note">最近采样:{{ result.latest.sampledAt ? new Date(result.latest.sampledAt).toLocaleString('zh-CN') : '尚无采样' }}。历史保留 {{ result.retentionDays }} 天,曲线为 {{ result.bucketSeconds }} 秒聚合均值;缺失采样断线显示。</p>
<TerminalMetricTrend v-for="m in ([{ metric: 'cpuPercent', title: 'CPU 使用率', unit: '%' }, { metric: 'memoryPercent', title: '内存使用率', unit: '%' }, { metric: 'socTemperatureC', title: 'SoC 温度', unit: '°C' }] as const)" :key="m.metric" :points="result.items" :metric="m.metric" :title="m.title" :unit="m.unit" :bucket="result.bucketSeconds" :from="Date.parse(result.from)" :to="Date.parse(result.to)" />
</template>
<p class="subtext metric-note">内存按 MemAvailable 计算;温度来自 soc-thermal,表示 SoC 温度。串流 FPS/RTT 尚未采集,桌面设置中的目标帧率不作为实测值。</p>
</section>
</template>
<style scoped>
.terminal-metrics{margin-top:24px;border-top:1px solid var(--vdi-border);padding-top:20px}.metric-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}.metric-toolbar h3{font-size:14px;margin-right:auto}.metric-state{margin:12px 0;line-height:1.7}.metric-values{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-values>div{padding:14px;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:10px;min-width:0}.metric-values span{display:block;font-size:12px;color:var(--vdi-muted);margin-bottom:8px}.metric-values strong{font-size:15px;overflow-wrap:anywhere}.metric-note{font-size:12px;line-height:1.8;margin:14px 0}
</style>

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Monitor, Menu, Cpu, User, List, Setting, DataAnalysis, Connection, ArrowRight, SwitchButton, Close, Refresh } from '@element-plus/icons-vue'
import { Monitor, Menu, Cpu, User, List, Setting, DataAnalysis, Connection, ArrowRight, SwitchButton, Close, Refresh, Files, Collection, Key } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import ThemeToggle from '../components/ThemeToggle.vue'
import ChangePasswordDialog from '../components/ChangePasswordDialog.vue'
import brandMark from '../assets/brand-mark.svg'
const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter()
const items = [
@ -12,7 +13,12 @@ const items = [
{ path: '/desktops', title: '桌面管理', icon: Monitor, group: 'work' },
{ path: '/sessions', title: '远程会话', icon: Connection, group: 'work' },
{ path: '/terminals', title: '终端管理', icon: Menu, group: 'work' },
{ path: '/terminal-policies', title: '终端策略', icon: Setting, group: 'manage' },
{ path: '/resources', title: '计算资源', icon: Cpu, group: 'manage' },
{ path: '/gpus', title: 'GPU 资源', icon: Cpu, group: 'manage' },
{ path: '/monitor', title: '监控告警', icon: DataAnalysis, group: 'manage' },
{ path: '/templates', title: '镜像模板', icon: Files, group: 'manage' },
{ path: '/pools', title: '桌面池', icon: Collection, group: 'manage' },
{ path: '/users', title: '用户与授权', icon: User, group: 'manage' },
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
@ -22,6 +28,7 @@ const pending = computed(() => data.tasks.filter(t => t.status === 'queued' || t
const avatar = computed(() => (auth.user?.displayName || auth.user?.username || '管').slice(0, 1))
const mockMode = import.meta.env.VITE_API_MODE === 'mock'
const navOpen = ref(false)
const passwordVisible = ref(false)
watch(() => route.path, () => { navOpen.value = false })
let timer: ReturnType<typeof setInterval>
onMounted(() => { void data.refresh(); timer = setInterval(() => { if (!document.hidden) void data.refresh() }, 5000) })
@ -38,10 +45,11 @@ async function logout() { try { await auth.logout() } catch { /* Local session i
<div class="sidebar-bottom"><div class="profile"><div class="avatar">{{ avatar }}</div><div class="profile-name"><strong>{{ auth.user?.displayName }}</strong><small>{{ roles[auth.user?.role || ''] }}</small></div><el-button text aria-label="退出登录" title="退出登录" @click="logout"><el-icon :size="18"><SwitchButton /></el-icon></el-button></div><div class="sidebar-footnote">云 · 管 · 端<span>VDI Cloud</span></div></div>
</aside>
<div class="workspace">
<header class="topbar"><div class="topbar-left"><el-button class="mobile-menu" aria-label="打开导航" :aria-expanded="navOpen" aria-controls="console-navigation" @click="navOpen = !navOpen"><el-icon :size="20"><Menu /></el-icon></el-button><div class="breadcrumbs"><span>VDI CLOUD</span><el-icon :size="11"><ArrowRight /></el-icon><span>{{ route.meta.title }}</span></div></div><div class="topbar-right"><span v-if="mockMode || data.settings?.mode === 'demo'" class="environment is-demo"><i />{{ mockMode ? '模拟接口' : '演示环境' }}</span><span v-else-if="data.settings?.mode === 'production'" class="environment"><i />现场联调环境</span><el-button class="header-refresh" text aria-label="刷新平台数据" title="刷新平台数据" @click="data.refresh"><el-icon :size="18"><Refresh /></el-icon></el-button><ThemeToggle /></div></header>
<header class="topbar"><div class="topbar-left"><el-button class="mobile-menu" aria-label="打开导航" :aria-expanded="navOpen" aria-controls="console-navigation" @click="navOpen = !navOpen"><el-icon :size="20"><Menu /></el-icon></el-button><div class="breadcrumbs"><span>VDI CLOUD</span><el-icon :size="11"><ArrowRight /></el-icon><span>{{ route.meta.title }}</span></div></div><div class="topbar-right"><span v-if="mockMode || data.settings?.mode === 'demo'" class="environment is-demo"><i />{{ mockMode ? '模拟接口' : '演示环境' }}</span><span v-else-if="data.settings?.mode === 'production'" class="environment"><i />现场联调环境</span><el-button class="header-refresh" text aria-label="刷新平台数据" title="刷新平台数据" @click="data.refresh"><el-icon :size="18"><Refresh /></el-icon></el-button><el-button text aria-label="修改密码" title="修改本人密码" @click="passwordVisible = true"><el-icon :size="18"><Key /></el-icon></el-button><ThemeToggle /></div></header>
<main id="main-content" class="page-container"><el-alert v-if="data.error" class="notice" type="error" :closable="false" show-icon title="数据刷新失败,当前信息可能已过期"><template #default>{{ data.error }} <el-button link type="primary" @click="data.refresh">重新加载</el-button></template></el-alert><div v-if="data.loading" class="panel"><el-skeleton :rows="8" animated /></div><router-view v-else /></main>
<footer class="workspace-footer"><span><span class="refresh-dot" />{{ data.updatedAt ? `最近刷新 ${data.updatedAt}` : '等待加载' }}<span class="refresh-note"> · 每 5 秒更新</span></span><router-link to="/tasks">{{ pending ? `${pending} 个任务等待回执` : '查看任务与审计' }} <el-icon><ArrowRight /></el-icon></router-link></footer>
<footer class="workspace-footer"><span><span class="refresh-dot" /><template v-if="route.path === '/gpus'">GPU 清单按需读取,请手动刷新</template><template v-else>{{ data.updatedAt ? `最近刷新 ${data.updatedAt}` : '等待加载' }}<span class="refresh-note"> · 每 5 秒更新</span></template></span><router-link to="/tasks">{{ pending ? `${pending} 个任务等待回执` : '查看任务与审计' }} <el-icon><ArrowRight /></el-icon></router-link></footer>
</div>
<ChangePasswordDialog v-model="passwordVisible" />
</div>
</template>
<style scoped>

View File

@ -7,9 +7,14 @@ export const router = createRouter({ history: createWebHistory(), routes: [
{ path: '', redirect: '/overview' },
{ path: 'overview', component: () => import('../views/Overview.vue'), meta: { title: '总览' } },
{ path: 'resources', component: () => import('../views/Resources.vue'), meta: { title: '计算资源' } },
{ path: 'gpus', component: () => import('../views/GPUs.vue'), meta: { title: 'GPU 资源' } },
{ path: 'monitor', component: () => import('../views/Monitor.vue'), meta: { title: '监控告警' } },
{ path: 'desktops', component: () => import('../views/Desktops.vue'), meta: { title: '桌面管理' } },
{ path: 'templates', component: () => import('../views/Templates.vue'), meta: { title: '镜像模板' } },
{ path: 'pools', component: () => import('../views/Pools.vue'), meta: { title: '桌面池' } },
{ path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } },
{ path: 'terminals', component: () => import('../views/Terminals.vue'), meta: { title: '终端管理' } },
{ path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } },
{ path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } },
{ path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } },
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },

View File

@ -1,18 +1,32 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { Grid, List, Search, Plus, ArrowRight, Monitor } from '@element-plus/icons-vue'
import { api, type Desktop, type DesktopAction, type DiscoveredVM, type ImportInput } from '../api'
import { api, type Desktop, type DesktopAction, type DiscoveredVM, type ImportInput, type ConnectionInput } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
import SystemIcon from '../components/SystemIcon.vue'
import DesktopBatchDialog from '../components/DesktopBatchDialog.vue'
import DesktopSnapshotsDialog from '../components/DesktopSnapshotsDialog.vue'
import DesktopHardwareDialog from '../components/DesktopHardwareDialog.vue'
import CloneDesktopDialog from '../components/CloneDesktopDialog.vue'
const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction()
const keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref(''), importVisible = ref(false)
const view = ref<'cards' | 'table'>('cards')
const batchIds = ref<string[]>([]), batchAction = ref<DesktopAction | 'history'>()
watch(() => data.desktops, values => { batchIds.value = batchIds.value.filter(id => values.some(d => d.id === id)) })
const hardwareDesktop = ref<Desktop>(), snapshotDesktop = ref<Desktop>()
const cloneVisible = ref(false), connectionDesktop = ref<Desktop>()
const connection = reactive<ConnectionInput>({ endpoint: '', width: 2560, height: 1440, fps: 30, initialized: false })
function openConnection(d: Desktop) { connectionDesktop.value = d; Object.assign(connection, { endpoint: d.endpoint, width: d.width, height: d.height, fps: d.fps, initialized: false }) }
async function saveConnection() { if (connectionDesktop.value && await run(() => api.configureDesktop(connectionDesktop.value!.id, { ...connection }), '连接配置已保存')) connectionDesktop.value = undefined }
const selected = computed(() => data.desktops.find(d => d.id === selectedId.value))
const filtered = computed(() => data.desktops.filter(d => `${d.name} ${d.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === d.powerState)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const pageSelected = computed(() => !!rows.value.length && rows.value.every(d => batchIds.value.includes(d.id)))
function toggleDesktop(id: string) { batchIds.value = batchIds.value.includes(id) ? batchIds.value.filter(value => value !== id) : [...batchIds.value, id].slice(0, 50) }
function selectPage() { const pageIds = rows.value.map(d => d.id); batchIds.value = pageSelected.value ? batchIds.value.filter(id => !pageIds.includes(id)) : [...new Set([...batchIds.value, ...pageIds])].slice(0, 50) }
const form = reactive<ImportInput>({ name: '', os: 'Windows', node: '', vmid: 100, endpoint: '' })
const discovered = ref<DiscoveredVM[]>([]), vmKey = ref('')
const labels = { start: '开机', shutdown: '正常关机', reboot: '重启桌面' }
@ -26,21 +40,24 @@ function chooseVM(key: string) { const v = discovered.value.find(v => `${v.node}
async function submitImport() { if (!form.name.trim() || !form.node) return; if (await run(() => api.importDesktop(form), '桌面已纳管')) importVisible.value = false }
</script>
<template>
<div class="page-heading"><div><h1>桌面管理</h1><p class="subtext">统一管理 Windows / Ubuntu / 麒麟云桌面与授权</p></div><el-button v-if="auth.canAdmin" type="primary" :loading="busy" :disabled="!data.settings?.pveConfigured" @click="openImport"><el-icon style="margin-right:7px"><Plus /></el-icon>纳管已有 VM</el-button></div>
<div class="page-heading"><div><h1>桌面管理</h1><p class="subtext">统一管理 Windows / Ubuntu / 麒麟云桌面与授权</p></div><div v-if="auth.canAdmin"><el-button :disabled="!data.settings?.pveConfigured" @click="cloneVisible = true">从模板创建</el-button><el-button type="primary" :loading="busy" :disabled="!data.settings?.pveConfigured" @click="openImport"><el-icon style="margin-right:7px"><Plus /></el-icon>纳管已有 VM</el-button></div></div>
<el-alert v-if="!data.settings?.pveConfigured" class="notice" type="info" title="当前可操作演示桌面;配置 Proxmox 后可纳管真实 VM。" :closable="false" show-icon />
<section class="panel desktop-browser" :class="{ 'card-browser': view === 'cards' }">
<div class="desktop-summary"><div><h2>云桌面资源</h2><p class="subtext">已纳管 {{ data.desktops.length }} 台桌面 · 按系统与电源状态查看</p></div><div class="summary-chips"><span>全部 <b>{{ data.desktops.length }}</b></span><span><i class="health-dot tone-ok" />运行中 <b>{{ data.desktops.filter(d => d.powerState === 'running').length }}</b></span><span>未授权 <b>{{ data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length }}</b></span></div></div>
<div class="desktop-batch-bar"><template v-if="auth.canOperate"><el-checkbox :model-value="pageSelected" :indeterminate="!pageSelected && rows.some(d => batchIds.includes(d.id))" :disabled="!rows.length || !!batchAction" @change="selectPage">选择本页</el-checkbox><span class="secondary">已选 {{ batchIds.length }} / 50 台(可跨页)</span><el-button :disabled="!batchIds.length" @click="batchIds = []">清空选择</el-button><el-button :disabled="!batchIds.length" @click="batchAction = 'start'">批量开机</el-button><el-button :disabled="!batchIds.length" @click="batchAction = 'shutdown'">批量关机</el-button><el-button :disabled="!batchIds.length" @click="batchAction = 'reboot'">批量重启</el-button></template><el-button @click="batchAction = 'history'">批量记录</el-button></div>
<div class="toolbar desktop-toolbar"><el-input v-model="keyword" aria-label="搜索桌面" placeholder="搜索名称 / VMID" clearable @input="page = 1"><template #prefix><el-icon><Search /></el-icon></template></el-input><el-select v-model="status" aria-label="电源状态筛选" placeholder="全部电源状态" clearable @change="page = 1"><el-option label="运行中" value="running" /><el-option label="已关机" value="stopped" /></el-select><span class="secondary result-count">共 {{ filtered.length }} 台</span><el-button-group class="view-switch" aria-label="桌面展示方式"><el-button :type="view === 'cards' ? 'primary' : 'default'" aria-label="卡片视图" :aria-pressed="view === 'cards'" @click="view = 'cards'"><el-icon><Grid /></el-icon></el-button><el-button :type="view === 'table' ? 'primary' : 'default'" aria-label="列表视图" :aria-pressed="view === 'table'" @click="view = 'table'"><el-icon><List /></el-icon></el-button></el-button-group></div>
<div v-if="view === 'cards' && rows.length" class="desktop-grid"><article v-for="d in rows" :key="d.id" class="desktop-card" :data-desktop-id="d.id"><div class="desktop-cover" :class="{ ubuntu: d.os === 'Ubuntu', kylin: d.os === '麒麟' }"><div class="os-icon"><SystemIcon :os="d.os" /></div><span class="desktop-vmid">VM {{ d.vmid }}</span><div class="cover-grid" /></div><div class="desktop-body"><div class="desktop-name-row"><h2>{{ d.name }}</h2><StatusTag :value="d.powerState" /></div><p class="subtext desktop-os">{{ d.os }} · {{ d.node }}</p><div class="desktop-chips"><span>{{ d.width }} × {{ d.height }}</span><span>{{ d.fps }} FPS 请求</span><span>{{ d.provider === 'proxmox' ? 'Proxmox' : '演示资源' }}</span></div><dl class="desktop-meta"><div><dt>授权用户</dt><dd>{{ data.users.filter(u => u.enabled && u.desktopIds.includes(d.id)).map(u => u.displayName).join('、') || '尚未分配' }}</dd></div><div><dt>连接服务</dt><dd>{{ d.serviceState === 'ready' ? '已就绪' : '尚未检测' }}</dd></div></dl><div class="desktop-card-actions"><el-button link @click="selectedId = d.id">查看详情<el-icon><ArrowRight /></el-icon></el-button><el-button v-if="auth.canOperate" :type="d.powerState === 'stopped' ? 'primary' : 'default'" :disabled="busy || activeTask(d)" @click="action(d, d.powerState === 'stopped' ? 'start' : 'shutdown')">{{ activeTask(d) ? '任务执行中' : d.powerState === 'stopped' ? '开机' : '正常关机' }}</el-button></div></div></article></div>
<div v-if="view === 'cards' && rows.length" class="desktop-grid"><article v-for="d in rows" :key="d.id" class="desktop-card" :data-desktop-id="d.id"><div class="desktop-cover" :class="{ ubuntu: d.os === 'Ubuntu', kylin: d.os === '麒麟' }"><el-checkbox v-if="auth.canOperate" :model-value="batchIds.includes(d.id)" @change="toggleDesktop(d.id)" :aria-label="`选择桌面 ${d.name}`" class="desktop-select" :disabled="batchIds.length >= 50 && !batchIds.includes(d.id)" /><div class="os-icon"><SystemIcon :os="d.os" /></div><span class="desktop-vmid">VM {{ d.vmid }}</span><div class="cover-grid" /></div><div class="desktop-body"><div class="desktop-name-row"><h2>{{ d.name }}</h2><StatusTag :value="d.powerState" /></div><p class="subtext desktop-os">{{ d.os }} · {{ d.node }}</p><div class="desktop-chips"><el-tag v-if="d.setupRequired" type="warning" size="small">待初始化</el-tag><span>{{ d.width }} × {{ d.height }}</span><span>{{ d.fps }} FPS 请求</span><span>{{ d.provider === 'proxmox' ? 'Proxmox' : '演示资源' }}</span></div><dl class="desktop-meta"><div><dt>授权用户</dt><dd>{{ data.users.filter(u => u.enabled && u.desktopIds.includes(d.id)).map(u => u.displayName).join('、') || '尚未分配' }}</dd></div><div><dt>连接服务</dt><dd>{{ d.setupRequired ? '待初始化' : d.serviceState === 'ready' ? '已就绪' : '尚未检测' }}</dd></div></dl><div class="desktop-card-actions"><el-button link @click="selectedId = d.id">查看详情<el-icon><ArrowRight /></el-icon></el-button><el-button v-if="auth.canOperate" :type="d.powerState === 'stopped' ? 'primary' : 'default'" :disabled="busy || activeTask(d)" @click="action(d, d.powerState === 'stopped' ? 'start' : 'shutdown')">{{ activeTask(d) ? '任务执行中' : d.powerState === 'stopped' ? '开机' : '正常关机' }}</el-button></div></div></article></div>
<div v-else-if="view === 'cards'" class="panel empty-panel"><div><div class="empty-icon"><el-icon :size="28"><Monitor /></el-icon></div><h3>暂无符合条件的桌面</h3><p class="subtext">调整筛选条件,或先纳管已有虚拟机。</p></div></div>
<el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column label="桌面 / VMID" min-width="175"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="secondary">VM {{ row.vmid }} · {{ row.provider === 'demo' ? '演示资源' : 'Proxmox' }}</div></template></el-table-column><el-table-column prop="os" label="系统" width="100" /><el-table-column label="节点 / 池" min-width="150"><template #default="{ row }">{{ row.node }}<div class="secondary">{{ row.pool || '未入池' }}</div></template></el-table-column><el-table-column label="电源 / 连接服务" width="155"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary" style="margin-top:5px">连接服务:{{ row.serviceState === 'ready' ? '就绪' : '未检测' }}</div></template></el-table-column><el-table-column label="操作" min-width="200"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || activeTask(row)" @click="action(row, row.powerState === 'stopped' ? 'start' : 'shutdown')">{{ row.powerState === 'stopped' ? '开机' : '关机' }}</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || row.powerState !== 'running' || activeTask(row)" @click="action(row, 'reboot')">重启</el-button><el-tag v-if="activeTask(row)" size="small">任务中</el-tag></div></template></el-table-column></el-table>
<el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column v-if="auth.canOperate" label="选择" width="60"><template #default="{ row }"><el-checkbox :model-value="batchIds.includes(row.id)" @change="toggleDesktop(row.id)" :aria-label="`选择桌面 ${row.name}`" :disabled="batchIds.length >= 50 && !batchIds.includes(row.id)" /></template></el-table-column><el-table-column label="桌面 / VMID" min-width="175"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="secondary">VM {{ row.vmid }} · {{ row.provider === 'demo' ? '演示资源' : 'Proxmox' }}</div></template></el-table-column><el-table-column prop="os" label="系统" width="100" /><el-table-column label="节点 / 池" min-width="150"><template #default="{ row }">{{ row.node }}<div class="secondary">{{ row.pool || '未入池' }}</div></template></el-table-column><el-table-column label="电源 / 连接服务" width="155"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary" style="margin-top:5px">连接服务:{{ row.setupRequired ? '待初始化' : row.serviceState === 'ready' ? '就绪' : '未检测' }}</div></template></el-table-column><el-table-column label="操作" min-width="200"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || activeTask(row)" @click="action(row, row.powerState === 'stopped' ? 'start' : 'shutdown')">{{ row.powerState === 'stopped' ? '开机' : '关机' }}</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || row.powerState !== 'running' || activeTask(row)" @click="action(row, 'reboot')">重启</el-button><el-tag v-if="activeTask(row)" size="small">任务中</el-tag></div></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" />
</section>
<el-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">还原镜像和模板发放将在后续阶段接入,本版只提供开关机与重启。</p><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer>
<el-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.setupRequired ? '待初始化,暂不能连接' : selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">从模板创建后需完成系统初始化和 Sunshine 配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer>
<el-dialog v-model="importVisible" title="纳管已有 VM" width="520px"><el-form label-position="top" @submit.prevent="submitImport"><el-form-item label="选择 VM" required><el-select v-model="vmKey" placeholder="选择节点和虚拟机" @change="chooseVM"><el-option v-for="v in discovered" :key="`${v.node}/${v.vmid}`" :value="`${v.node}/${v.vmid}`" :label="`${v.node} / ${v.vmid} / ${v.name}`" /></el-select></el-form-item><el-form-item label="桌面名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in (['Windows', 'Ubuntu', '麒麟'] as const)" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="桌面连接地址(可后续补充)"><el-input v-model="form.endpoint" placeholder="VM 的 IP 或主机名" /></el-form-item><p class="subtext">纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="importVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.node || !form.name.trim()">确认纳管</el-button></div></el-form></el-dialog>
<DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" />
<el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog>
</template>
<style scoped>
.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px}
.desktop-batch-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 20px;font-size:12px}.desktop-batch-bar .el-button{margin:0}.desktop-select{position:absolute;top:2px;right:8px;z-index:2}.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px}
@media(min-width:1700px){.desktop-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:1200px){.desktop-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:600px){.desktop-grid{grid-template-columns:1fr}.card-browser .desktop-summary{padding:18px}.desktop-summary{gap:16px}.result-count{display:none}.view-switch{margin-left:0}.desktop-toolbar{gap:12px}.desktop-toolbar .el-input{flex:1;min-width:190px}.desktop-toolbar .el-select{width:calc(100% - 95px)}.desktop-toolbar .el-input{order:-2;flex-basis:100%}.desktop-body{padding:21px}.desktop-cover{height:95px}}

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { Cpu, Refresh, Search } from '@element-plus/icons-vue'
import { api, errorMessage, type GPUInventory, type GPUItem } from '../api'
import { usePlatform } from '../stores/platform'
import StatusTag from '../components/StatusTag.vue'
const platform = usePlatform(), node = ref(''), inventory = ref<GPUInventory>(), busy = ref(false), error = ref(''), keyword = ref(''), state = ref(''), selected = ref<GPUItem>()
const nodes = computed(() => platform.resources.filter(r => r.provider === 'proxmox'))
const labels = { referenced: '有配置引用', multiple: '多 VM 引用', unreferenced: '未发现配置引用', unknown: '待核查' }
const tones = { referenced: 'info', multiple: 'warning', unreferenced: 'info', unknown: 'warning' } as const
const items = computed(() => (inventory.value?.items || []).filter(g => (!state.value || g.state === state.value) && `${g.name} ${g.vendor} ${g.address} ${g.references.map(r => `${r.vmName} ${r.vmid} ${r.desktopName}`).join(' ')}`.toLowerCase().includes(keyword.value.toLowerCase())))
const referencedCount = computed(() => inventory.value?.items.filter(g => g.references.length).length || 0)
const uncertainCount = computed(() => inventory.value?.items.filter(g => g.state === 'unknown' || g.state === 'multiple').length || 0)
const now = ref(Date.now()), stale = computed(() => !!inventory.value && now.value - Date.parse(inventory.value.observedAt) > 90000)
const timer = setInterval(() => { now.value = Date.now() }, 5000)
let generation = 0
onUnmounted(() => { generation++; clearInterval(timer) })
watch(nodes, values => { if (!values.some(r => r.id === node.value)) node.value = values[0]?.id || '' }, { immediate: true })
watch(node, () => { selected.value = undefined; void load() }, { immediate: true })
async function load() {
const version = ++generation, target = node.value
inventory.value = undefined; error.value = ''; busy.value = !!target
if (!target) return
try { const value = await api.gpuInventory(target); if (version === generation) { inventory.value = value; now.value = Date.now() } }
catch (e) { if (version === generation) error.value = errorMessage(e) }
finally { if (version === generation) busy.value = false }
}
const owners = (gpu: GPUItem) => [...new Set(gpu.references.map(r => `${r.vmName || 'VM'} · ${r.vmid}`))].join('、')
</script>
<template>
<div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div>
<el-alert v-if="!platform.settings?.pveConfigured" type="info" title="尚未接入 Proxmox" description="由管理员在系统设置配置接入后,读取节点的真实显卡清单。" :closable="false" class="notice" />
<el-alert v-else-if="!nodes.length" type="warning" title="暂无可查询节点,请先在计算资源同步节点" :closable="false" class="notice" />
<section class="panel gpu-controls"><div class="toolbar"><el-select v-model="node" aria-label="GPU 计算节点" placeholder="选择计算节点"><el-option v-for="n in nodes" :key="n.id" :value="n.id" :label="n.name" /></el-select><el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索显卡、PCI 或虚拟机" aria-label="搜索 GPU" /><el-select v-model="state" clearable placeholder="全部引用状态" aria-label="GPU 引用状态"><el-option v-for="(label, value) in labels" :key="value" :value="value" :label="label" /></el-select></div><p class="secondary">配置引用包含关机虚拟机、模板和待应用变更。当前页面不判断宿主驱动占用或设备是否可分配。</p></section>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert>
<template v-if="inventory">
<div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div>
<section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section>
<div v-if="items.length" class="gpu-grid"><article v-for="gpu in items" :key="gpu.id" class="panel gpu-card"><div class="gpu-card-top"><div class="gpu-symbol"><el-icon :size="26"><Cpu /></el-icon></div><el-tag :type="tones[gpu.state]">{{ labels[gpu.state] }}</el-tag></div><p class="eyebrow">{{ gpu.vendor || gpu.vendorId }}</p><h2>{{ gpu.name || '显卡 ' + gpu.deviceId }}</h2><dl><div><dt>PCI 地址</dt><dd class="mono">{{ gpu.address }}</dd></div><div><dt>节点</dt><dd>{{ inventory.node }}</dd></div><div><dt>IOMMU 分组</dt><dd>{{ gpu.iommuGroup === null ? '未报告' : gpu.iommuGroup }}</dd></div><div><dt>配置引用</dt><dd>{{ owners(gpu) || (gpu.state === 'unknown' ? '检查范围不完整' : '未发现') }}</dd></div></dl><div class="gpu-card-footer"><span class="secondary">利用率、显存与温度尚未采集</span><el-button link type="primary" @click="selected = gpu">查看 GPU 详情</el-button></div></article></div>
<section v-else class="panel"><el-empty :description="inventory.items.length ? '暂无符合筛选条件的显卡' : '当前节点未发现显示控制器类 PCI 设备'" /></section>
</template>
<section v-else-if="busy" class="panel empty-panel" aria-live="polite"><p class="subtext">正在读取显卡与虚拟机配置…</p></section>
<el-drawer :model-value="!!selected" title="GPU 资源详情" size="600px" @closed="selected = undefined">
<template v-if="selected && inventory"><h2 class="gpu-detail-name">{{ selected.name || selected.deviceId }}</h2><p class="subtext mono">{{ inventory.node }} · {{ selected.address }}</p><el-tag :type="tones[selected.state]" style="margin:16px 0">{{ labels[selected.state] }}</el-tag><el-descriptions :column="1" border><el-descriptions-item label="厂商">{{ selected.vendor || '未报告' }}</el-descriptions-item><el-descriptions-item label="厂商 / 设备 ID">{{ selected.vendorId }} / {{ selected.deviceId }}</el-descriptions-item><el-descriptions-item label="PCI 类别">{{ selected.class }}</el-descriptions-item><el-descriptions-item label="IOMMU 分组">{{ selected.iommuGroup === null ? '未报告' : selected.iommuGroup }}</el-descriptions-item></el-descriptions><h3 class="gpu-ref-title">虚拟机配置引用</h3><p class="subtext">配置记录不代表设备实际已由虚拟机使用;待应用项在重启后可能生效。</p><article v-for="(owner, index) in selected.references" :key="index" class="gpu-reference"><div class="row"><strong>{{ owner.vmName || '虚拟机' }} · VM {{ owner.vmid }}</strong><StatusTag :value="owner.vmStatus" /></div><div class="gpu-ref-tags"><el-tag size="small">{{ owner.phase === 'current' ? '当前配置' : '待应用配置' }}</el-tag><el-tag v-if="owner.pendingRemoval" size="small" type="warning">待移除</el-tag><el-tag v-if="owner.template" size="small" type="info">模板</el-tag><el-tag size="small" type="info">{{ owner.mediated ? '介导设备' : 'PCI 直通' }}</el-tag></div><p class="secondary">{{ owner.slot }} · {{ owner.source === 'mapping' ? 'PCI 映射 ' + owner.mapping : '直接 PCI 地址' }}</p><p class="secondary">{{ owner.desktopId ? '已纳管桌面:' + owner.desktopName : '尚未纳管为平台桌面' }}</p></article><el-empty v-if="!selected.references.length" :description="selected.state === 'unknown' ? '检查范围不完整,引用情况待核查' : '未发现 QEMU 配置引用'" /><p class="subtext">本页提供资源查看;显卡绑定、解绑、运行驱动及健康指标尚未接入。</p></template>
</el-drawer>
</template>
<style scoped>
.gpu-controls .toolbar{margin-bottom:12px}.gpu-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;color:var(--vdi-muted);font-size:12px}.gpu-summary>span{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:9px;padding:12px}.gpu-summary b{color:var(--vdi-text);margin-left:8px}.gpu-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.gpu-card{margin:0;display:flex;flex-direction:column}.gpu-card-top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:22px}.gpu-symbol{width:52px;height:52px;display:grid;place-items:center;background:var(--vdi-accent-soft);color:var(--vdi-accent);border-radius:13px}.gpu-card h2,.gpu-detail-name{overflow-wrap:anywhere;line-height:1.6}.gpu-card dl{margin:22px 0;display:grid;gap:16px;font-size:12px}.gpu-card dl>div{display:flex;justify-content:space-between;gap:20px}.gpu-card dt{color:var(--vdi-muted);flex-shrink:0}.gpu-card dd{margin:0;text-align:right;overflow-wrap:anywhere}.gpu-card-footer{margin-top:auto;padding-top:14px;border-top:1px solid var(--vdi-border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.gpu-issues{border-color:var(--vdi-warn)}.gpu-issues h2{font-size:15px}.gpu-issues ul{padding-left:20px;margin-top:12px;color:var(--vdi-muted);font-size:12px;line-height:2;overflow-wrap:anywhere}.gpu-ref-title{margin:25px 0 8px;font-size:15px}.gpu-reference{margin-top:16px;padding:16px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.gpu-reference .row{margin:0 0 12px}.gpu-reference strong{font-size:13px}.gpu-ref-tags{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}@media(min-width:1700px){.gpu-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:950px){.gpu-grid{grid-template-columns:1fr}}@media(max-width:600px){.gpu-controls .toolbar .el-select{width:100%}.gpu-summary{gap:8px}.gpu-summary>span{padding:9px}.gpu-card-footer{align-items:flex-start}}
</style>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { api, errorMessage, type AlertRule, type AlertRuleInput, type AlertRecord, type ResourcePoint } from '../api'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import ResourceTrend from '../components/ResourceTrend.vue'
const data = usePlatform(), auth = useAuth(), { run, busy: saving } = useAction()
const metricLabels: Record<string, string> = { node_cpu: '节点 CPU', node_memory: '节点内存', node_storage: '节点存储', node_unavailable: '节点不可用 / 采集异常', terminal_offline: '终端离线' }
const rules = ref<AlertRule[]>([]), alerts = ref<AlertRecord[]>([]), points = ref<ResourcePoint[]>([])
const resourceId = ref(''), hours = ref(24), bucket = ref(300), from = ref(Date.now() - 86400000), to = ref(Date.now())
const status = ref(''), keyword = ref(''), page = ref(1), total = ref(0), firing = ref(0), unacknowledged = ref(0), loading = ref(false), error = ref(''), chartError = ref('')
const nodes = computed(() => data.resources.filter(r => r.provider === 'proxmox'))
const tab = ref('alerts'), dialog = ref(false), editId = ref('')
const form = reactive<AlertRuleInput>({ name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
const offline = computed(() => ['node_unavailable', 'terminal_offline'].includes(form.metric))
const stamp = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'
const reasons: Record<string, string> = { condition_cleared: '指标恢复', rule_changed: '规则变更', target_unavailable: '目标移除或停用' }
async function chart() {
chartError.value = ''
if (!resourceId.value) { points.value = []; return }
const selected = resourceId.value, selectedHours = hours.value, end = Date.now(), start = end - selectedHours * 3600000
try {
const result = await api.resourceSeries(selected, new Date(start).toISOString(), new Date(end).toISOString())
if (resourceId.value !== selected || hours.value !== selectedHours) return
points.value = result.items; bucket.value = result.bucketSeconds; from.value = start; to.value = end
} catch (e) { chartError.value = errorMessage(e); points.value = [] }
}
async function load(reset = false) {
if (loading.value) return
if (reset) page.value = 1
loading.value = true; error.value = ''
try {
const [r, a] = await Promise.all([api.alertRules(), api.alerts(page.value, status.value, keyword.value)])
rules.value = r.items; alerts.value = a.items; total.value = a.total; firing.value = a.firingCount; unacknowledged.value = a.unacknowledgedCount
if (!resourceId.value) resourceId.value = nodes.value[0]?.id || ''
await chart()
} catch (e) { error.value = errorMessage(e) } finally { loading.value = false }
}
function edit(rule?: AlertRule) {
editId.value = rule?.id || ''
Object.assign(form, rule ? { name: rule.name, metric: rule.metric, threshold: rule.threshold, holdSeconds: rule.holdSeconds, enabled: rule.enabled } : { name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
dialog.value = true
}
function metricChanged() { form.threshold = offline.value ? 180 : 90; form.holdSeconds = offline.value ? 0 : 120 }
async function save() {
if (!form.name.trim()) return
const ok = await run(() => api.saveAlertRule(editId.value, { ...form, name: form.name.trim() }), '告警规则已保存')
if (ok) { dialog.value = false; await load() }
}
async function acknowledge(row: AlertRecord) { if (await run(() => api.acknowledgeAlert(row.id), '已确认,恢复状态仍由指标判断')) await load() }
let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !dialog.value) void load() }, 15000) })
onUnmounted(() => clearInterval(timer))
</script>
<template>
<div class="page-heading"><div><h1>监控告警</h1><p class="subtext">节点历史趋势与终端离线告警 · 每 15 秒刷新</p></div><el-button :loading="loading" @click="load()">刷新监控</el-button></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<div class="monitor-summary"><article class="panel"><span>正在告警</span><strong :class="firing ? 'tone-danger' : 'tone-ok'">{{ firing }}</strong></article><article class="panel"><span>等待确认</span><strong>{{ unacknowledged }}</strong></article><article class="panel"><span>启用规则</span><strong>{{ rules.filter(r => r.enabled).length }}</strong></article></div>
<section class="panel">
<div class="panel-heading"><div><h2>节点资源趋势</h2><p class="subtext">采样保留 30 天,图表单次最多查看 7 天;空缺表示无有效数据。</p></div><div class="chart-filters"><el-select v-model="resourceId" aria-label="监控节点" placeholder="暂无节点" @change="chart"><el-option v-for="r in nodes" :key="r.id" :label="r.name" :value="r.id" /></el-select><el-select v-model="hours" aria-label="监控时间范围" @change="chart"><el-option :value="1" label="最近 1 小时" /><el-option :value="6" label="最近 6 小时" /><el-option :value="24" label="最近 24 小时" /><el-option :value="168" label="最近 7 天" /></el-select></div></div>
<el-alert v-if="chartError" type="error" :title="chartError" :closable="false" class="notice" /><div class="trend-grid"><ResourceTrend v-for="m in ([{ key: 'cpu', label: 'CPU 使用率' }, { key: 'memory', label: '内存使用率' }, { key: 'storage', label: '存储使用率' }] as const)" :key="m.key" :points="points" :metric="m.key" :title="m.label" :bucket="bucket" :from="from" :to="to" /></div>
</section>
<section class="panel">
<el-tabs v-model="tab"><el-tab-pane label="告警事件" name="alerts" /><el-tab-pane label="告警规则" name="rules" /></el-tabs>
<template v-if="tab === 'alerts'">
<div class="alert-filters"><el-input v-model="keyword" aria-label="搜索告警" placeholder="规则或目标名称" clearable @keyup.enter="load(true)" /><el-select v-model="status" aria-label="告警状态"><el-option value="" label="全部状态" /><el-option value="firing" label="正在告警" /><el-option value="resolved" label="已恢复" /></el-select><el-button :loading="loading" @click="load(true)">查询告警</el-button></div>
<el-table :data="alerts" empty-text="暂无告警事件"><el-table-column label="规则 / 目标" min-width="190"><template #default="{ row }"><strong>{{ row.ruleName }}</strong><div class="subtext">{{ row.targetName }}</div></template></el-table-column><el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="row.status === 'firing' ? 'danger' : 'success'">{{ row.status === 'firing' ? '正在告警' : '已恢复' }}</el-tag></template></el-table-column><el-table-column label="观测值 / 阈值" min-width="160"><template #default="{ row }">{{ row.value.toFixed(1) }} / {{ row.threshold }} {{ row.metric.endsWith('offline') || row.metric === 'node_unavailable' ? '秒' : '%' }}<div v-if="row.metric === 'node_unavailable'" class="subtext">含节点离线状态检测</div></template></el-table-column><el-table-column label="发生 / 恢复" min-width="185"><template #default="{ row }"><div>{{ stamp(row.openedAt) }}</div><div class="subtext">{{ row.resolvedAt ? stamp(row.resolvedAt) : '持续中' }}</div><div class="subtext">{{ reasons[row.resolveReason] }}</div></template></el-table-column><el-table-column label="确认" min-width="150"><template #default="{ row }"><span v-if="row.acknowledgedAt">{{ row.acknowledgedBy }}<div class="subtext">{{ stamp(row.acknowledgedAt) }}</div></span><el-button v-else-if="auth.canOperate && row.status === 'firing'" link type="primary" :disabled="saving" @click="acknowledge(row)">确认告警</el-button><span v-else class="secondary">未确认</span></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load()" /><p class="subtext retention-note">确认表示已知悉,指标恢复后自动结束告警。已恢复事件保留 90 天。</p>
</template>
<template v-else><div class="panel-heading"><p class="subtext">水位按持续时间触发;离线按秒数触发。修改规则后重新计时。</p><el-button v-if="auth.canAdmin" type="primary" @click="edit()">新增规则</el-button></div><el-table :data="rules" empty-text="暂无规则"><el-table-column prop="name" label="规则名称" min-width="190" /><el-table-column label="指标" min-width="190"><template #default="{ row }">{{ metricLabels[row.metric] }}</template></el-table-column><el-table-column label="阈值 / 持续" min-width="155"><template #default="{ row }">{{ row.threshold }}{{ ['node_unavailable', 'terminal_offline'].includes(row.metric) ? ' 秒离线' : `% / ${row.holdSeconds} 秒` }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="90"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑规则</el-button></template></el-table-column></el-table></template>
</section>
<el-dialog v-model="dialog" :title="editId ? '编辑告警规则' : '新增告警规则'" width="540px" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving"><el-form label-position="top"><el-form-item label="规则名称"><el-input v-model="form.name" maxlength="80" aria-label="规则名称" :disabled="saving" /></el-form-item><el-form-item label="监控指标"><el-select v-model="form.metric" aria-label="规则监控指标" :disabled="saving" @change="metricChanged"><el-option v-for="(label, value) in metricLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item :label="offline ? '离线阈值(秒)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : 100" :precision="offline ? 0 : 1" aria-label="告警阈值" :disabled="saving" /></el-form-item><el-form-item v-if="!offline" label="持续时间(秒)"><el-input-number v-model="form.holdSeconds" :min="0" :max="3600" :precision="0" aria-label="告警持续时间" :disabled="saving" /></el-form-item><el-form-item label="启用规则"><el-switch v-model="form.enabled" aria-label="启用告警规则" :disabled="saving" /></el-form-item></el-form><el-alert v-if="editId" title="保存会结束本规则当前告警,并按新规则重新计时。" type="info" :closable="false" /><template #footer><el-button :disabled="saving" @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!form.name.trim()" @click="save">保存规则</el-button></template></el-dialog>
</template>
<style scoped>
.monitor-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.monitor-summary .panel{display:flex;justify-content:space-between;align-items:center;gap:12px}.monitor-summary span{font-size:12px;color:var(--vdi-muted)}.monitor-summary strong{font-size:28px}.trend-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:20px}.chart-filters,.alert-filters{display:flex;gap:12px;flex-wrap:wrap}.chart-filters .el-select{width:160px}.alert-filters{margin:18px 0}.alert-filters .el-input{width:230px}.alert-filters .el-select{width:150px}.el-pagination{margin-top:20px;flex-wrap:wrap}.retention-note{margin-top:15px}.subtext{line-height:1.7}
@media(max-width:1200px){.trend-grid{grid-template-columns:1fr}.panel-heading{flex-wrap:wrap;gap:16px}}
@media(max-width:600px){.monitor-summary{gap:8px}.monitor-summary .panel{padding:14px;flex-direction:column;align-items:flex-start}.monitor-summary strong{font-size:24px}.chart-filters,.alert-filters{width:100%}.chart-filters .el-select,.alert-filters .el-select{flex:1;min-width:120px}.alert-filters .el-input{width:100%}}
</style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { api, allPages, errorMessage, type DesktopPool, type ImageTemplate, type PoolInput } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import CloneDesktopDialog from '../components/CloneDesktopDialog.vue'
const auth = useAuth(), data = usePlatform(), items = ref<DesktopPool[]>([]), templates = ref<ImageTemplate[]>([])
const loading = ref(false), busy = ref(false), error = ref(''), visible = ref(false), editId = ref(''), memberPool = ref<DesktopPool>(), memberIds = ref<string[]>([]), cloneVisible = ref(false), clonePool = ref<DesktopPool>(), keyword = ref('')
const form = reactive<PoolInput>({ name: '', type: 'dedicated', maxCount: 10, templateId: null, enabled: true })
const filtered = computed(() => items.value.filter(p => p.name.toLowerCase().includes(keyword.value.toLowerCase())))
const desktops = computed(() => data.desktops.filter(d => !d.poolId || d.poolId === memberPool.value?.id))
async function load(quiet = false) { if (!quiet) loading.value = true; try { [items.value, templates.value] = await Promise.all([allPages(api.pools), allPages(api.templates)]); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value) void load(true) }, 5000) })
onUnmounted(() => clearInterval(timer))
function edit(pool?: DesktopPool) { editId.value = pool?.id || ''; Object.assign(form, pool ? { name: pool.name, type: pool.type, maxCount: pool.maxCount, templateId: pool.templateId, enabled: pool.enabled } : { name: '', type: 'dedicated', maxCount: 10, templateId: null, enabled: true }); visible.value = true }
async function save() { busy.value = true; try { await api.savePool(editId.value, { ...form }); visible.value = false; ElMessage.success('桌面池已保存'); await Promise.all([load(), data.refresh()]) } catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false } }
function members(pool: DesktopPool) { memberPool.value = pool; memberIds.value = data.desktops.filter(d => d.poolId === pool.id).map(d => d.id) }
async function saveMembers() { if (!memberPool.value) return; busy.value = true; try { await api.poolMembers(memberPool.value.id, memberIds.value); memberPool.value = undefined; ElMessage.success('桌面池成员已更新'); await Promise.all([load(), data.refresh()]) } catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false } }
function clone(pool: DesktopPool) { clonePool.value = pool; cloneVisible.value = true }
</script>
<template>
<div class="page-heading"><div><h1>桌面池</h1><p class="subtext">按用途组织桌面,控制容量、模板与授权范围</p></div><div><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button v-if="auth.canAdmin" :icon="Plus" type="primary" @click="edit()">新建桌面池</el-button></div></div>
<el-alert class="notice" type="info" :closable="false" show-icon title="当前采用管理员分配与回收。公共池的自动领取、退出还原和自动扩容尚未接入。" />
<el-alert v-if="error" class="notice" type="error" :title="error" :closable="false" />
<section class="panel" v-loading="loading">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索桌面池" aria-label="搜索桌面池" clearable /></div>
<el-table :data="filtered" empty-text="尚无桌面池,创建后可加入已有桌面或从模板创建。"><el-table-column prop="name" label="桌面池" min-width="170" /><el-table-column label="类型" width="110"><template #default="{ row }">{{ row.type === 'shared' ? '公共池' : '专属池' }}</template></el-table-column><el-table-column label="已用 / 容量" min-width="170"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round((row.desktopCount + row.reservedCount) / row.maxCount * 100))" :show-text="false" /><div class="capacity">{{ row.desktopCount }} 台桌面 + {{ row.reservedCount }} 台创建预留 / {{ row.maxCount }}</div></template></el-table-column><el-table-column label="已授权 / 活动" min-width="120"><template #default="{ row }">{{ row.assignedCount }} / {{ row.activeCount }}</template></el-table-column><el-table-column label="默认模板" min-width="150"><template #default="{ row }">{{ templates.find(t => t.id === row.templateId)?.name || '不限定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="235"><template #default="{ row }"><el-button link type="primary" :disabled="!row.enabled || row.desktopCount + row.reservedCount >= row.maxCount" @click="clone(row)">创建桌面</el-button><el-button link @click="members(row)">成员</el-button><el-button link @click="edit(row)">编辑</el-button></template></el-table-column></el-table>
<p class="pool-note">创建中和结果待确认的任务均占用容量。加入桌面池不会自动修改用户授权,请在「用户与授权」分配桌面。停用池会阻止新建,不会中断现有桌面。</p>
</section>
<el-dialog v-model="visible" :title="editId ? '编辑桌面池' : '新建桌面池'" width="540px" :close-on-click-modal="false"><el-form label-position="top" :disabled="busy"><el-form-item label="名称"><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="类型"><el-radio-group v-model="form.type"><el-radio value="dedicated">专属池</el-radio><el-radio value="shared">公共池</el-radio></el-radio-group></el-form-item><el-form-item label="容量上限"><el-input-number v-model="form.maxCount" :min="1" :max="10000" :precision="0" /></el-form-item><el-form-item label="创建模板(可选)"><el-select v-model="form.templateId" clearable placeholder="不限定模板"><el-option v-for="t in templates" :key="t.id" :label="`${t.name} · ${t.version}`" :value="t.id" :disabled="!t.enabled" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :disabled="!form.name.trim()" :loading="busy" @click="save">保存桌面池</el-button></template></el-dialog>
<el-dialog :model-value="!!memberPool" :title="`桌面池成员 · ${memberPool?.name || ''}`" width="620px" :close-on-click-modal="false" @close="memberPool = undefined"><p class="subtext">仅列出未加入其他池的桌面;移出池不会撤销已有用户授权。</p><el-select v-model="memberIds" multiple filterable placeholder="选择桌面" :disabled="busy" style="width:100%;margin:20px 0"><el-option v-for="d in desktops" :key="d.id" :label="`${d.name} · ${d.os} / ${d.vmid}`" :value="d.id" /></el-select><p class="subtext">已选 {{ memberIds.length }} 台 + 创建预留 {{ memberPool?.reservedCount || 0 }} 台 / 容量 {{ memberPool?.maxCount }}</p><template #footer><el-button :disabled="busy" @click="memberPool = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="memberIds.length + (memberPool?.reservedCount || 0) > (memberPool?.maxCount || 0)" @click="saveMembers">保存成员</el-button></template></el-dialog>
<CloneDesktopDialog v-model="cloneVisible" :pool-id="clonePool?.id" :template-id="clonePool?.templateId || undefined" @submitted="load" />
</template>
<style scoped>
.capacity{font-size:11px;color:var(--vdi-muted);margin-top:7px}.pool-note{padding:20px;color:var(--vdi-muted);font-size:12px;line-height:1.8}
</style>

View File

@ -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) {
</script>
<template>
<div class="page-heading"><div><h1>远程会话</h1><p class="subtext">查看终端的连接状态与实际上报的会话指标</p></div><el-button @click="data.refresh"><el-icon><Refresh /></el-icon><span style="margin-left:7px">刷新会话</span></el-button></div>
<el-tabs v-model="tab"><el-tab-pane label="当前会话" name="active" /><el-tab-pane label="历史记录" name="history" /></el-tabs>
<SessionHistory v-if="tab === 'history'" />
<template v-else>
<div class="session-summary"><span><i class="health-dot tone-ok" />{{ sessions.filter(t => t.sessionState === 'connected').length }} 个已连接</span><span><i class="health-dot tone-warn" />{{ sessions.filter(t => t.sessionState === 'connecting').length }} 个连接中</span><router-link to="/terminals">管理终端 <el-icon><ArrowRight /></el-icon></router-link></div>
<div v-if="selected" class="session-layout"><section class="panel session-workspace"><div class="panel-heading"><div><h2>{{ desktop?.name || '云桌面' }}</h2><p class="subtext">{{ selected.name }} · {{ user?.displayName || '设备绑定模式' }}</p></div><el-tag :type="selected.sessionState === 'connected' ? 'success' : 'warning'">{{ selected.sessionState === 'connected' ? '已连接' : '连接中' }}</el-tag></div><div class="preview-unavailable"><div class="preview-grid" /><div class="preview-content"><div class="preview-symbol"><el-icon :size="42"><Monitor /></el-icon></div><h3>{{ selected.sessionState === 'connected' ? '桌面正在终端显示' : '终端正在建立连接' }}</h3><p>管理控制台尚未接入远程画面预览</p><span>{{ desktop?.os }} · {{ desktop?.width }} × {{ desktop?.height }}</span></div><div class="preview-caption"><el-icon><VideoCamera /></el-icon>画面预览未接入</div></div><div class="session-device-strip"><span><el-icon><Connection /></el-icon>{{ selected.name }}</span><span class="mono">{{ selected.sn }}</span></div></section><section class="panel session-details"><div class="section-kicker">SESSION DETAILS</div><h2>会话信息</h2><p class="subtext">{{ desktop?.name || '云桌面' }}</p><dl><div><dt>连接状态</dt><dd :class="selected.sessionState === 'connected' ? 'tone-ok' : 'tone-warn'">{{ selected.sessionState === 'connected' ? '已连接' : '连接中' }}</dd></div><div><dt>桌面使用者</dt><dd>{{ user?.displayName || '未关联' }}</dd></div><div><dt>分辨率</dt><dd>{{ desktop ? `${desktop.width} × ${desktop.height}` : '暂无数据' }}</dd></div><div><dt>实际帧率</dt><dd>{{ selected.sessionState === 'connected' && selected.fps !== null ? `${selected.fps} FPS` : '暂无数据' }}</dd></div><div><dt>网络 RTT</dt><dd>{{ selected.sessionState === 'connected' && selected.rtt !== null ? `${selected.rtt} ms` : '暂无数据' }}</dd></div><div><dt>GPU / 显存</dt><dd class="secondary">尚未采集</dd></div><div><dt>最近心跳</dt><dd class="heartbeat-time">{{ selected.lastSeen ? new Date(selected.lastSeen).toLocaleTimeString('zh-CN') : '暂无数据' }}</dd></div></dl><p class="subtext session-note">网络 RTT 为设备采样值,不代表整体输入延迟。</p><router-link to="/terminals" class="details-link"><el-button style="width:100%">查看终端详情</el-button></router-link><el-button v-if="auth.canOperate" type="danger" plain class="disconnect-button" :disabled="busy || pending(selected.id)" @click="disconnect(selected)">{{ pending(selected.id) ? '等待命令回执' : '断开连接' }}</el-button></section></div>
<section v-else class="panel empty-session"><div class="empty-icon"><el-icon :size="30"><Connection /></el-icon></div><h2>当前没有活动会话</h2><p class="subtext">在终端选择已授权的云桌面并连接后,会话会显示在这里。</p><router-link to="/terminals"><el-button type="primary" style="margin-top:24px">查看终端</el-button></router-link></section>
<section v-if="sessions.length > 1" class="panel"><div class="panel-heading"><h2>全部会话</h2><span class="secondary">{{ sessions.length }} 个连接</span></div><div class="session-list"><button v-for="t in sessions" :key="t.id" class="session-option" :class="{ active: selected?.id === t.id }" @click="selectedId = t.id"><el-icon :size="20"><Monitor /></el-icon><div><strong>{{ data.desktops.find(d => d.id === t.sessionDesktopId)?.name || '云桌面' }}</strong><span>{{ t.name }}</span></div><span :class="t.sessionState === 'connected' ? 'tone-ok' : 'tone-warn'">{{ t.sessionState === 'connected' ? '已连接' : '连接中' }}</span></button></div></section>
</template>
</template>
<style scoped>
.session-summary{display:flex;gap:24px;align-items:center;flex-wrap:wrap;font-size:12px;color:var(--vdi-muted);margin-bottom:22px}.session-summary>span{display:flex;align-items:center}.session-summary>a{margin-left:auto;display:flex;gap:5px;align-items:center}.session-layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:20px}.session-workspace{padding:22px 22px 0;overflow:hidden}.preview-unavailable{position:relative;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:14px;min-height:350px;aspect-ratio:16/10;display:grid;place-items:center;overflow:hidden}.preview-grid{position:absolute;inset:0;background-image:linear-gradient(var(--vdi-border) 1px,transparent 1px),linear-gradient(90deg,var(--vdi-border) 1px,transparent 1px);background-size:35px 35px;opacity:.18;mask-image:radial-gradient(ellipse at center,#000,transparent 75%)}.preview-content{text-align:center;position:relative;padding:24px}.preview-symbol{width:96px;height:80px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:20px;display:grid;place-items:center;margin:0 auto 24px;color:var(--vdi-accent)}.preview-content h3{font-size:18px}.preview-content p{color:var(--vdi-muted);font-size:13px;margin-top:12px}.preview-content>span{display:block;font-size:11px;color:var(--vdi-muted);margin-top:17px}.preview-caption{position:absolute;bottom:17px;left:19px;font-size:10px;color:var(--vdi-muted);display:flex;align-items:center;gap:6px}.session-device-strip{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 0;flex-wrap:wrap;color:var(--vdi-muted);font-size:11px}.session-device-strip>span:first-child{display:flex;gap:7px;align-items:center}.session-details dl{margin:22px 0 0}.session-details dl>div{display:flex;justify-content:space-between;align-items:center;gap:14px;font-size:12px;padding:15px 0;border-bottom:1px solid var(--vdi-border)}.session-details dt{color:var(--vdi-muted)}.session-details dd{margin:0;font-weight:500;text-align:right}.session-note{font-size:11px;margin:20px 0}.details-link{display:block;margin-top:24px}.disconnect-button{width:100%;margin-top:10px}.disconnect-button.el-button{margin-left:0}.empty-session{text-align:center;padding:85px 28px;min-height:410px}.empty-session .subtext{max-width:400px;margin:12px auto 0}.session-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.session-option{border:1px solid var(--vdi-border);background:var(--vdi-panel-2);border-radius:12px;padding:17px;display:flex;gap:14px;align-items:center;text-align:left;color:var(--vdi-text);cursor:pointer}.session-option.active{border-color:var(--vdi-brand);background:var(--vdi-accent-soft)}.session-option>div{flex:1}.session-option strong{font-size:13px}.session-option>div>span{display:block;font-size:11px;color:var(--vdi-muted);margin-top:5px}.session-option>span{font-size:11px}

View File

@ -3,16 +3,19 @@ import { ref } from 'vue'
import { Connection, Lock, Setting, CircleCheck, Clock } from '@element-plus/icons-vue'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
const data = usePlatform(), auth = useAuth(), section = ref('platform')
const sections = [{ id: 'platform', title: '平台接入', icon: Connection }, { id: 'permissions', title: '管理员权限', icon: Lock }, { id: 'capabilities', title: '功能范围', icon: Setting }]
const planned = ['镜像模板与克隆发放', '专属 / 公共桌面池', 'GPU 资源绑定', '历史指标与告警', '终端策略下发', 'OTA 灰度与回滚']
import PveConnectionDialog from '../components/PveConnectionDialog.vue'
import AdministratorAccounts from '../components/AdministratorAccounts.vue'
const data = usePlatform(), auth = useAuth(), section = ref('platform'), pveVisible = ref(false)
const sections = [{ id: 'platform', title: '平台接入', icon: Connection }, { id: 'accounts', title: '管理员账号', icon: Lock }, { id: 'permissions', title: '角色权限', icon: Lock }, { id: 'capabilities', title: '功能范围', icon: Setting }]
const planned = ['公共池自动领取与退出还原', '新桌面自动初始化', 'GPU 资源绑定', '外部告警通知与串流 FPS/RTT 采集', 'OTA 灰度与回滚']
</script>
<template>
<div class="page-heading"><div><h1>系统设置</h1><p class="subtext">平台接入状态、访问权限与当前功能范围</p></div></div>
<div class="settings-layout"><nav class="panel settings-nav" aria-label="设置分类"><button v-for="item in sections" :key="item.id" :class="{ active: section === item.id }" :aria-pressed="section === item.id" @click="section = item.id"><el-icon :size="17"><component :is="item.icon" /></el-icon>{{ item.title }}</button></nav><div class="settings-content"><section v-if="section === 'platform'" class="panel settings-panel"><div class="section-kicker">PLATFORM CONNECTION</div><h2>平台接入</h2><p class="subtext">统一查看管理服务、计算资源与终端通道</p><div class="setting-row"><div><strong>运行环境</strong><p>使用独立数据库保存平台业务数据</p></div><el-tag :type="data.settings?.mode === 'demo' ? 'warning' : 'success'">{{ data.settings?.mode === 'demo' ? '演示环境' : '现场联调环境' }}</el-tag></div><div class="setting-row"><div><strong>Proxmox 计算平台</strong><p>连接状态以最近同步任务结果为准</p></div><span :class="data.settings?.pveConfigured ? 'tone-ok' : 'secondary'"><i class="health-dot" />{{ data.settings?.pveConfigured ? '已配置' : '尚未配置' }}</span></div><div class="setting-row"><div><strong>终端管理通道</strong><p>设备注册、心跳、命令领取与执行回执</p></div><span class="setting-value">HTTPS</span></div><div class="setting-row"><div><strong>业务数据存储</strong><p>账号、设备、授权关系、任务与审计</p></div><span class="setting-value">PostgreSQL</span></div><div class="setting-row"><div><strong>当前管理员</strong><p>{{ auth.user?.displayName }}</p></div><span class="setting-value">{{ auth.user?.username }}</span></div><p class="settings-footnote">接入地址与访问凭据由服务端配置管理。</p></section><section v-else-if="section === 'permissions'" class="panel settings-panel"><div class="section-kicker">ACCESS CONTROL</div><h2>管理员权限</h2><p class="subtext">三种管理角色,按职责控制访问与操作</p><el-table :data="[{ name: '超级管理员', query: '允许', operate: '允许', assign: '允许' }, { name: '运维人员', query: '允许', operate: '允许', assign: '不允许' }, { name: '只读人员', query: '允许', operate: '不允许', assign: '不允许' }]" style="margin-top:26px"><el-table-column prop="name" label="角色" min-width="120" /><el-table-column prop="query" label="查看资源" min-width="110" /><el-table-column prop="operate" label="设备 / 桌面操作" min-width="145" /><el-table-column prop="assign" label="授权 / 接入" min-width="120" /></el-table><p class="settings-footnote">管理员账号与桌面使用者分开管理。管理员账号维护页面尚未开放。</p></section><section v-else class="panel settings-panel"><div class="section-kicker">PLATFORM CAPABILITIES</div><h2>功能范围</h2><p class="subtext">当前已接通的能力与后续开发范围</p><div class="capability-group"><h3>当前可用</h3><div class="capability-grid"><span v-for="name in ['登录与角色权限', '计算资源同步', '已有桌面纳管与电源管理', '终端注册与心跳', '用户桌面授权', '远程会话状态与重置', '诊断日志采集', '任务跟踪与操作审计']" :key="name"><el-icon class="tone-ok"><CircleCheck /></el-icon>{{ name }}</span></div></div><div class="capability-group"><h3>待接入</h3><div class="capability-grid"><span v-for="name in planned" :key="name"><el-icon class="secondary"><Clock /></el-icon>{{ name }}</span></div></div></section></div></div>
<div class="settings-layout"><nav class="panel settings-nav" aria-label="设置分类"><button v-for="item in sections" :key="item.id" :class="{ active: section === item.id }" :aria-pressed="section === item.id" @click="section = item.id"><el-icon :size="17"><component :is="item.icon" /></el-icon>{{ item.title }}</button></nav><div class="settings-content"><section v-if="section === 'platform'" class="panel settings-panel"><div class="section-kicker">PLATFORM CONNECTION</div><h2>平台接入</h2><p class="subtext">统一查看管理服务、计算资源与终端通道</p><div class="setting-row"><div><strong>运行环境</strong><p>使用独立数据库保存平台业务数据</p></div><el-tag :type="data.settings?.mode === 'demo' ? 'warning' : 'success'">{{ data.settings?.mode === 'demo' ? '演示环境' : '现场联调环境' }}</el-tag></div><div class="setting-row"><div><strong>Proxmox 计算平台</strong><p>连接状态以最近同步任务结果为准</p></div><span :class="data.settings?.pveConfigured ? 'tone-ok' : 'secondary'"><i class="health-dot" />{{ data.settings?.pveConfigured ? '已配置' : '尚未配置' }}</span></div><div class="setting-row"><div><strong>终端管理通道</strong><p>设备注册、心跳、命令领取与执行回执</p></div><span class="setting-value">HTTPS</span></div><div class="setting-row"><div><strong>业务数据存储</strong><p>账号、设备、授权关系、任务与审计</p></div><span class="setting-value">PostgreSQL</span></div><div class="setting-row"><div><strong>当前管理员</strong><p>{{ auth.user?.displayName }}</p></div><span class="setting-value">{{ auth.user?.username }}</span></div><el-button v-if="auth.canAdmin" type="primary" style="margin-top:22px" @click="pveVisible = true">配置 Proxmox 接入</el-button><p class="settings-footnote">在控制台检查连接并保存;原密钥不回显,留空可保留。</p></section><section v-else-if="section === 'accounts'" class="panel settings-panel"><AdministratorAccounts /></section><section v-else-if="section === 'permissions'" class="panel settings-panel"><div class="section-kicker">ACCESS CONTROL</div><h2>管理员权限</h2><p class="subtext">三种管理角色,按职责控制访问与操作</p><el-table :data="[{ name: '超级管理员', query: '允许', operate: '允许', assign: '允许' }, { name: '运维人员', query: '允许', operate: '允许', assign: '不允许' }, { name: '只读人员', query: '允许', operate: '不允许', assign: '不允许' }]" style="margin-top:26px"><el-table-column prop="name" label="角色" min-width="120" /><el-table-column prop="query" label="查看资源" min-width="110" /><el-table-column prop="operate" label="设备 / 桌面操作" min-width="145" /><el-table-column prop="assign" label="授权 / 接入" min-width="120" /></el-table><p class="settings-footnote">管理员账号与桌面使用者分开管理。超级管理员可在「管理员账号」维护账号、角色、启停和密码。</p></section><section v-else class="panel settings-panel"><div class="section-kicker">PLATFORM CAPABILITIES</div><h2>功能范围</h2><p class="subtext">当前已接通的能力与后续开发范围</p><div class="capability-group"><h3>当前可用</h3><div class="capability-grid"><span v-for="name in ['登录限流与角色权限', '管理员账号维护与密码修改', '计算资源同步', 'GPU 清单与虚拟机配置引用', '已有桌面纳管与电源管理', '桌面批量开关机、重启与逐台结果', '镜像模板登记与 PVE 克隆', '桌面池容量与成员管理', '桌面连接与规格管理', '关机快照创建、删除与手动还原', '终端注册、分组、停用与重新激活', '用户资料、启停与批量桌面授权', '会话状态、历史与重置', 'CSV 批量导入', '节点趋势与告警', '终端 CPU、内存、SoC 温度与历史趋势', '诊断日志采集', '终端策略版本、下发与应用回执', '任务跟踪与操作审计']" :key="name"><el-icon class="tone-ok"><CircleCheck /></el-icon>{{ name }}</span></div></div><div class="capability-group"><h3>待接入</h3><div class="capability-grid"><span v-for="name in planned" :key="name"><el-icon class="secondary"><Clock /></el-icon>{{ name }}</span></div></div></section></div></div>
<PveConnectionDialog v-model="pveVisible" />
</template>
<style scoped>
.settings-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:20px;align-items:start}.settings-nav{padding:12px;display:grid;gap:5px}.settings-nav button{display:flex;gap:11px;align-items:center;border:0;padding:14px 15px;border-radius:11px;background:transparent;color:var(--vdi-muted);font-size:13px;text-align:left;cursor:pointer}.settings-nav button.active{background:var(--vdi-accent-soft);color:var(--vdi-accent);font-weight:600}.settings-nav button:hover{color:var(--vdi-accent)}.settings-panel{padding:28px}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--vdi-border);font-size:12px}.setting-row strong{font-size:13px;font-weight:550}.setting-row p{font-size:11px;color:var(--vdi-muted);margin-top:7px;line-height:1.7}.setting-row>span{flex-shrink:0}.setting-row .health-dot{width:6px;height:6px;margin-right:6px}.setting-value{color:var(--vdi-muted);font-size:12px}.settings-footnote{color:var(--vdi-muted);font-size:11px;line-height:1.8;margin-top:22px}.capability-group{margin-top:28px;padding-top:24px;border-top:1px solid var(--vdi-border)}.capability-group h3{font-size:13px;margin-bottom:20px}.capability-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;font-size:12px}.capability-grid>span{display:flex;align-items:center;gap:9px}
.settings-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:20px;align-items:start}.settings-nav{padding:12px;display:grid;gap:5px}.settings-nav button{display:flex;gap:11px;align-items:center;border:0;padding:14px 15px;border-radius:11px;background:transparent;color:var(--vdi-muted);font-size:13px;text-align:left;cursor:pointer}.settings-nav button.active{background:var(--vdi-accent-soft);color:var(--vdi-accent);font-weight:600}.settings-nav button:hover{color:var(--vdi-accent)}.settings-content{min-width:0}.settings-panel{padding:28px;min-width:0}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--vdi-border);font-size:12px}.setting-row strong{font-size:13px;font-weight:550}.setting-row p{font-size:11px;color:var(--vdi-muted);margin-top:7px;line-height:1.7}.setting-row>span{flex-shrink:0}.setting-row .health-dot{width:6px;height:6px;margin-right:6px}.setting-value{color:var(--vdi-muted);font-size:12px}.settings-footnote{color:var(--vdi-muted);font-size:11px;line-height:1.8;margin-top:22px}.capability-group{margin-top:28px;padding-top:24px;border-top:1px solid var(--vdi-border)}.capability-group h3{font-size:13px;margin-bottom:20px}.capability-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;font-size:12px}.capability-grid>span{display:flex;align-items:center;gap:9px}
@media(max-width:1100px){.settings-layout{grid-template-columns:1fr}.settings-nav{display:flex;flex-wrap:wrap;margin-bottom:0}.settings-nav button{flex:1;white-space:nowrap;padding:12px;justify-content:center}.settings-panel{padding:22px}}
@media(max-width:600px){.settings-nav{gap:3px;padding:8px}.settings-nav button{gap:6px;padding:10px 8px;font-size:11px}.settings-panel{padding:19px}.setting-row{gap:15px}.setting-row p{max-width:180px}.setting-row>span{font-size:10px}.capability-grid{grid-template-columns:1fr}}
</style>

View File

@ -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<string, string> = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面' }
const labels: Record<string, string> = { '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
<el-table v-else :data="auditRows" empty-text="尚无审计记录"><el-table-column prop="actor" label="操作者" width="100" /><el-table-column label="操作" min-width="130"><template #default="{ row }">{{ labels[row.action] || row.action }}</template></el-table-column><el-table-column prop="target" label="对象" min-width="180" /><el-table-column label="结果" width="125"><template #default="{ row }"><StatusTag :value="row.result" /></template></el-table-column><el-table-column label="时间" min-width="175"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column><el-table-column prop="requestId" label="请求标识" min-width="180" show-overflow-tooltip /></el-table>
<el-pagination v-model:current-page="page" :total="tab === 'tasks' ? filtered.length : auditFiltered.length" :page-size="10" layout="total, prev, pager, next" />
</section>
<el-drawer :model-value="!!selected" title="任务详情" size="580px" @close="selectedId = ''"><el-descriptions v-if="selected" :column="1" border><el-descriptions-item label="任务 ID"><span class="mono">{{ selected.id }}</span></el-descriptions-item><el-descriptions-item label="目标">{{ selected.targetName }}</el-descriptions-item><el-descriptions-item label="操作">{{ labels[selected.action] || selected.action }}</el-descriptions-item><el-descriptions-item label="状态"><StatusTag :value="selected.status === 'running' ? '执行中' : selected.status" /></el-descriptions-item><el-descriptions-item label="结果">{{ selected.message }}</el-descriptions-item><el-descriptions-item label="操作者">{{ selected.actor }}</el-descriptions-item><el-descriptions-item label="PVE 任务标识"><span class="mono">{{ selected.providerRef || '无' }}</span></el-descriptions-item><el-descriptions-item label="更新时间">{{ new Date(selected.updatedAt).toLocaleString('zh-CN') }}</el-descriptions-item></el-descriptions><el-button v-if="selected && auth.canOperate && selected.action === 'collect-logs' && selected.status === 'success'" style="margin-top:20px" @click="downloadDiagnostics(selected.id)">下载诊断日志</el-button><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
<el-drawer :model-value="!!selected" title="任务详情" size="580px" @close="selectedId = ''"><el-descriptions v-if="selected" :column="1" border><el-descriptions-item label="任务 ID"><span class="mono">{{ selected.id }}</span></el-descriptions-item><el-descriptions-item label="目标">{{ selected.targetName }}</el-descriptions-item><el-descriptions-item label="操作">{{ labels[selected.action] || selected.action }}</el-descriptions-item><el-descriptions-item label="状态"><StatusTag :value="selected.status === 'running' ? '执行中' : selected.status" /></el-descriptions-item><el-descriptions-item label="结果">{{ selected.message }}</el-descriptions-item><el-descriptions-item label="操作者">{{ selected.actor }}</el-descriptions-item><el-descriptions-item label="PVE 任务标识"><span class="mono">{{ selected.providerRef || '无' }}</span></el-descriptions-item><el-descriptions-item label="更新时间">{{ new Date(selected.updatedAt).toLocaleString('zh-CN') }}</el-descriptions-item></el-descriptions><el-button v-if="selected && auth.canOperate && selected.action === 'collect-logs' && selected.status === 'success'" style="margin-top:20px" @click="downloadDiagnostics(selected.id)">下载诊断日志</el-button><el-button v-if="selected?.kind === 'provision' && selected.status === 'timeout' && selected.providerRef && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileProvision(selected!.id), '已提交核对,只查询原 PVE 任务')">核对克隆结果</el-button><el-button v-if="selected?.kind === 'hardware' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileHardware(selected!.id), '已核对实际规格,未重发修改')">核对规格结果</el-button><el-button v-if="selected?.kind === 'snapshot' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileSnapshot(selected!.id), '已核对原快照任务,未重发修改')">核对快照结果</el-button><el-button v-if="selected?.kind === 'batch-desktop' && selected.status === 'timeout' && auth.canOperate" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileBatchPower(selected!.id), '已核对原电源任务,未重发操作')">核对电源结果</el-button><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { api, allPages, errorMessage, type ImageTemplate, type DiscoveredVM, type TemplateInput } from '../api'
import { useAuth } from '../stores/auth'
import SystemIcon from '../components/SystemIcon.vue'
import CloneDesktopDialog from '../components/CloneDesktopDialog.vue'
const auth = useAuth(), items = ref<ImageTemplate[]>([]), discovered = ref<DiscoveredVM[]>([])
const loading = ref(false), busy = ref(false), error = ref(''), discoverError = ref(''), visible = ref(false), cloneVisible = ref(false), templateId = ref(''), keyword = ref(''), vmKey = ref('')
const form = reactive<TemplateInput>({ name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000 })
const filtered = computed(() => items.value.filter(t => `${t.name} ${t.version} ${t.os} ${t.vmid}`.toLowerCase().includes(keyword.value.toLowerCase())))
const candidates = computed(() => discovered.value.filter(v => !items.value.some(t => t.node === v.node && t.vmid === v.vmid)))
async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.templates); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value) void load(true) }, 5000) })
onUnmounted(() => clearInterval(timer))
async function openRegister() {
visible.value = true; discoverError.value = ''; discovered.value = []; vmKey.value = ''; busy.value = true
Object.assign(form, { name: '', version: '1.0', os: 'Windows', node: '', vmid: 9000 })
try { discovered.value = await allPages(api.discoverTemplates) } catch (e) { discoverError.value = errorMessage(e) } finally { busy.value = false }
}
function choose() { const vm = candidates.value.find(v => `${v.node}/${v.vmid}` === vmKey.value); if (vm) Object.assign(form, { node: vm.node, vmid: vm.vmid, name: vm.name }) }
async function register() {
busy.value = true
try { await api.registerTemplate({ ...form }); visible.value = false; ElMessage.success('模板已登记并校验'); await load() }
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
}
async function update(item: ImageTemplate, validate = false) {
busy.value = true
try { if (validate) await api.validateTemplate(item.id); else await api.updateTemplate(item.id, !item.enabled); ElMessage.success(validate ? '模板配置已重新验证' : '模板状态已更新'); await load() }
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
}
function clone(item: ImageTemplate) { templateId.value = item.id; cloneVisible.value = true }
</script>
<template>
<div class="page-heading"><div><h1>镜像模板</h1><p class="subtext">登记 PVE 专用模板,按版本创建新的云桌面</p></div><div><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button v-if="auth.canAdmin" type="primary" :icon="Plus" @click="openRegister">登记模板</el-button></div></div>
<el-alert v-if="error" class="notice" type="error" :title="error" :closable="false" show-icon />
<section class="panel" v-loading="loading">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索模板名称 / 版本 / 系统 / VMID" aria-label="搜索模板" clearable /></div>
<el-table :data="filtered" empty-text="尚未登记模板。请先在 PVE 制作专用模板,再点击登记模板。">
<el-table-column label="模板 / 版本" min-width="200"><template #default="{ row }"><div class="template-title"><SystemIcon :os="row.os" :size="30" /><div><strong>{{ row.name }}</strong><div class="secondary">{{ row.version }} · {{ row.os }}</div></div></div></template></el-table-column>
<el-table-column prop="node" label="源节点" min-width="120" /><el-table-column prop="vmid" label="模板 VMID" width="120" />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="最近校验" min-width="180"><template #default="{ row }">{{ new Date(row.validatedAt).toLocaleString('zh-CN') }}</template></el-table-column>
<el-table-column v-if="auth.canAdmin" label="操作" min-width="255"><template #default="{ row }"><el-button link type="primary" :disabled="busy || !row.enabled" @click="clone(row)">创建桌面</el-button><el-button link :disabled="busy" @click="update(row, true)">重新验证</el-button><el-button link :disabled="busy" @click="update(row)">{{ row.enabled ? '停用' : '启用' }}</el-button></template></el-table-column>
</el-table>
<p class="catalog-note">模板登记会校验 PVE 模板标记、磁盘与直通配置。模板修改后需重新验证;停用会阻止新的创建请求。现有业务虚拟机不会自动转换为模板。</p>
</section>
<el-dialog v-model="visible" title="登记镜像模板" width="560px" :close-on-click-modal="false">
<el-alert v-if="discoverError" type="error" :title="discoverError" :closable="false" /><el-alert v-else-if="!busy && !candidates.length" type="info" title="未发现可登记的模板,请先在 PVE 准备专用模板;现有运行桌面不能直接作为模板登记。" :closable="false" />
<el-form label-position="top" :disabled="busy"><el-form-item label="PVE 模板"><el-select v-model="vmKey" placeholder="选择发现的模板" filterable @change="choose"><el-option v-for="v in candidates" :key="`${v.node}/${v.vmid}`" :label="`${v.name} · ${v.node} / ${v.vmid}`" :value="`${v.node}/${v.vmid}`" /></el-select></el-form-item><el-form-item label="模板名称"><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="版本"><el-input v-model="form.version" maxlength="40" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in ['Windows', 'Ubuntu', '麒麟']" :key="os" :value="os" /></el-select></el-form-item></el-form>
<template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!vmKey || !form.name.trim() || !form.version.trim()" @click="register">校验并登记</el-button></template>
</el-dialog>
<CloneDesktopDialog v-model="cloneVisible" :template-id="templateId" />
</template>
<style scoped>
.template-title{display:flex;align-items:center;gap:12px}.template-title strong{font-size:13px}.template-title .secondary{font-size:11px;margin-top:5px}.catalog-note{color:var(--vdi-muted);font-size:12px;line-height:1.8;padding:20px}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { api, allPages, errorMessage, type TerminalPolicy, type TerminalPolicyInput, type PolicyDeployment, type PolicyDeploymentInput, type PolicyResult } from '../api'
import { useAuth } from '../stores/auth'
const auth = useAuth()
const templates = ref<TerminalPolicy[]>([]), deployments = ref<PolicyDeployment[]>([]), states = ref<PolicyResult[]>([]), results = ref<PolicyResult[]>([])
const error = ref(''), loading = ref(false), busy = ref(false), tab = ref('templates'), keyword = ref(''), page = ref(1)
const createVisible = ref(false), deployVisible = ref(false), selected = ref<PolicyDeployment>(), detailError = ref('')
const form = reactive<TerminalPolicyInput>({ name: '', version: '1.0', heartbeatInterval: 30, commandPollInterval: 3, desktopRefreshInterval: 15 })
const target = reactive<PolicyDeploymentInput>({ templateId: '', targetMode: 'devices', terminalIds: [], group: '' })
const groups = computed(() => [...new Set(states.value.map(s => s.group).filter(Boolean))].sort())
const eligible = (s: PolicyResult) => s.enabled && s.registered && s.protocol === 1
const targets = computed(() => states.value.filter(s => target.targetMode === 'group' ? s.group === target.group : target.terminalIds?.includes(s.terminalId)))
const invalidTargets = computed(() => targets.value.some(s => !eligible(s)))
const template = computed(() => templates.value.find(t => t.id === target.templateId))
const filtered = computed(() => states.value.filter(s => `${s.terminalName} ${s.group} ${s.templateName}`.toLowerCase().includes(keyword.value.toLowerCase())))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const deploymentRows = computed(() => deployments.value.slice((page.value - 1) * 10, page.value * 10))
const labels: Record<string, string> = { unconfigured: '未配置', pending: '等待设备应用', applied: '已应用', failed: '应用失败', superseded: '已被新策略替代' }
const errors: Record<string, string> = { invalid_policy: '客户端不接受该配置', persist_failed: '设备无法安全保存配置,请检查磁盘和权限', runtime_unavailable: '客户端运行组件未就绪' }
const time = (s: string | null) => s ? new Date(s).toLocaleString('zh-CN') : '—'
function stateLabel(s: PolicyResult) { return labels[s.status] || s.status }
let running = false, detailGeneration = 0, timer: ReturnType<typeof setInterval> | undefined
async function load(silent = false) {
if (running) return
running = true; if (!silent) loading.value = true
try {
const [t, d, s] = await Promise.all([allPages(api.terminalPolicies), allPages(api.policyDeployments), allPages(api.policyStates)])
templates.value = t; deployments.value = d; states.value = s; error.value = ''
if (selected.value) await loadResults(selected.value.id)
} catch (e) { error.value = errorMessage(e) } finally { running = false; loading.value = false }
}
async function loadResults(id: string) {
const generation = ++detailGeneration
try { const data = await allPages(page => api.policyResults(id, page)); if (generation === detailGeneration && selected.value?.id === id) { results.value = data; detailError.value = '' } }
catch (e) { if (generation === detailGeneration) detailError.value = errorMessage(e) }
}
function detail(d: PolicyDeployment) { selected.value = d; results.value = []; detailError.value = ''; void loadResults(d.id) }
function create(t?: TerminalPolicy) {
Object.assign(form, t ? { name: t.name, version: '', heartbeatInterval: t.heartbeatInterval, commandPollInterval: t.commandPollInterval, desktopRefreshInterval: t.desktopRefreshInterval } : { name: '', version: '1.0', heartbeatInterval: 30, commandPollInterval: 3, desktopRefreshInterval: 15 })
createVisible.value = true
}
async function save() {
busy.value = true
try { await api.createTerminalPolicy({ ...form }); createVisible.value = false; ElMessage.success('策略版本已保存'); await load() } catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
}
function closeDeployment(done: () => void) { if (!busy.value) done() }
let requestKey = '', submitted = ''
function deploy(t: TerminalPolicy, ids: string[] = []) { Object.assign(target, { templateId: t.id, targetMode: 'devices', terminalIds: ids, group: '' }); requestKey = crypto.randomUUID(); submitted = ''; deployVisible.value = true }
async function dispatch() {
if (!template.value || !targets.value.length || invalidTargets.value || busy.value) return
const body: PolicyDeploymentInput = target.targetMode === 'group' ? { templateId: target.templateId, targetMode: 'group', group: target.group } : { templateId: target.templateId, targetMode: 'devices', terminalIds: [...(target.terminalIds || [])].sort() }
const fingerprint = JSON.stringify(body)
busy.value = true
try {
await ElMessageBox.confirm(`将「${template.value.name} · ${template.value.version}」下发到${target.targetMode === 'group' ? `「${target.group}」分组当前的` : ''} ${targets.value.length} 台终端。离线设备将在恢复联网后应用,最终以设备回执为准。`, '确认下发策略', { type: 'warning', confirmButtonText: '确认下发', cancelButtonText: '取消' })
if (submitted && submitted !== fingerprint) requestKey = crypto.randomUUID()
submitted = fingerprint
const d = await api.deployPolicy(body, requestKey)
deployVisible.value = false; tab.value = 'deployments'; page.value = 1; ElMessage.success('下发已登记,等待设备应用'); await load(); detail(d)
} catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(errorMessage(e)) } finally { busy.value = false }
}
function retry(s: PolicyResult) { const t = templates.value.find(t => t.name === s.templateName && t.version === s.templateVersion); if (t) deploy(t, [s.terminalId]) }
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden) void load(true) }, 5000) })
onUnmounted(() => { if (timer) clearInterval(timer); ++detailGeneration })
</script>
<template>
<div class="page-heading"><div><h1>终端策略</h1><p class="subtext">配置有版本,下发有记录,生效以设备回执为准</p></div><div><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button v-if="auth.canAdmin" type="primary" :icon="Plus" @click="create()">新建策略</el-button></div></div>
<el-alert v-if="error" class="notice" type="error" :title="error" :closable="false" />
<div class="policy-summary"><section class="panel"><span>策略版本</span><strong>{{ templates.length }}</strong></section><section class="panel"><span>当前已应用</span><strong>{{ states.filter(s => s.status === 'applied').length }}</strong></section><section class="panel"><span>等待设备</span><strong>{{ states.filter(s => s.status === 'pending').length }}</strong></section><section class="panel"><span>应用失败</span><strong>{{ states.filter(s => s.status === 'failed').length }}</strong></section></div>
<section class="panel" v-loading="loading">
<el-tabs v-model="tab" @tab-change="page = 1"><el-tab-pane label="配置模板" name="templates" /><el-tab-pane label="下发记录" name="deployments" /><el-tab-pane label="设备结果" name="states" /></el-tabs>
<template v-if="tab === 'templates'">
<p class="policy-note">每个版本保存后固定。调整配置可复制为新版本;恢复旧配置时,重新下发对应版本。</p>
<el-table :data="templates" empty-text="尚无策略,创建模板后可下发到设备或分组"><el-table-column label="策略 / 版本" min-width="180"><template #default="{ row }"><strong>{{ row.name }}</strong><div class="secondary">{{ row.version }}</div></template></el-table-column><el-table-column label="心跳间隔" min-width="115"><template #default="{ row }">{{ row.heartbeatInterval }} 秒</template></el-table-column><el-table-column label="命令检查" min-width="115"><template #default="{ row }">{{ row.commandPollInterval }} 秒</template></el-table-column><el-table-column label="桌面列表刷新" min-width="140"><template #default="{ row }">{{ row.desktopRefreshInterval }} 秒</template></el-table-column><el-table-column label="创建时间" min-width="175"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="180"><template #default="{ row }"><el-button link type="primary" @click="deploy(row)">下发</el-button><el-button link @click="create(row)">复制新版本</el-button></template></el-table-column></el-table>
</template>
<template v-else-if="tab === 'deployments'"><el-table :data="deploymentRows" empty-text="尚无下发记录"><el-table-column label="策略版本" min-width="190"><template #default="{ row }">{{ row.templateName }} · {{ row.templateVersion }}</template></el-table-column><el-table-column label="设备数" prop="targetCount" width="100" /><el-table-column label="操作者" prop="actor" min-width="110" /><el-table-column label="提交时间" min-width="180"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column><el-table-column label="操作" width="130"><template #default="{ row }"><el-button link type="primary" @click="detail(row)">查看结果</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="deployments.length" :page-size="10" layout="total, prev, pager, next" /></template>
<template v-else><div class="toolbar"><el-input v-model="keyword" aria-label="搜索策略设备" placeholder="搜索设备 / 分组 / 策略" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="没有符合条件的终端"><el-table-column label="终端 / 分组" min-width="160"><template #default="{ row }"><strong>{{ row.terminalName }}</strong><div class="secondary">{{ row.group || '未分组' }} · {{ row.online && row.enabled ? '在线' : '离线' }}</div></template></el-table-column><el-table-column label="策略能力" min-width="130"><template #default="{ row }">{{ !row.enabled ? '设备已停用' : !row.registered ? '等待激活' : row.protocol === 1 ? '支持策略' : '需更新客户端' }}</template></el-table-column><el-table-column label="期望策略" min-width="170"><template #default="{ row }">{{ row.templateName ? `${row.templateName} · ${row.templateVersion}` : '默认配置' }}<div class="secondary">配置版本 {{ row.configVersion }}</div></template></el-table-column><el-table-column label="应用结果" min-width="155"><template #default="{ row }"><el-tag :type="row.status === 'applied' ? 'success' : row.status === 'failed' ? 'danger' : 'info'">{{ stateLabel(row) }}</el-tag><div v-if="row.status === 'pending' && !row.online" class="secondary">等待设备恢复联网</div></template></el-table-column><el-table-column label="最近回执 / 原因" min-width="215"><template #default="{ row }">{{ time(row.acknowledgedAt) }}<div v-if="row.errorCode" class="secondary">{{ errors[row.errorCode] || row.errorCode }}</div></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="120"><template #default="{ row }"><el-button v-if="row.deploymentId" link type="primary" :disabled="!eligible(row)" @click="retry(row)">重新下发</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></template>
</section>
<el-dialog v-model="createVisible" title="新建终端策略版本" width="550px" :close-on-click-modal="false"><el-form label-position="top" :disabled="busy"><el-form-item label="策略名称"><el-input v-model="form.name" aria-label="策略名称" maxlength="80" /></el-form-item><el-form-item label="版本标记"><el-input v-model="form.version" aria-label="策略版本" maxlength="40" placeholder="例如 1.0" /></el-form-item><el-form-item label="心跳间隔(15~60 秒)"><el-input-number v-model="form.heartbeatInterval" aria-label="心跳间隔" :min="15" :max="60" :precision="0" /></el-form-item><el-form-item label="远程命令检查间隔(3~30 秒)"><el-input-number v-model="form.commandPollInterval" aria-label="命令检查间隔" :min="3" :max="30" :precision="0" /></el-form-item><el-form-item label="桌面列表刷新间隔(5~120 秒)"><el-input-number v-model="form.desktopRefreshInterval" aria-label="桌面列表刷新间隔" :min="5" :max="120" :precision="0" /></el-form-item></el-form><p class="subtext">间隔越短,状态更新和命令响应越快。桌面列表仅在选择页刷新。</p><template #footer><el-button :disabled="busy" @click="createVisible = false">取消</el-button><el-button type="primary" :disabled="!form.name.trim() || !form.version.trim()" :loading="busy" @click="save">保存策略版本</el-button></template></el-dialog>
<el-dialog v-model="deployVisible" title="下发终端策略" width="600px" :close-on-click-modal="false" :before-close="closeDeployment"><el-form label-position="top" :disabled="busy"><el-form-item label="策略版本"><el-select v-model="target.templateId" aria-label="下发策略版本"><el-option v-for="t in templates" :key="t.id" :value="t.id" :label="`${t.name} · ${t.version}`" /></el-select></el-form-item><div v-if="template" class="policy-preview">心跳 {{ template.heartbeatInterval }} 秒 · 命令检查 {{ template.commandPollInterval }} 秒 · 列表刷新 {{ template.desktopRefreshInterval }} 秒</div><el-form-item label="下发范围"><el-radio-group v-model="target.targetMode"><el-radio value="devices">指定设备</el-radio><el-radio value="group">指定分组</el-radio></el-radio-group></el-form-item><el-form-item v-if="target.targetMode === 'devices'" label="选择终端"><el-select v-model="target.terminalIds" multiple filterable aria-label="选择策略终端"><el-option v-for="s in states" :key="s.terminalId" :value="s.terminalId" :disabled="!eligible(s)" :label="`${s.terminalName} · ${s.group || '未分组'}${!eligible(s) ? '(暂不支持)' : !s.online ? '(离线)' : ''}`" /></el-select></el-form-item><el-form-item v-else label="选择分组"><el-select v-model="target.group" aria-label="选择策略分组"><el-option v-for="g in groups" :key="g" :value="g" :label="g" /></el-select></el-form-item></el-form><el-alert v-if="invalidTargets" type="warning" :closable="false" title="所选范围包含停用、未激活或旧客户端设备,请先处理或改为指定设备。" /><p class="subtext">本次 {{ targets.length }} 台。按提交时的分组成员创建下发记录,新加入分组的设备需另行下发。设备每 15 秒检查策略,离线时等待恢复联网。</p><template #footer><el-button :disabled="busy" @click="deployVisible = false">取消</el-button><el-button type="primary" :disabled="!template || !targets.length || targets.length > 200 || invalidTargets" :loading="busy" @click="dispatch">提交下发</el-button></template></el-dialog>
<el-drawer :model-value="!!selected" title="策略下发结果" size="650px" @close="selected = undefined; ++detailGeneration"><template v-if="selected"><h2>{{ selected.templateName }} · {{ selected.templateVersion }}</h2><p class="subtext">{{ selected.actor }} · {{ time(selected.createdAt) }} · {{ selected.targetCount }} 台</p><p class="mono secondary">{{ selected.id }}</p><el-alert v-if="detailError" type="error" :closable="false" :title="detailError" /><el-table :data="results" empty-text="暂无结果"><el-table-column label="终端" prop="terminalName" min-width="130" /><el-table-column label="配置版本" prop="configVersion" width="95" /><el-table-column label="应用状态" min-width="160"><template #default="{ row }"><el-tag :type="row.status === 'applied' ? 'success' : row.status === 'failed' ? 'danger' : 'info'">{{ stateLabel(row) }}</el-tag><div v-if="row.status === 'pending' && !row.online" class="secondary">离线等待</div><div v-if="row.errorCode" class="secondary">{{ errors[row.errorCode] || row.errorCode }}</div></template></el-table-column><el-table-column label="设备回执" min-width="170"><template #default="{ row }">{{ time(row.acknowledgedAt) }}</template></el-table-column></el-table><p class="subtext">本页保留当次下发结果。当前期望版本请到「设备结果」查看。应用失败的设备每次检查策略时会尝试恢复,也可以重新下发。</p></template></el-drawer>
</template>
<style scoped>
.policy-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:20px}.policy-summary section{padding:20px}.policy-summary span{display:block;color:var(--vdi-muted);font-size:12px}.policy-summary strong{display:block;font-size:28px;margin-top:10px}.policy-note{padding:4px 20px 12px;color:var(--vdi-muted);font-size:12px}.secondary{font-size:11px;color:var(--vdi-muted);margin-top:6px;overflow-wrap:anywhere}.policy-preview{padding:14px;border-radius:10px;background:var(--vdi-panel-2);font-size:12px;color:var(--vdi-muted);margin-bottom:20px}.el-dialog .subtext,.el-drawer .subtext{margin:18px 0;line-height:1.8}h2{font-size:19px}.el-select{width:100%}@media(max-width:600px){.policy-summary{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.policy-summary section{padding:15px}.policy-summary strong{font-size:24px}}
</style>

View File

@ -1,16 +1,23 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { api, type Terminal, type TerminalAction } 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'
import TerminalMetrics from '../components/TerminalMetrics.vue'
const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction()
const csvVisible = ref(false), metricsVisible = ref(false)
const keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref(''), createVisible = ref(false), secret = ref(''), activationVisible = ref(false)
const editVisible = ref(false), editingId = ref(''), activationSN = ref('')
const editForm = reactive({ name: '', group: '' })
const form = reactive({ sn: '', name: '', group: '' })
const filtered = computed(() => data.terminals.filter(t => `${t.name} ${t.sn}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === t.status)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const selected = computed(() => data.terminals.find(t => t.id === selectedId.value))
watch(selectedId, () => { metricsVisible.value = false })
const disabled = (t: Terminal) => t.enabled === false || t.status === 'disabled'
const labels = { reboot: '重启盒子', 'reset-session': '重置会话', 'collect-logs': '收集日志' }
const desktopName = (id: string | null) => data.desktops.find(d => d.id === id)?.name || '无活动连接'
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running'].includes(t.status))
@ -18,12 +25,29 @@ async function command(t: Terminal, action: TerminalAction) {
const note = t.status === 'online' ? '将向终端提交命令并等待设备回执。' : '终端当前不在线,命令可能超时;不会显示为已执行成功。'
if (await confirm(`${labels[action]} · ${t.name}`, note)) await run(() => api.terminalCommand(t.id, action), '命令已提交,等待终端回执')
}
async function enroll() { if (!form.sn.trim() || !form.name.trim()) return; if (await run(async () => { const r = await api.enroll(form); secret.value = r.activationSecret }, '终端已登记')) { createVisible.value = false; activationVisible.value = true; form.sn = ''; form.name = ''; form.group = '' } }
async function enroll() { if (!form.sn.trim() || !form.name.trim()) return; if (await run(async () => { const r = await api.enroll(form); secret.value = r.activationSecret; activationSN.value = form.sn }, '终端已登记')) { createVisible.value = false; activationVisible.value = true; form.sn = ''; form.name = ''; form.group = '' } }
function edit(t: Terminal) { editingId.value = t.id; Object.assign(editForm, { name: t.name, group: t.group }); editVisible.value = true }
async function saveTerminal() { if (!editForm.name.trim() || busy.value) return; if (await run(() => api.updateTerminal(editingId.value, editForm), '终端资料已保存')) editVisible.value = false }
async function disable(t: Terminal) {
if (!await confirm(`停用终端 · ${t.name}`, '旧设备令牌和激活密钥将失效,不能再访问平台。重新使用需生成并配置新的激活信息。')) return
await run(() => api.disableTerminal(t.id), '终端已停用,旧凭据已撤销')
}
async function reactivate(t: Terminal) {
if (!await confirm(`重新激活 · ${t.name}`, '旧令牌、激活密钥和连接票据将失效。新激活密钥只显示一次,需要由设备管理员更新设备配置,不能自动恢复旧身份。')) return
if (await run(async () => { const r = await api.reactivateTerminal(t.id); secret.value = r.activationSecret; activationSN.value = t.sn }, '已生成新的激活信息')) activationVisible.value = true
}
</script>
<template>
<div class="page-heading"><div><h1>终端管理</h1><p class="subtext">设备身份、在线心跳与远程命令</p></div><el-button v-if="auth.canAdmin" type="primary" @click="createVisible = true">登记终端</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索终端名称 / SN" aria-label="搜索终端" clearable @input="page = 1" /><el-select v-model="status" placeholder="全部网络状态" aria-label="终端网络状态筛选" clearable @change="page = 1"><el-option label="在线" value="online" /><el-option label="离线" value="offline" /><el-option label="待激活" value="unregistered" /></el-select><span class="secondary">共 {{ filtered.length }} 台</span></div><el-table :data="rows" empty-text="暂无终端,先登记设备并完成激活"><el-table-column label="终端 / SN" min-width="185"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="mono secondary">{{ row.sn }}</div></template></el-table-column><el-table-column prop="group" label="分组" width="100" /><el-table-column label="网络状态" width="100"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column label="当前连接" min-width="150"><template #default="{ row }">{{ desktopName(row.sessionDesktopId) }}</template></el-table-column><el-table-column label="最近心跳" min-width="170"><template #default="{ row }">{{ row.lastSeen ? new Date(row.lastSeen).toLocaleString('zh-CN') : '尚未注册' }}</template></el-table-column><el-table-column label="操作" min-width="165"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || pending(row.id)" @click="command(row, 'reboot')">重启盒子</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section>
<el-drawer :model-value="!!selected" title="终端详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="设备 SN">{{ selected.sn }}</el-descriptions-item><el-descriptions-item label="网络状态"><StatusTag :value="selected.status" /></el-descriptions-item><el-descriptions-item label="Agent 版本">{{ selected.agentVersion || '尚未上报' }}</el-descriptions-item><el-descriptions-item label="配置版本">v{{ selected.configVersion }}</el-descriptions-item><el-descriptions-item label="当前连接">{{ desktopName(selected.sessionDesktopId) }}</el-descriptions-item><el-descriptions-item label="帧率">{{ selected.status === 'online' && selected.fps !== null ? `${selected.fps} FPS` : '暂无数据' }}</el-descriptions-item><el-descriptions-item label="网络 RTT">{{ selected.status === 'online' && selected.rtt !== null ? `${selected.rtt} ms` : '暂无数据' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">重置会话只结束终端连接,不会还原或关机云桌面。网络 RTT 不等于整体输入延迟。</p><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reboot')">重启盒子</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reset-session')">重置会话</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'collect-logs')">收集日志</el-button></div></template></el-drawer>
<div class="page-heading"><div><h1>终端管理</h1><p class="subtext">设备身份、在线心跳与远程命令</p></div><div v-if="auth.canAdmin" class="heading-actions"><el-button @click="csvVisible = true">批量导入</el-button><el-button type="primary" @click="createVisible = true">登记终端</el-button></div></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索终端名称 / SN" aria-label="搜索终端" clearable @input="page = 1" /><el-select v-model="status" placeholder="全部设备状态" aria-label="终端设备状态筛选" clearable @change="page = 1"><el-option label="在线" value="online" /><el-option label="离线" value="offline" /><el-option label="待激活" value="unregistered" /><el-option label="停用" value="disabled" /></el-select><span class="secondary">共 {{ filtered.length }} 台</span></div><el-table :data="rows" empty-text="暂无终端,先登记设备并完成激活"><el-table-column label="终端 / SN" min-width="185"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="mono secondary">{{ row.sn }}</div></template></el-table-column><el-table-column prop="group" label="分组" width="100" /><el-table-column label="设备状态" width="100"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column label="当前连接" min-width="150"><template #default="{ row }">{{ desktopName(row.sessionDesktopId) }}</template></el-table-column><el-table-column label="最近心跳" min-width="170"><template #default="{ row }">{{ row.lastSeen ? new Date(row.lastSeen).toLocaleString('zh-CN') : '尚未注册' }}</template></el-table-column><el-table-column label="操作" min-width="280"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate && !disabled(row)" link type="primary" :disabled="busy || pending(row.id)" @click="command(row, 'reboot')">重启盒子</el-button><el-button v-if="auth.canAdmin" link type="primary" :disabled="busy" @click="edit(row)">编辑资料</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section>
<el-drawer :model-value="!!selected" title="终端详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="设备 SN">{{ selected.sn }}</el-descriptions-item><el-descriptions-item label="设备状态"><StatusTag :value="selected.status" /></el-descriptions-item><el-descriptions-item label="Agent 版本">{{ selected.agentVersion || '尚未上报' }}</el-descriptions-item><el-descriptions-item label="配置版本">v{{ selected.configVersion }}</el-descriptions-item><el-descriptions-item label="当前连接">{{ desktopName(selected.sessionDesktopId) }}</el-descriptions-item><el-descriptions-item label="帧率">{{ selected.status === 'online' && selected.fps != null ? `${selected.fps} FPS` : '暂无数据' }}</el-descriptions-item><el-descriptions-item label="网络 RTT">{{ selected.status === 'online' && selected.rtt != null ? `${selected.rtt} ms` : '暂无数据' }}</el-descriptions-item></el-descriptions><el-button style="margin-top:18px" @click="metricsVisible = !metricsVisible">{{ metricsVisible ? '收起运行指标' : '查看运行指标' }}</el-button><TerminalMetrics v-if="metricsVisible" :terminal-id="selected.id" /><p class="subtext" style="margin:20px 0">重置会话只结束终端连接,不会还原或关机云桌面。网络 RTT 不等于整体输入延迟。</p><div class="actions" v-if="auth.canOperate && !disabled(selected)"><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reboot')">重启盒子</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reset-session')">重置会话</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'collect-logs')">收集日志</el-button></div><div v-if="auth.canAdmin" class="lifecycle-actions"><h3>设备管理</h3><p class="subtext">停用或重新激活会撤销旧凭据。请先结束活动会话并处理未完成命令;重新激活后需在设备上配置新密钥。</p><div class="actions"><el-button :disabled="busy" @click="edit(selected)">编辑资料</el-button><el-button v-if="!disabled(selected)" type="danger" plain :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="disable(selected)">停用设备</el-button><el-button :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="reactivate(selected)">重新激活</el-button></div></div></template></el-drawer>
<el-dialog v-model="createVisible" title="登记终端" width="480px"><el-form label-position="top" @submit.prevent="enroll"><el-form-item label="设备 SN" required><el-input v-model="form.sn" maxlength="100" /></el-form-item><el-form-item label="终端名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="分组"><el-input v-model="form.group" maxlength="80" /></el-form-item><div class="actions" style="justify-content:flex-end"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.sn.trim() || !form.name.trim()">登记并生成激活信息</el-button></div></el-form></el-dialog>
<el-dialog v-model="activationVisible" title="终端激活信息" width="530px" @closed="secret = ''"><p class="subtext" style="margin-bottom:16px">激活密钥只在此处显示一次,交给设备管理员配置。设备注册后即失效。</p><div class="secret mono" data-testid="activation-secret">{{ secret }}</div><template #footer><el-button type="primary" @click="activationVisible = false">已保存,关闭</el-button></template></el-dialog>
<el-dialog v-model="editVisible" title="编辑终端资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveTerminal"><el-form-item label="终端名称"><el-input v-model="editForm.name" maxlength="80" aria-label="编辑终端名称" /></el-form-item><el-form-item label="分组"><el-input v-model="editForm.group" maxlength="80" aria-label="编辑终端分组" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="editVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!editForm.name.trim()" @click="saveTerminal">保存终端资料</el-button></template></el-dialog>
<el-dialog v-model="activationVisible" title="终端激活信息" width="530px" @closed="secret = ''"><p class="subtext" style="margin-bottom:16px">激活密钥只在此处显示一次,交给设备管理员配置。设备注册后即失效。</p><p class="subtext" style="margin-bottom:12px">设备 SN:{{ activationSN }}</p><div class="secret mono" data-testid="activation-secret">{{ secret }}</div><template #footer><el-button type="primary" @click="activationVisible = false">已保存,关闭</el-button></template></el-dialog>
<CsvImportDialog v-model="csvVisible" kind="terminals" @completed="data.refresh" />
</template>
<style scoped>
.heading-actions{display:flex;gap:10px;flex-wrap:wrap}.heading-actions .el-button{margin-left:0}
.lifecycle-actions{margin-top:28px;padding-top:24px;border-top:1px solid var(--vdi-border)}.lifecycle-actions h3{font-size:14px}.lifecycle-actions p{margin:12px 0 18px;line-height:1.8}
</style>

View File

@ -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<User[]>([])
const profile = reactive({ displayName: '', enabled: true })
const batchVisible = ref(false), batchUsers = ref<User[]>([])
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
}
</script>
<template>
<div class="page-heading"><div><h1>用户与授权</h1><p class="subtext">每位用户可获得多个桌面;每台终端关联一位使用者</p></div><el-button v-if="auth.canAdmin" type="primary" @click="createVisible = true">新建用户</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 用户账号" aria-label="搜索用户" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" empty-text="暂无终端用户"><el-table-column label="用户 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="120"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理桌面使用者。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p>
<div class="page-heading"><div><h1>用户与授权</h1><p class="subtext">每位用户可获得多个桌面;每台终端关联一位使用者</p></div><div v-if="auth.canAdmin" class="actions"><el-button @click="csvKind = 'users'; csvVisible = true">导入用户</el-button><el-button @click="csvKind = 'bindings'; csvVisible = true">导入授权</el-button><el-button :disabled="!checked.length || busy" @click="openBatch">批量授权<span v-if="checked.length">({{ checked.length }})</span></el-button><el-button type="primary" @click="createVisible = true">新建用户</el-button></div></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 用户账号" aria-label="搜索用户" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" row-key="id" empty-text="暂无终端用户" @selection-change="selectUsers"><el-table-column v-if="auth.canAdmin" type="selection" width="44" /><el-table-column label="用户 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="185"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理桌面使用者。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p>
<el-dialog v-model="createVisible" title="新建终端用户" width="480px"><el-form label-position="top" @submit.prevent="create"><el-form-item label="姓名" required><el-input v-model="createForm.displayName" maxlength="80" /></el-form-item><el-form-item label="用户账号" required><el-input v-model="createForm.username" maxlength="80" /></el-form-item><p class="subtext">首版使用设备绑定模式,此账号用于授权关系,不是桌面系统密码。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :disabled="!createForm.username.trim() || !createForm.displayName.trim()" :loading="busy">创建用户</el-button></div></el-form></el-dialog>
<el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px"><el-form label-position="top" @submit.prevent="save"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="用户状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog>
<el-dialog v-model="profileVisible" title="编辑用户资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="用户账号"><el-input :model-value="profileUsername" disabled /></el-form-item><el-form-item label="姓名"><el-input v-model="profile.displayName" maxlength="80" aria-label="编辑用户姓名" /></el-form-item><el-form-item label="用户状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑用户启用状态" /></el-form-item><p class="subtext">登录名固定;停用保留桌面授权,阻止后续连接。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存用户资料</el-button></template></el-dialog>
<el-dialog v-model="batchVisible" title="批量桌面授权" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy"><el-form-item label="操作方式"><el-radio-group v-model="batch.action" aria-label="批量授权操作"><el-radio-button value="grant">追加分配</el-radio-button><el-radio-button value="revoke">撤销指定</el-radio-button></el-radio-group></el-form-item><el-form-item :label="`目标用户(${batchUsers.length} 位)`"><div class="batch-users"><el-tag v-for="u in batchUsers" :key="u.id" type="info">{{ u.displayName }} / {{ u.username }}</el-tag></div></el-form-item><el-form-item label="所选桌面"><el-checkbox-group v-model="batch.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><p class="subtext">只追加或撤销所选桌面,保留其他授权;停用用户仍需启用后才能连接。每次最多 100 位用户和 100 台桌面。</p></el-form><template #footer><el-button :disabled="busy" @click="batchVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!batchUsers.length || !batch.desktopIds.length || batchUsers.length > 100" @click="saveBatch">提交批量授权</el-button></template></el-dialog>
<el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" @submit.prevent="save" :disabled="busy"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id) || (t.enabled === false && t.id !== binding.terminalId)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="用户状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog>
<CsvImportDialog v-model="csvVisible" :kind="csvKind" @completed="data.refresh" />
</template>
<style scoped>
.batch-users{display:flex;flex-wrap:wrap;gap:8px;max-height:140px;overflow:auto}
</style>

View File

@ -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}

View File

@ -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; }
}

View File

@ -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; }
}

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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")
}

View File

@ -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"`

View File

@ -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<now()-interval '2 minutes'") == nil {
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop') AND created_at<now()-interval '2 minutes') OR (kind='batch-desktop' AND ((status='queued' AND created_at<now()-interval '2 hours') OR (status='running' AND updated_at<now()-interval '2 minutes'))) OR (kind IN ('provision','hardware','snapshot') AND updated_at<now()-interval '35 minutes'))") == nil {
for _, t := range expired {
if t.Kind == "batch-desktop" {
s.finishBatchPower(t, "", "expired", "")
continue
}
if t.Kind == "snapshot" {
s.finishSnapshot(t, "", "expired", "")
continue
}
if t.Kind == "hardware" {
s.finishHardware(t, "", "expired", "")
continue
}
if t.Kind == "provision" {
_, _ = s.DB.ExecContext(ctx, "UPDATE provisioning_jobs SET state='uncertain' WHERE task_id=$1 AND state='reserved'", t.ID)
}
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
}
}
var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE ((status='queued' AND kind!='terminal') OR (kind='provision' AND status='running' AND provider_ref!='') OR (kind IN ('hardware','snapshot','batch-desktop') AND status='running')) AND ($1 OR kind NOT IN ('provision','hardware','snapshot','batch-desktop')) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, !provisionBusy)
if err != nil {
continue
}
s.execute(ctx, t)
if t.Kind == "provision" || t.Kind == "hardware" || t.Kind == "snapshot" || t.Kind == "batch-desktop" {
provisionBusy = true
go func(task model.Task) { s.execute(workerCtx, task); provisionDone <- struct{}{} }(t)
} else {
s.execute(ctx, t)
}
}
}
}
@ -474,6 +594,27 @@ func (s *Server) RunSync(ctx context.Context) {
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
if t.Kind == "batch-desktop" {
s.executeBatchPower(parent, t)
return
}
if t.Kind == "snapshot" {
s.executeSnapshot(parent, t)
return
}
if t.Kind == "hardware" {
s.executeHardware(parent, t)
return
}
if err := s.RefreshCompute(parent); err != nil {
_ = s.complete(parent, t, "failed", "计算平台配置无法加载,未提交操作")
return
}
pve := s.currentPVE()
if t.Kind == "provision" {
s.executeProvision(parent, t)
return
}
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
@ -503,11 +644,11 @@ func (s *Server) execute(parent context.Context, t model.Task) {
}
}
} else if err == nil {
if s.PVE == nil {
if pve == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
upid, err = pve.Action(ctx, d.Node, d.VMID, t.Action)
uncertain = true
var httpError *proxmox.HTTPError
if errors.As(err, &httpError) && httpError.Status >= 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" {

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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, &current, 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)
}

View File

@ -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)
}

View File

@ -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})
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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, &current, "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
}

View File

@ -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)
}

View File

@ -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)})
}

View File

@ -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)
}

View File

@ -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<now()-interval '5 minutes' THEN 1 ELSE least(login_limits.attempts+1,$2+1) END,
window_started=CASE WHEN login_limits.window_started<now()-interval '5 minutes' THEN now() ELSE login_limits.window_started END RETURNING attempts,window_started`, key, limit.max)
if err != nil {
s.dbError(c, err)
return false
}
if row.Attempts > 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
}

View File

@ -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
}

View File

@ -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_at<now()-interval '90 seconds'`); err != nil {
return err
}
rules := []alertRule{}
if err = tx.SelectContext(ctx, &rules, "SELECT * FROM alert_rules WHERE enabled ORDER BY id"); err != nil {
return err
}
for _, rule := range rules {
targets := []observation{}
query := `SELECT id,name,extract(epoch FROM(now()-last_seen))::double precision value,false unavailable FROM terminals WHERE enabled AND token_hash IS NOT NULL AND last_seen IS NOT NULL AND status!='unregistered'`
if rule.Metric == "node_unavailable" {
query = `SELECT id,name,extract(epoch FROM(now()-updated_at))::double precision value,(status!='online') unavailable FROM resources WHERE provider='proxmox'`
}
column := map[string]string{"node_cpu": "cpu", "node_memory": "memory", "node_storage": "storage"}[rule.Metric]
if column != "" {
query = `SELECT r.id,r.name,CASE WHEN r.status='online' AND r.updated_at>now()-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_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM session_history WHERE ended_at<now()-interval '90 days'", "DELETE FROM alerts WHERE resolved_at<now()-interval '90 days'"} {
if _, err = tx.ExecContext(ctx, query); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Server) RunObservability(ctx context.Context) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
readCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
if err := s.observe(readCtx); err != nil {
log.Printf("observability evaluation failed: %v", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@ -0,0 +1,355 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"vdi-cloud/platform/pkg/proxmox"
)
func TestCSVParsingAndAtomicImport(t *testing.T) {
rows, message := parseImport(importInput{Kind: "users", CSV: "\ufeffusername,displayName\r\nworker,\"研发,一组\"\r\nworker,重复\r\n"})
if message != "" || len(rows) != 2 || rows[0].Values["displayName"] != "研发,一组" || len(rows[1].Errors) == 0 {
t.Fatalf("CSV parsing: %v %s", rows, message)
}
f := newFixture(t)
admin := f.login("admin")
viewer := f.login("viewer")
in := map[string]any{"kind": "users", "csv": "username,displayName\nimported,导入用户\n"}
code, out := f.request("POST", "/imports/preview", viewer, in, "")
expect(t, 403, code, out)
code, out = f.request("POST", "/imports/preview", admin, in, "")
expect(t, 200, code, out)
if out["validRows"] != float64(1) {
t.Fatal(out)
}
in["previewToken"] = out["previewToken"]
var count int
f.db.Get(&count, "SELECT count(*) FROM users WHERE username='imported'")
if count != 0 {
t.Fatal("preview wrote a user")
}
code, out = f.request("POST", "/imports/commit", admin, in, "csv-users")
expect(t, 200, code, out)
code, out = f.request("POST", "/imports/commit", admin, in, "csv-users")
expect(t, 200, code, out)
if out["replayed"] != true {
t.Fatal("replay not recognized")
}
bad := map[string]any{"kind": "users", "csv": "username,displayName\nfresh,有效\nimported,已存在\n"}
code, out = f.request("POST", "/imports/preview", admin, bad, "")
expect(t, 200, code, out)
if out["errorRows"] != float64(1) {
t.Fatal(out)
}
bad["previewToken"] = strings.Repeat("0", 64)
code, out = f.request("POST", "/imports/commit", admin, bad, "csv-invalid")
expect(t, 409, code, out)
f.db.Get(&count, "SELECT count(*) FROM users WHERE username='fresh'")
if count != 0 {
t.Fatal("partial import")
}
terminals := map[string]any{"kind": "terminals", "csv": "sn,name,group\nCSV-TERM-001,批量终端,研发\n"}
code, out = f.request("POST", "/imports/preview", admin, terminals, "")
expect(t, 200, code, out)
terminals["previewToken"] = out["previewToken"]
code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals")
expect(t, 200, code, out)
keys := out["activationSecrets"].([]any)
if len(keys) != 1 {
t.Fatal(out)
}
secret := keys[0].(map[string]any)["activationSecret"].(string)
var stored string
f.db.Get(&stored, "SELECT result::text FROM import_batches WHERE id=$1", out["id"])
if strings.Contains(stored, secret) {
t.Fatal("plaintext activation secret persisted")
}
code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals")
expect(t, 200, code, out)
if len(out["activationSecrets"].([]any)) != 0 {
t.Fatal("replay exposed credentials")
}
binding := map[string]any{"kind": "bindings", "csv": "username,terminalSn,desktopIds\nimported,CSV-TERM-001,desk_demo_windows\n"}
code, out = f.request("POST", "/imports/preview", admin, binding, "")
expect(t, 200, code, out)
binding["previewToken"] = out["previewToken"]
var uid string
f.db.Get(&uid, "SELECT id FROM users WHERE username='imported'")
if _, err := f.db.Exec("INSERT INTO bindings(user_id,desktop_id) VALUES($1,'desk_demo_ubuntu')", uid); err != nil {
t.Fatal(err)
}
code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings")
expect(t, 409, code, out)
code, out = f.request("POST", "/imports/preview", admin, binding, "")
expect(t, 200, code, out)
binding["previewToken"] = out["previewToken"]
code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings")
expect(t, 200, code, out)
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id=$1", uid)
if count != 2 {
t.Fatal("existing grant was lost")
}
var sn string
f.db.Get(&sn, "SELECT t.sn FROM users u JOIN terminals t ON t.id=u.terminal_id WHERE u.id=$1", uid)
if sn != "CSV-TERM-001" {
t.Fatal("terminal not bound")
}
}
func TestSessionHistoryLifecycle(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
code, out := f.request("POST", "/terminals", admin, map[string]any{"sn": "HISTORY-001", "name": "历史终端", "group": "测试"}, "")
expect(t, 201, code, out)
id := out["id"].(string)
code, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "HISTORY-001", "activationSecret": out["activationSecret"], "agentVersion": "test"}, "")
expect(t, 200, code, out)
device := out["accessToken"].(string)
code, 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, code, out)
if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.1' WHERE id='desk_demo_windows'"); err != nil {
t.Fatal(err)
}
reserve := func() string {
code, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, code, out)
code, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "")
expect(t, 200, code, out)
return out["sessionId"].(string)
}
session := reserve()
var row sessionRecord
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session); err != nil {
t.Fatal(err)
}
if row.State != "connecting" || row.ConnectedAt != nil {
t.Fatal("reservation claimed stream ready")
}
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "")
expect(t, 200, code, out)
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
first := *row.ConnectedAt
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "")
expect(t, 200, code, out)
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
if !row.ConnectedAt.Equal(first) {
t.Fatal("connected time reset")
}
if _, err := f.db.Exec("UPDATE terminals SET name='改名' WHERE id=$1", id); err != nil {
t.Fatal(err)
}
second := reserve()
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "")
expect(t, 409, code, out)
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
if row.EndReason != "replaced" || row.TerminalName != "历史终端" {
t.Fatal("lost snapshot or replacement")
}
if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1", second); err != nil {
t.Fatal(err)
}
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "connected"}, "")
expect(t, 200, code, out)
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
t.Fatal(err)
}
if row.Source != "observed" || row.ConnectedAt == nil {
t.Fatal("did not resume observed history")
}
if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
t.Fatal(err)
}
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
code, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "test"}, "")
expect(t, 200, code, out)
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
t.Fatal(err)
}
if row.Source != "observed" || row.State != "connected" {
t.Fatal("heartbeat did not resume observed history")
}
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "ended"}, "")
expect(t, 200, code, out)
code, out = f.request("GET", "/sessions/history?keyword="+url.QueryEscape("历史终端")+"&pageSize=1", admin, nil, "")
expect(t, 200, code, out)
if out["total"] != float64(1) || len(out["items"].([]any)) != 1 {
t.Fatal(out)
}
code, out = f.request("GET", "/sessions/history?status=invalid", admin, nil, "")
expect(t, 400, code, out)
}
func TestMonitoringHoldAcknowledgeRecoveryAndSeries(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
viewer := f.login("viewer")
operator := f.login("operator")
rule := map[string]any{"name": "测试CPU", "metric": "node_cpu", "threshold": 90, "holdSeconds": 120, "enabled": true}
code, out := f.request("POST", "/monitor/rules", viewer, rule, "")
expect(t, 403, code, out)
code, out = f.request("POST", "/monitor/rules", admin, rule, "")
expect(t, 200, code, out)
rid := out["id"].(string)
if _, err := f.db.Exec("INSERT INTO resources VALUES('test-node','测试节点','online',0.95,0.5,0.5,'proxmox',now()); INSERT INTO resource_samples(resource_id,cpu,memory) VALUES('test-node',0.95,0.5)"); err != nil {
t.Fatal(err)
}
evaluate := func() {
t.Helper()
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
}
evaluate()
var count int
f.db.Get(&count, "SELECT count(*) FROM alerts")
if count != 0 {
t.Fatal("triggered before hold")
}
if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds'"); err != nil {
t.Fatal(err)
}
evaluate()
evaluate()
f.db.Get(&count, "SELECT count(*) FROM alerts")
if count != 1 {
t.Fatal("alert missing or duplicated")
}
var aid string
f.db.Get(&aid, "SELECT id FROM alerts")
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", viewer, nil, "")
expect(t, 403, code, out)
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", operator, nil, "")
expect(t, 200, code, out)
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", admin, nil, "")
expect(t, 200, code, out)
var actor string
f.db.Get(&actor, "SELECT acknowledged_by FROM alerts WHERE id=$1", aid)
if actor != "operator" {
t.Fatal("first acknowledgment overwritten")
}
if _, err := f.db.Exec("UPDATE resources SET updated_at=now()-interval '100 seconds' WHERE id='test-node'"); err != nil {
t.Fatal(err)
}
evaluate()
var state string
f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid)
if state != "firing" {
t.Fatal("missing data claimed recovery")
}
if _, err := f.db.Exec("UPDATE resources SET updated_at=now() WHERE id='test-node'; UPDATE resource_samples SET cpu=0.1"); err != nil {
t.Fatal(err)
}
evaluate()
f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid)
if state != "resolved" {
t.Fatal("did not recover")
}
code, out = f.request("GET", "/monitor/series?resourceId=test-node", viewer, nil, "")
expect(t, 200, code, out)
points := out["items"].([]any)
if len(points) != 1 || points[0].(map[string]any)["storage"] != nil || points[0].(map[string]any)["cpu"] != float64(10) {
t.Fatal(out)
}
rule["enabled"] = false
code, out = f.request("PUT", "/monitor/rules/"+rid, admin, rule, "")
expect(t, 200, code, out)
code, out = f.request("GET", "/monitor/alerts?status=resolved&pageSize=1", viewer, nil, "")
expect(t, 200, code, out)
if out["total"] != float64(1) {
t.Fatal(out)
}
}
func TestNodeAndTerminalOfflineThresholds(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
for _, metric := range []string{"node_unavailable", "terminal_offline"} {
code, out := f.request("POST", "/monitor/rules", admin, map[string]any{"name": metric, "metric": metric, "threshold": 120, "holdSeconds": 0, "enabled": true}, "")
expect(t, 200, code, out)
}
if _, err := f.db.Exec(`INSERT INTO resources VALUES('offline-node','离线节点','offline',0,0,0,'proxmox',now());
INSERT INTO terminals(id,sn,name,bootstrap_hash,token_hash,last_seen,status) VALUES('offline-box','OFFLINE-001','离线终端','hash','token',now()-interval '130 seconds','online'),('new-box','NEW-001','未激活终端','hash',NULL,NULL,'unregistered')`); err != nil {
t.Fatal(err)
}
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
var count int
f.db.Get(&count, "SELECT count(*) FROM alerts")
if count != 1 {
t.Fatal("offline node fired early or inactive terminal included")
}
if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds' WHERE target_id='offline-node'"); err != nil {
t.Fatal(err)
}
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'")
if count != 2 {
t.Fatal("node never fired")
}
if _, err := f.db.Exec("UPDATE terminals SET enabled=false WHERE id='offline-box'; UPDATE resources SET status='online',updated_at=now() WHERE id='offline-node'"); err != nil {
t.Fatal(err)
}
if err := f.server.observe(context.Background()); err != nil {
t.Fatal(err)
}
f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'")
if count != 0 {
t.Fatal("disabled or recovered target stayed firing")
}
}
// Ensure nullable fields retain their JSON representation for clients drawing gaps.
func TestResourcePointNullJSON(t *testing.T) {
b, err := json.Marshal(resourcePoint{Time: time.Now()})
if err != nil || !strings.Contains(string(b), `"cpu":null`) {
t.Fatal(string(b), err)
}
}
func TestPVESyncPersistsSamplesAndOfflineGaps(t *testing.T) {
f := newFixture(t)
online := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := "offline"
if online {
status = "online"
}
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"type": "node", "node": "sample-node", "status": status, "cpu": 0.4, "mem": float64(16 << 30), "maxmem": float64(32 << 30), "disk": float64(40 << 30), "maxdisk": float64(100 << 30)}}})
}))
defer srv.Close()
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
if err := f.server.sync(context.Background()); err != nil {
t.Fatal(err)
}
var point resourcePoint
if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node'"); err != nil {
t.Fatal(err)
}
if point.Memory == nil || *point.Memory != 0.5 {
t.Fatal("incorrect memory ratio")
}
online = false
if err := f.server.sync(context.Background()); err != nil {
t.Fatal(err)
}
if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node' ORDER BY sampled_at DESC LIMIT 1"); err != nil {
t.Fatal(err)
}
if point.CPU != nil || point.Memory != nil || point.Storage != nil {
t.Fatal("offline node claimed a valid sample")
}
}

View File

@ -0,0 +1,322 @@
package server
import (
"database/sql"
"encoding/json"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/store"
)
// These settings affect management polling only, never the active stream.
type PolicySettings struct {
HeartbeatInterval int `json:"heartbeatInterval" db:"heartbeat_interval" binding:"min=15,max=60"`
CommandPollInterval int `json:"commandPollInterval" db:"command_poll_interval" binding:"min=3,max=30"`
DesktopRefreshInterval int `json:"desktopRefreshInterval" db:"desktop_refresh_interval" binding:"min=5,max=120"`
}
type terminalPolicy struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name" binding:"required,max=80"`
Version string `json:"version" db:"version" binding:"required,max=40"`
PolicySettings
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
type policyDeployment struct {
ID string `json:"id" db:"id"`
TemplateID string `json:"templateId" db:"template_id"`
TemplateName string `json:"templateName" db:"template_name"`
TemplateVersion string `json:"templateVersion" db:"template_version"`
Actor string `json:"actor" db:"actor"`
TargetCount int `json:"targetCount" db:"target_count"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
type policyResult struct {
ID string `json:"id" db:"id"`
TerminalID string `json:"terminalId" db:"terminal_id"`
TerminalName string `json:"terminalName" db:"terminal_name"`
Group string `json:"group" db:"group_name"`
Protocol int `json:"protocol" db:"protocol"`
DeploymentID string `json:"deploymentId" db:"deployment_id"`
TemplateName string `json:"templateName" db:"template_name"`
TemplateVersion string `json:"templateVersion" db:"template_version"`
ConfigVersion int `json:"configVersion" db:"config_version"`
Status string `json:"status" db:"status"`
ErrorCode string `json:"errorCode" db:"error_code"`
AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"`
Online bool `json:"online" db:"online"`
Enabled bool `json:"enabled" db:"enabled"`
Registered bool `json:"registered" db:"registered"`
}
const deploymentSQL = `SELECT d.id,d.template_id,p.name template_name,p.version template_version,d.actor,
(SELECT count(*) FROM terminal_policy_deliveries r WHERE r.deployment_id=d.id) target_count,d.created_at
FROM terminal_policy_deployments d JOIN terminal_policy_templates p ON p.id=d.template_id`
const resultSQL = `SELECT COALESCE(r.id,'') id,t.id terminal_id,t.name terminal_name,t.group_name,
COALESCE(c.protocol,0) protocol,COALESCE(r.deployment_id,'') deployment_id,
COALESCE(p.name,'') template_name,COALESCE(p.version,'') template_version,
t.config_version,COALESCE(r.status,'unconfigured') status,COALESCE(r.error_code,'') 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 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})
}

View File

@ -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)
}
}

View File

@ -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, &current, "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})
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)

View File

@ -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")})
}

View File

@ -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"]) != "<nil>" && 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, &current, "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})
}

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -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})
}

View File

@ -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<now()-interval '30 days'")
if old != 0 {
t.Fatal("metrics retention not applied")
}
}

View File

@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS administrators (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('super_admin','operator','viewer')), password_hash text NOT NULL
);
ALTER TABLE administrators ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS admin_sessions (
id text PRIMARY KEY, admin_id text NOT NULL REFERENCES administrators(id), expires_at timestamptz NOT NULL
);
@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS terminals (
session_desktop_id text REFERENCES desktops(id), config_version integer NOT NULL DEFAULT 1,
bootstrap_hash text NOT NULL, token_hash text UNIQUE, fps double precision, rtt double precision
);
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS users (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
enabled boolean NOT NULL DEFAULT true, terminal_id text UNIQUE REFERENCES terminals(id)
@ -57,3 +59,142 @@ CREATE TABLE IF NOT EXISTS task_diagnostics (
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS image_templates (
id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')),
node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL,
enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(node,vmid), UNIQUE(name,version)
);
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);

View File

@ -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
}
}

View File

@ -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
}

128
platform/pkg/proxmox/gpu.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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 }) },