Compare commits
6
Commits
d87fa47e24
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7b01e4db | ||
|
|
b6026f9759 | ||
|
|
9f05aa20ea | ||
|
|
5ac692724d | ||
|
|
6134d49c16 | ||
|
|
eef485306f |
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { fail?: boolean; deny?: boolean; slow?: boolean } = {}) {
|
||||
const revoked: string[] = [], queries: string[] = []
|
||||
const now = new Date().toISOString(), expires = new Date(Date.now() + 3600000).toISOString()
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'session-fixture'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
|
||||
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: 1, pageSize: 20, requestId: 'sessions-ui' } })
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['administrator-sessions'], pveConfigured: true } }); 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([{ id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin', enabled: true }, { id: 'other', username: 'maintainer', displayName: '运维', role: 'operator', enabled: true }]); return }
|
||||
const match = path.match(/^\/administrators\/(owner|other)\/sessions(?:\/(.+))?$/)
|
||||
if (match) {
|
||||
const id = match[1]!, key = match[2]
|
||||
if (request.method() === 'DELETE') {
|
||||
revoked.push(`${id}/${key}`)
|
||||
if (options.deny) { await route.fulfill({ status: 403, json: { code: 'forbidden', message: '当前账号没有该操作权限' } }); return }
|
||||
await route.fulfill({ status: 204 }); return
|
||||
}
|
||||
queries.push(id)
|
||||
if (options.fail && id === 'other') { await route.fulfill({ status: 500, json: { code: 'database_error', message: '登录列表暂不可用' } }); return }
|
||||
if (options.slow && id === 'owner') await new Promise(resolve => setTimeout(resolve, 700))
|
||||
const p = Number(url.searchParams.get('page') || 1), size = 20, total = 21 - revoked.filter(r => r.startsWith(`${id}/`)).length
|
||||
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => ({ key: `${id}-${(p - 1) * size + i}`, createdAt: i === 1 ? null : now, expiresAt: expires, clientIp: i === 1 ? '' : `192.0.2.${(p - 1) * size + i + 1}`, userAgent: 'Example browser <script>alert(1)</script>', current: id === 'owner' && p === 1 && i === 0 })), total); return
|
||||
}
|
||||
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
|
||||
})
|
||||
await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible()
|
||||
return { revoked, queries }
|
||||
}
|
||||
async function open(page: Page, name: string) {
|
||||
await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '在线登录', exact: true }).click()
|
||||
return page.getByRole('dialog', { name: `在线登录 · ${name}`, exact: true })
|
||||
}
|
||||
|
||||
test('登录来源、旧登录未知信息、分页、当前登录保护及深浅窄屏', async ({ page }, info) => {
|
||||
const calls = await fixture(page), dialog = await open(page, 'root')
|
||||
await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('row').filter({ hasText: '当前登录' }).getByRole('button', { name: '撤销登录' })).toBeDisabled()
|
||||
await expect(dialog.getByText('升级前登录,时间未知', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText('升级前登录,来源未知', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath('admin-sessions-dark.png'), fullPage: true, animations: 'disabled' })
|
||||
await dialog.locator('.el-pagination .btn-next').click(); await expect(dialog.getByText('192.0.2.21', { exact: true })).toBeVisible()
|
||||
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
|
||||
await open(page, 'root'); await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||
await page.screenshot({ path: info.outputPath('admin-sessions-mobile.png'), fullPage: true, animations: 'disabled' })
|
||||
expect(calls.revoked).toEqual([])
|
||||
})
|
||||
|
||||
test('撤销指定登录需确认,取消不提交,成功刷新', async ({ page }) => {
|
||||
const calls = await fixture(page), dialog = await open(page, 'maintainer')
|
||||
await expect(dialog.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
|
||||
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(calls.revoked).toEqual([])
|
||||
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect(dialog.getByText('共 20 次有效登录', { exact: true })).toBeVisible()
|
||||
expect(calls.revoked).toEqual(['other/other-0'])
|
||||
})
|
||||
|
||||
test('查询和撤销失败清除旧结果并显示错误,不误报成功', async ({ page }) => {
|
||||
const calls = await fixture(page, { deny: true }), dialog = await open(page, 'maintainer')
|
||||
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect(dialog.getByText('当前账号没有该操作权限', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
|
||||
expect(calls.revoked).toHaveLength(1)
|
||||
await page.unrouteAll(); await fixture(page, { fail: true }); await open(page, 'maintainer')
|
||||
await expect(page.getByRole('dialog').getByText('登录列表暂不可用', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('切换账号后旧请求不能覆盖新的登录列表', async ({ page }) => {
|
||||
const calls = await fixture(page, { slow: true }), old = await open(page, 'root')
|
||||
await expect.poll(() => calls.queries.includes('owner')).toBe(true)
|
||||
await old.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
const current = await open(page, 'maintainer')
|
||||
await expect(current.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
|
||||
await page.waitForTimeout(850)
|
||||
await expect(current.getByText('当前登录', { exact: true })).toHaveCount(0)
|
||||
expect(calls.revoked).toEqual([])
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { fail?: boolean; oversized?: boolean; exportFail?: boolean } = {}) {
|
||||
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = []
|
||||
const now = new Date().toISOString()
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'audit-fixture'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
|
||||
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'audit-ui', ...extra } })
|
||||
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送写请求' } }); return }
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '只读人员', role: 'viewer' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['audit'], pveConfigured: false } }); 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 === '/audit-logs/export') {
|
||||
exports.push(url.searchParams)
|
||||
if (options.exportFail) { await route.fulfill({ status: 413, json: { message: '匹配记录超过 10000 条,请缩小时间范围', code: 'export_too_large', requestId: 'audit-ui' } }); return }
|
||||
await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff时间,操作者\r\n2026-11-11T00:00:00Z,root\r\n' }); return
|
||||
}
|
||||
if (path === '/audit-logs') {
|
||||
queries.push(url.searchParams)
|
||||
if (options.fail && url.searchParams.get('keyword')) { await route.fulfill({ status: 500, json: { message: '审计查询暂不可用', code: 'database_error', requestId: 'audit-ui' } }); return }
|
||||
const p = Number(url.searchParams.get('page') || 1), filtered = !!url.searchParams.get('actor'), total = options.oversized ? 10001 : filtered ? 1 : 45
|
||||
const items = Array.from({ length: Math.min(20, total - (p - 1) * 20) }, (_, i) => ({ id: `audit-${(p - 1) * 20 + i + 1}`, actor: filtered ? 'operator_%' : 'root', action: 'save_alert_rule', target: `node-${(p - 1) * 20 + i + 1}`, result: 'success', requestId: `req-${p}-${i}`, createdAt: now }))
|
||||
await list(items, { total, page: p, pageSize: 20 }); return
|
||||
}
|
||||
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
|
||||
})
|
||||
return { queries, exports, writes }
|
||||
}
|
||||
|
||||
test('按需加载审计,真实分页参数和固定截止时间,浅色移动布局', async ({ page }, info) => {
|
||||
const calls = await fixture(page); await page.goto('/tasks')
|
||||
await expect(page.getByRole('tab', { name: '执行任务' })).toBeVisible()
|
||||
expect(calls.queries).toHaveLength(0)
|
||||
await page.getByRole('tab', { name: '操作审计' }).click()
|
||||
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
await page.locator('.el-pagination .btn-next').click()
|
||||
await expect(page.getByRole('cell', { name: 'node-21', exact: true })).toBeVisible()
|
||||
expect(calls.queries[calls.queries.length - 1]?.get('page')).toBe('2')
|
||||
expect(calls.queries[calls.queries.length - 1]?.get('to')).toBe(calls.queries[0]?.get('to'))
|
||||
await page.screenshot({ path: info.outputPath('audit-dark.png'), fullPage: true, animations: 'disabled' })
|
||||
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.screenshot({ path: info.outputPath('audit-mobile-light.png'), fullPage: true, animations: 'disabled' })
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||
expect(calls.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('操作人、动作、结果与时间筛选传到后端,导出使用已查询条件而非未提交的输入', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
|
||||
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '审计操作者', exact: true }).fill('operator_%')
|
||||
await page.getByRole('textbox', { name: '审计关键词' }).fill('req_trace')
|
||||
await page.getByRole('combobox', { name: '审计动作' }).click(); await page.getByRole('option', { name: '保存告警规则', exact: true }).click()
|
||||
await page.getByRole('combobox', { name: '审计结果' }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
|
||||
await page.getByRole('combobox', { name: '审计时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
|
||||
await page.getByRole('button', { name: '查询审计', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: 'operator_%', exact: true })).toBeVisible()
|
||||
const q = calls.queries[calls.queries.length - 1]!
|
||||
expect(Object.fromEntries(q)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', page: '1', pageSize: '20' })
|
||||
expect(q.get('from')).toBeTruthy(); expect(q.get('to')).toBeTruthy()
|
||||
await page.getByRole('textbox', { name: '审计关键词' }).fill('还未查询的输入')
|
||||
const downloaded = page.waitForEvent('download'); await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
|
||||
expect((await downloaded).suggestedFilename()).toMatch(/操作审计.*\.csv$/)
|
||||
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', from: q.get('from'), to: q.get('to') })
|
||||
expect(calls.exports[0]?.get('page')).toBeNull()
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: 'root', exact: true }).first()).toBeVisible()
|
||||
expect(calls.queries[calls.queries.length - 1]?.get('actor')).toBe('')
|
||||
expect(calls.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('查询失败清除旧结果并禁用导出,允许重试', async ({ page }) => {
|
||||
const calls = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
|
||||
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '审计关键词' }).fill('failure'); await page.getByRole('button', { name: '查询审计', exact: true }).click()
|
||||
await expect(page.getByText(/审计查询暂不可用/)).toBeVisible(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
expect(calls.exports).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('导出超量有明确提示,服务端导出错误保留查询结果', async ({ page }) => {
|
||||
await fixture(page, { oversized: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
|
||||
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
|
||||
await page.unrouteAll(); await fixture(page, { exportFail: true }); await page.reload(); await page.getByRole('tab', { name: '操作审计' }).click()
|
||||
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
|
||||
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { role?: string; queryError?: boolean; exportError?: boolean; slow?: boolean; offline?: boolean; pending?: boolean; collectError?: boolean } = {}) {
|
||||
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: { path: string; key: string | undefined; body: unknown }[] = []
|
||||
let collected = false, attempts = 0
|
||||
const now = new Date().toISOString()
|
||||
const task = () => ({ id: 'collect-1', kind: 'terminal', action: 'collect-logs', targetId: 't1', targetName: '同名盒子', actor: 'operator', status: collected ? 'success' : 'running', message: collected ? '日志已回传' : '等待设备回执', providerRef: '', createdAt: now, updatedAt: now })
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'diagnostics-fixture'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
|
||||
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100 } })
|
||||
if (request.method() !== 'GET') {
|
||||
writes.push({ path, key: request.headers()['idempotency-key'], body: request.postDataJSON() })
|
||||
if (path === '/terminals/t1/commands') {
|
||||
attempts++
|
||||
if (options.collectError && attempts === 1) { await route.fulfill({ status: 503, json: { message: '提交结果尚未确认', code: 'unavailable', requestId: 'diag-ui' } }); return }
|
||||
collected = true; await route.fulfill({ status: 202, json: { ...task(), status: 'queued', message: '采集命令已提交' } }); return
|
||||
}
|
||||
await route.abort(); return
|
||||
}
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'operator', username: 'operator', displayName: '运维', role: options.role || 'operator' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
|
||||
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1 } }); return }
|
||||
if (path === '/desktops') { await list([{ id: 'd1', name: '办公桌面', node: 'pve', vmid: 100, powerState: 'running', serviceState: 'unknown', os: 'Windows' }]); return }
|
||||
if (path === '/terminals') { await list(['t1', 't2'].map((id, i) => ({ id, name: '同名盒子', sn: `SN-${i + 1}`, status: options.offline ? 'offline' : 'online', enabled: true, lastSeen: now, sessionDesktopId: null }))); return }
|
||||
if (path === '/resources' || path === '/users') { await list([]); return }
|
||||
if (path === '/tasks') { await list(collected || options.pending ? [options.pending ? { ...task(), status: 'timeout' } : task()] : []); return }
|
||||
if (path === '/diagnostics/export') {
|
||||
exports.push(url.searchParams)
|
||||
if (options.exportError) { await route.fulfill({ status: 413, json: { code: 'export_too_large', message: '诊断事件超过 2000 条', requestId: 'diag-ui' } }); return }
|
||||
await route.fulfill({ contentType: 'application/zip', body: 'fixture-download' }); return
|
||||
}
|
||||
if (path === '/diagnostics') {
|
||||
queries.push(url.searchParams)
|
||||
if (options.queryError && url.searchParams.get('source')) { await route.fulfill({ status: 500, json: { message: '诊断查询暂不可用', requestId: 'diag-ui' } }); return }
|
||||
const target = url.searchParams.get('targetId') || 't1', kind = url.searchParams.get('targetType') || 'terminal', source = url.searchParams.get('source') || '', p = Number(url.searchParams.get('page') || 1)
|
||||
if (options.slow && target === 't1') await new Promise(resolve => setTimeout(resolve, 600))
|
||||
const items = [{ id: `event-${p}`, source: 'logs', time: now, action: 'collect-logs', status: 'success', targetId: target, targetName: target === 't2' ? '第二个同名盒子' : '同名盒子', actor: 'operator', message: target === 't2' ? '第二个设备独立记录' : '终端已回传的日志片段', details: JSON.stringify({ taskId: `task-${p}`, logTail: '<img src=x onerror="window.diagnosticInjected=true">\nnetwork error\n[REDACTED]', bootId: 'test' }) }]
|
||||
await route.fulfill({ json: { subject: { kind, id: target, name: kind === 'desktop' ? '办公桌面' : target === 't2' ? '第二个同名盒子' : '同名盒子', state: options.offline ? 'offline' : kind === 'desktop' ? 'running' : 'online', node: kind === 'desktop' ? 'pve' : '', vmid: kind === 'desktop' ? 100 : 0, lastSeen: now, agentVersion: 'test' }, from: url.searchParams.get('from') || now, to: url.searchParams.get('to') || now, observedAt: now, items, total: source ? 1 : 25, page: p, pageSize: 20, source, counts: { tasks: 10, sessions: 10, logs: 3, alerts: 1, audits: 1 }, notes: ['按唯一标识关联;无记录不代表设备无故障。', '日志为设备回传片段,当前快照独立于历史时间范围。'] } }); return
|
||||
}
|
||||
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
|
||||
})
|
||||
return { queries, exports, writes }
|
||||
}
|
||||
|
||||
test('按对象显式查询,关联来源与详情以纯文本展示,支持分页', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/diagnostics')
|
||||
await expect(page.getByText('选择需要排查的设备或桌面,再查询诊断记录')).toBeVisible(); expect(calls.queries).toHaveLength(0)
|
||||
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-1', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '关联事件', exact: true })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('targetId')).toBe('t1')
|
||||
await page.getByText('查看关联标识与详情', { exact: true }).click(); await expect(page.locator('.diagnosis-log')).toContainText('<img src=x'); expect(await page.evaluate(() => (window as Window & { diagnosticInjected?: boolean }).diagnosticInjected)).toBeUndefined()
|
||||
await expect(page.locator('.diagnosis-event img')).toHaveCount(0)
|
||||
const cutoff = calls.queries.slice(-1)[0]?.get('to'); await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('page')).toBe('2'); expect(calls.queries.slice(-1)[0]?.get('to')).toBe(cutoff)
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('导出沿用已查询条件,修改筛选立即清除旧记录并禁用导出', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
|
||||
const applied = calls.queries.slice(-1)[0]!, download = page.waitForEvent('download'); await page.getByRole('button', { name: '导出诊断包', exact: true }).click(); expect((await download).suggestedFilename()).toContain('.zip')
|
||||
expect(calls.exports[0]?.get('targetId')).toBe(applied.get('targetId')); expect(calls.exports[0]?.get('to')).toBe(applied.get('to')); expect(calls.exports[0]?.get('page')).toBeNull()
|
||||
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '监控告警', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('source')).toBe('alerts')
|
||||
})
|
||||
|
||||
test('查询失败清除结果,导出超量显示后台错误', async ({ page }) => {
|
||||
const calls = await fixture(page, { queryError: true, exportError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '导出诊断包' }).click(); await expect(page.getByText(/诊断事件超过 2000 条/)).toBeVisible()
|
||||
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '执行任务', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByText(/诊断查询暂不可用/)).toBeVisible(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('切换对象后较晚返回的旧查询不能覆盖新对象', async ({ page }) => {
|
||||
await fixture(page, { slow: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1')
|
||||
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-2', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '第二个同名盒子', exact: true })).toBeVisible(); await expect(page.getByText('第二个设备独立记录')).toBeVisible(); await expect(page.getByRole('heading', { name: '同名盒子', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('采集先确认,取消不提交,提交与回执分开,结果失败可复用原幂等标识', async ({ page }) => {
|
||||
const calls = await fixture(page, { collectError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await page.getByRole('button', { name: '收集此设备日志' }).click()
|
||||
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不会重启设备'); await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText(/提交结果尚未确认/)).toBeVisible()
|
||||
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText('本次日志采集 · 同名盒子')).toBeVisible(); await expect(page.getByRole('button', { name: '查看本次采集' })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(2); expect(calls.writes[0]?.key).toBeTruthy(); expect(calls.writes[0]?.key).toBe(calls.writes[1]?.key); expect(calls.writes[1]?.body).toEqual({ action: 'collect-logs' })
|
||||
await page.getByRole('button', { name: '查看本次采集' }).click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('source')).toBe('logs')
|
||||
})
|
||||
|
||||
for (const options of [{ offline: true }, { pending: true }]) test(`离线或有未知任务时禁止发起新采集 ${JSON.stringify(options)}`, async ({ page }) => {
|
||||
const calls = await fixture(page, options); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('button', { name: '收集此设备日志' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('只读角色隐藏高级入口且直接访问被拦截', async ({ page }) => {
|
||||
const calls = await fixture(page, { role: 'viewer' }); await page.goto('/overview'); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '日志与诊断' })).toHaveCount(0)
|
||||
await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page).toHaveURL(/\/forbidden$/); expect(calls.queries).toHaveLength(0); expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('桌面视角与深浅主题、390宽度布局可读', async ({ page }, info) => {
|
||||
const calls = await fixture(page); await page.goto('/diagnostics?targetType=desktop&targetId=d1'); await expect(page.getByRole('heading', { name: '办公桌面', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '收集此设备日志' })).toHaveCount(0)
|
||||
await page.screenshot({ path: info.outputPath('diagnostics-dark.png'), fullPage: true, animations: 'disabled' }); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.screenshot({ path: info.outputPath('diagnostics-light.png'), fullPage: true, animations: 'disabled' })
|
||||
await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: info.outputPath('diagnostics-mobile.png'), fullPage: true, animations: 'disabled' }); expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { role?: string; empty?: boolean; ambiguous?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) {
|
||||
const writes: { path: string; body: Record<string, unknown> }[] = [], reads: string[] = []
|
||||
let users = options.empty ? [] : [{ id: 'u1', username: 'zhang', displayName: '张三', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, { id: 'u2', username: 'li', displayName: '李四', enabled: false, terminalId: null, desktopIds: [] as string[] }]
|
||||
const desktops = options.empty ? [] : ['d1', 'd2', ...(options.ambiguous ? ['d3'] : [])].map((id, i) => ({ id, name: options.ambiguous && i === 2 ? '备用桌面' : i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() }))
|
||||
const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }]
|
||||
const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved })
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', ''), method = request.method(), body = request.postData() ? request.postDataJSON() : {}
|
||||
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: Number(url.searchParams.get('page') || 1), pageSize: 10, requestId: 'edition-ui' } })
|
||||
if (method !== 'GET') writes.push({ path, body }); else reads.push(path + url.search)
|
||||
if (options.fail === path && method !== 'GET' || options.fail === 'reads' && path === '/desktops' || options.fail === 'alerts' && path === '/monitor/alerts') { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '当前服务不可用', requestId: 'edition-ui' } }); return }
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: options.id || 'admin', username: 'admin', displayName: '管理员', role: options.role || 'super_admin' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
|
||||
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1, desktopCount: desktops.length, onlineTerminals: 0, terminalCount: terminals.length, runningDesktops: 1, failedTasks: 0 } }); return }
|
||||
if (path === '/desktops') { await list(desktops); return }
|
||||
if (path === '/users' && method === 'POST') { const user = { ...body, id: 'new-user', terminalId: null, desktopIds: [], enabled: true }; users.push(user); await route.fulfill({ status: 201, json: user }); return }
|
||||
if (path === '/users') { await list(users); return }
|
||||
if (path === '/terminals' && method === 'POST') { await route.fulfill({ status: 201, json: { id: 'new-terminal', activationSecret: 'one-time-test-secret' } }); return }
|
||||
if (path === '/terminals') { await list(terminals); return }
|
||||
if (path === '/users/batch-bindings') {
|
||||
users = users.map(u => body.userIds.includes(u.id) ? { ...u, desktopIds: body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds])] : u.desktopIds.filter(id => !body.desktopIds.includes(id)) } : u)
|
||||
await route.fulfill({ json: { affectedUsers: 1 } }); return
|
||||
}
|
||||
if (path.startsWith('/desktops/') && path.endsWith('/actions')) { await route.fulfill({ status: 202, json: task }); return }
|
||||
if (path === '/tasks') { await list(url.searchParams.get('active') ? options.timeout ? [task] : [] : [task]); return }
|
||||
if (path === '/audit-logs') { await list([{ id: 'a1', target: '张三', action: 'batch_grant_bindings', result: 'success', actor: 'admin', createdAt: new Date().toISOString() }], 21); return }
|
||||
if (path === '/monitor/alerts') { await route.fulfill({ json: { items: [], total: 0, firingCount: 0, unacknowledgedCount: 0 } }); return }
|
||||
if (path === '/resources') { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
|
||||
})
|
||||
return { writes, reads }
|
||||
}
|
||||
|
||||
test('默认通用版五个入口,切换高级版保留专业深链和账号偏好', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/workbench$/); await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible(); await expect(page.locator('.simple-stats .panel').first()).toContainText('运行中的桌面1 / 2 台桌面')
|
||||
const nav = page.getByRole('navigation', { name: '主导航' }); await expect(nav.getByRole('link')).toHaveCount(5)
|
||||
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
|
||||
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toBeVisible(); await expect(nav.getByRole('link', { name: '镜像模板' })).toBeVisible()
|
||||
await page.goto('/'); await expect(page).toHaveURL(/\/overview$/)
|
||||
await page.goto('/people'); await expect(page.getByRole('button', { name: '通用版', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.goto('/users'); await expect(page.getByRole('heading', { name: '用户与授权' })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('另一个账号默认通用版,存储不可用时仍可切换', async ({ page }) => {
|
||||
await fixture(page, { id: 'reader' }); await page.addInitScript(() => { localStorage.setItem('vdi.console-mode:another-account', 'advanced'); Storage.prototype.setItem = () => { throw new Error('storage disabled') } })
|
||||
await page.goto('/'); await expect(page).toHaveURL(/\/workbench$/)
|
||||
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
|
||||
})
|
||||
|
||||
test('授权先展示固定对象方案,确认才提交,追加和撤销保留其余授权', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/people')
|
||||
const row = page.getByRole('row').filter({ hasText: '张三' }).first()
|
||||
await row.getByRole('button', { name: '分配桌面', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click()
|
||||
await dialog.getByRole('button', { name: '查看方案' }).click(); await expect(dialog.getByText('确认变更方案')).toBeVisible(); expect(calls.writes).toHaveLength(0)
|
||||
await dialog.getByRole('button', { name: '确认并保存授权' }).dblclick(); await expect(dialog).not.toBeVisible()
|
||||
expect(calls.writes).toEqual([{ path: '/users/batch-bindings', body: { userIds: ['u1'], desktopIds: ['d2'], action: 'grant' } }])
|
||||
await expect(row).toContainText('备用桌面'); await expect(row).toContainText('办公桌面'); await expect(row).toContainText('前台盒子')
|
||||
await row.getByRole('button', { name: '撤销授权' }).click(); const revoke = page.getByRole('dialog', { name: '撤销指定授权' })
|
||||
await revoke.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await revoke.getByRole('button', { name: '查看方案' }).click(); await revoke.getByRole('button', { name: '确认并保存授权' }).click()
|
||||
await expect(revoke).not.toBeVisible(); await expect(row).not.toContainText('备用桌面'); await expect(row).toContainText('办公桌面')
|
||||
expect(calls.writes[1]?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'revoke' })
|
||||
})
|
||||
|
||||
test('授权失败保留方案,不显示成功或自动重发', async ({ page }) => {
|
||||
const calls = await fixture(page, { fail: '/users/batch-bindings' }); await page.goto('/people')
|
||||
await page.getByRole('row').filter({ hasText: '张三' }).first().getByRole('button', { name: '分配桌面' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await dialog.getByRole('button', { name: '查看方案' }).click(); await dialog.getByRole('button', { name: '确认并保存授权' }).click()
|
||||
await expect(page.getByText(/当前服务不可用/)).toBeVisible(); await expect(dialog).toBeVisible(); expect(calls.writes).toHaveLength(1)
|
||||
})
|
||||
|
||||
for (const role of ['viewer', 'operator']) test(`${role}切换版本不提升权限`, async ({ page }) => {
|
||||
const calls = await fixture(page, { role }); await page.goto('/people')
|
||||
await expect(page.getByRole('button', { name: '添加员工' })).toHaveCount(0); await expect(page.getByRole('button', { name: '分配桌面', exact: true })).toHaveCount(0)
|
||||
if (role === 'viewer') await expect(page.getByRole('button', { name: '正常关机', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '高级版', exact: true }).click(); await page.goto('/settings'); await expect(page).toHaveURL(/\/forbidden$/)
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('设备诊断只读,区分离线、等待激活与缺少关联', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/help?terminal=t1')
|
||||
await expect(page.getByText('未收到在线心跳,请检查盒子电源、网络和平台地址。')).toBeVisible(); await expect(page.getByText('使用人:张三')).toBeVisible(); await expect(page.getByText('桌面运行且连接服务就绪', { exact: false })).toBeVisible()
|
||||
await page.goto('/help?terminal=t2'); await expect(page.getByText('设备尚未激活', { exact: false })).toBeVisible(); await expect(page.getByText('请在员工与桌面中关联此设备。')).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('设备登记返回真实接口激活信息,关闭后移除且不持久化', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/devices'); await page.getByRole('button', { name: '登记新设备' }).click()
|
||||
await page.getByLabel('新设备名称').fill('新设备'); await page.getByLabel('新设备 SN').fill('SN-NEW'); await page.getByRole('button', { name: '登记并获取激活信息' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '请保存设备激活信息' }); await expect(dialog.getByLabel('设备激活密钥')).toHaveValue('one-time-test-secret')
|
||||
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await expect(dialog).not.toBeVisible()
|
||||
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('one-time-test-secret')
|
||||
expect(calls.writes[0]).toEqual({ path: '/terminals', body: { name: '新设备', sn: 'SN-NEW', group: '' } })
|
||||
})
|
||||
|
||||
test('电源操作取消不提交,结果未知时阻止重复操作', async ({ page }) => {
|
||||
const calls = await fixture(page, { timeout: true }); await page.goto('/people')
|
||||
const backup = page.getByRole('row').filter({ hasText: '备用桌面' }).last(); await expect(backup.getByRole('button', { name: '开机', exact: true })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); const dialog = page.locator('.el-message-box')
|
||||
await expect(dialog).toContainText('保存文件'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); await dialog.getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect.poll(() => calls.writes.length).toBe(1); expect(calls.writes[0]).toEqual({ path: '/desktops/d1/actions', body: { action: 'reboot' } })
|
||||
})
|
||||
|
||||
test('通用记录查历史审计和任务,服务端分页', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/records'); await expect(page.getByRole('cell', { name: '批量分配桌面' })).toBeVisible()
|
||||
await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.reads.some(p => p.includes('/audit-logs?') && p.includes('page=2'))).toBe(true)
|
||||
await page.getByRole('tab', { name: '设备与桌面任务' }).click(); await expect(page.getByRole('cell', { name: '等待核对' })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('空态、告警失败和平台数据失败有明确提示', async ({ page }) => {
|
||||
await fixture(page, { empty: true, fail: 'alerts' }); await page.goto('/people'); await expect(page.getByText('暂无员工,请先添加员工')).toBeVisible()
|
||||
await page.goto('/help'); await expect(page.getByText(/告警读取失败,不能判断是否正常/)).toBeVisible(); await expect(page.getByText('当前没有触发中的告警')).toHaveCount(0)
|
||||
await page.unroute('**/api/v1/**'); await fixture(page, { fail: 'reads' }); await page.goto('/workbench'); await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible()
|
||||
})
|
||||
|
||||
test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, info) => {
|
||||
await fixture(page); await page.goto('/workbench'); await expect(page.getByRole('heading', { name: '你想完成什么?' })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath('workbench-dark.png'), fullPage: true })
|
||||
await page.getByRole('button', { name: /切换浅色主题/ }).click(); await page.screenshot({ path: info.outputPath('workbench-light.png'), fullPage: true })
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: '打开导航', exact: true }).click(); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '员工与桌面' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true })
|
||||
})
|
||||
|
||||
test('通用工作台固定场景助手生成方案,确认后复用真实授权接口', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('可执行方案', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '分配已有桌面', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.assistant-plan').getByText('备用桌面', { exact: false }).first()).toBeVisible()
|
||||
await page.getByRole('button', { name: '执行方案', exact: true }).click()
|
||||
await page.getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '已执行', exact: true })).toBeVisible()
|
||||
expect(calls.writes.filter(w => w.path === '/users/batch-bindings')).toHaveLength(1)
|
||||
expect(calls.writes.find(w => w.path === '/users/batch-bindings')?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'grant' })
|
||||
})
|
||||
|
||||
test('通用工作台助手对未知或重复授权给出阻止方案,不发写请求', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给不存在的人分配一台桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('没有匹配到员工', { exact: false })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配办公桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('授权已存在', { exact: false })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('通用工作台助手不会在多个同名桌面之间自动选择', async ({ page }) => {
|
||||
const calls = await fixture(page, { ambiguous: true }); await page.goto('/workbench')
|
||||
await page.getByRole('textbox', { name: '描述需求', exact: true }).fill('给张三分配备用桌面')
|
||||
await page.getByRole('button', { name: '生成方案', exact: true }).click()
|
||||
await expect(page.getByText('需要补充信息', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('匹配到多个桌面', { exact: false })).toBeVisible()
|
||||
expect(calls.writes).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
async function fixture(page: Page, options: { role?: string; fail?: boolean; status?: string } = {}) {
|
||||
const writes: string[] = []
|
||||
const task = { id: 'gpu-task', kind: 'gpu-prepare', action: 'prepare-desktop', targetId: 'desk', targetName: '显卡办公桌面', actor: 'device:box', status: options.status || 'timeout', message: '等待核对', providerRef: '{}', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
|
||||
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20 } })
|
||||
if (request.method() !== 'GET') {
|
||||
writes.push(path)
|
||||
if (path !== '/tasks/gpu-task/gpu-reconcile') { await route.abort(); return }
|
||||
if (options.fail) { await route.fulfill({ status: 409, json: { code: 'unknown_submission', message: '未记录原 PVE 任务编号,需要在 PVE 手工核对,不能自动重发', requestId: 'gpu-ui' } }); return }
|
||||
task.status = 'failed'; task.message = '管理员已核对原 PVE 任务及实际电源状态,可重新准备桌面'
|
||||
await route.fulfill({ json: { reconciled: true, vmid: '100' } }); return
|
||||
}
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'admin', displayName: '管理员', role: options.role || 'operator' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
|
||||
if (path === '/overview') { await route.fulfill({ json: {} }); return }
|
||||
if (path === '/tasks') { await list(url.searchParams.get('active') && task.status !== 'timeout' ? [] : [task]); return }
|
||||
if (['/desktops', '/users', '/terminals', '/resources'].includes(path)) { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '未定义接口' } })
|
||||
})
|
||||
return writes
|
||||
}
|
||||
|
||||
test('高级版运维先确认再核对,取消无变更,成功后不再提供核对按钮', async ({ page }) => {
|
||||
const writes = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
|
||||
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click()
|
||||
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不重发电源操作'); await expect(confirm).toContainText('原任务标记为失败')
|
||||
await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(writes).toHaveLength(0)
|
||||
await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click(); await confirm.getByRole('button', { name: '确认提交' }).click()
|
||||
await expect(drawer).not.toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
|
||||
await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click(); await expect(drawer.getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('缺少原任务编号显示后台拒绝,保留未知状态且不自动重发', async ({ page }) => {
|
||||
const writes = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
|
||||
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
|
||||
await expect(page.getByText(/未记录原 PVE 任务编号/)).toBeVisible(); await expect(drawer).toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
|
||||
})
|
||||
|
||||
for (const options of [{ role: 'viewer' }, { role: 'operator', status: 'success' }]) test(`角色${options.role}与状态${options.status || 'timeout'}限制核对`, async ({ page }) => {
|
||||
const writes = await fixture(page, options); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '任务详情' }).getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0); expect(writes).toHaveLength(0)
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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' }])
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { enabled?: boolean; fail?: boolean; loggedOut?: boolean } = {}) {
|
||||
const writes: { path: string; body: Record<string, string> }[] = []
|
||||
let enabled = options.enabled ?? false
|
||||
const codes = Array.from({ length: 8 }, (_, i) => `AAAAAAAA-BBBBBBBB-CCCCCCCC-${String(i).padStart(8, '0')}`)
|
||||
if (!options.loggedOut) await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'mfa-fixture'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', ''), body = request.postData() ? request.postDataJSON() : {}
|
||||
if (request.method() !== 'GET') writes.push({ path, body })
|
||||
const fail = (code: string, message: string, status = 400) => route.fulfill({ status, json: { code, message, requestId: 'mfa-ui' } })
|
||||
if (path === '/auth/login') {
|
||||
if (body.password !== 'Correct-password-2026') { await fail('invalid_credentials', '用户名或密码错误', 401); return }
|
||||
if (!body.code) { await fail('mfa_required', '请输入身份验证器动态码或一次性恢复码', 401); return }
|
||||
if (body.code !== '123456' && body.code !== codes[0]) { await fail('mfa_invalid', '验证码错误、已使用或已过期', 401); return }
|
||||
await route.fulfill({ json: { accessToken: 'logged-in-after-mfa', expiresIn: 3600, user: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } } }); return
|
||||
}
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } }); return }
|
||||
if (path === '/auth/mfa' && request.method() === 'GET') { await route.fulfill({ json: { enabled, recoveryRemaining: enabled ? 8 : 0 } }); return }
|
||||
if (path === '/auth/mfa/setup') {
|
||||
if (body.password !== 'Correct-password-2026') { await fail('current_password_invalid', '当前密码不正确'); return }
|
||||
await route.fulfill({ json: { secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', provisioningUri: 'otpauth://totp/VDI%20Cloud:reader?secret=example', expiresAt: new Date(Date.now() + 600000).toISOString() } }); return
|
||||
}
|
||||
if (path === '/auth/mfa/confirm') {
|
||||
if (options.fail || body.code !== '123456') { await fail('mfa_invalid', '验证码错误或已过期'); return }
|
||||
enabled = true; await route.fulfill({ json: { recoveryCodes: codes } }); return
|
||||
}
|
||||
if (path === '/auth/mfa/recovery') { await route.fulfill({ json: { recoveryCodes: codes } }); return }
|
||||
if (path === '/auth/mfa' && request.method() === 'DELETE') { enabled = false; await route.fulfill({ status: 204 }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['admin-mfa'], pveConfigured: true } }); return }
|
||||
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
|
||||
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 100, requestId: 'mfa-ui' } }); return }
|
||||
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
|
||||
})
|
||||
return { writes, codes }
|
||||
}
|
||||
|
||||
test('只读人员也可绑定本人验证器,确认后才开启,恢复码下载和关闭后不再显示', async ({ page }, info) => {
|
||||
const calls = await fixture(page); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '本人双重验证', exact: true })
|
||||
await expect(dialog.getByText('未开启', { exact: true })).toBeVisible()
|
||||
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026')
|
||||
await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
|
||||
await expect(dialog.getByLabel('身份验证器密钥')).toBeVisible()
|
||||
expect(calls.writes.map(x => x.path)).toEqual(['/auth/mfa/setup'])
|
||||
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('123456'); await dialog.getByRole('button', { name: '验证并开启' }).click()
|
||||
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
|
||||
await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toBeVisible()
|
||||
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
|
||||
await page.screenshot({ path: info.outputPath('mfa-recovery-dark.png'), fullPage: true, animations: 'disabled' })
|
||||
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click()
|
||||
await expect(dialog.getByText('已开启', { exact: true })).toBeVisible(); await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toHaveCount(0)
|
||||
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('JBSWY3')
|
||||
})
|
||||
|
||||
test('验证码错误保持未完成绑定,窄屏和浅色布局可用,关闭清除密钥', async ({ page }, info) => {
|
||||
await fixture(page, { fail: true }); await page.goto('/overview'); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.getByRole('button', { name: '双重验证', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '本人双重验证' })
|
||||
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
|
||||
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('111111'); await dialog.getByRole('button', { name: '验证并开启' }).click()
|
||||
await expect(dialog.getByText(/验证码错误或已过期/)).toBeVisible(); await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||
await page.screenshot({ path: info.outputPath('mfa-setup-mobile-light.png'), fullPage: true, animations: 'disabled' })
|
||||
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click(); await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); await expect(dialog.getByLabel('身份验证器密钥')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('更新恢复码和关闭需要密码及验证码,取消不提交', async ({ page }) => {
|
||||
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
|
||||
await dialog.getByRole('button', { name: '关闭双重验证', exact: true }).click()
|
||||
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill('123456')
|
||||
await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
|
||||
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.writes[0]?.body).toEqual({ password: 'Correct-password-2026', code: '123456' })
|
||||
})
|
||||
|
||||
test('重新生成恢复码经过确认后显示新码并支持下载', async ({ page }) => {
|
||||
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
|
||||
await dialog.getByRole('button', { name: '重新生成恢复码', exact: true }).click()
|
||||
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill(calls.codes[0]!)
|
||||
await dialog.getByRole('button', { name: '生成新恢复码', exact: true }).click(); expect(calls.writes).toHaveLength(0)
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
|
||||
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
|
||||
expect(calls.writes[0]?.path).toBe('/auth/mfa/recovery'); expect(calls.writes[0]?.body.code).toBe(calls.codes[0])
|
||||
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
|
||||
})
|
||||
|
||||
for (const recovery of [false, true]) test(`登录先验证密码,动态码${recovery ? '使用恢复码' : '错误后重试'}成功前不保存令牌`, async ({ page }) => {
|
||||
const calls = await fixture(page, { loggedOut: true }); await page.goto('/login')
|
||||
await page.getByRole('textbox', { name: '用户名', exact: true }).fill('reader'); await page.getByLabel('密码', { exact: true }).fill('incorrect'); await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByText(/用户名或密码错误/)).toBeVisible(); await expect(page.getByRole('textbox', { name: '登录动态码或恢复码' })).toHaveCount(0)
|
||||
await page.getByLabel('密码', { exact: true }).fill('Correct-password-2026'); await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const input = page.getByRole('textbox', { name: '登录动态码或恢复码' }); await expect(input).toBeVisible(); expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull()
|
||||
await input.fill('111111'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page.getByText(/验证码错误、已使用或已过期/)).toBeVisible(); await expect(input).toHaveValue('')
|
||||
await input.fill(recovery ? calls.codes[0]! : '123456'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page).toHaveURL(/\/workbench$/)
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBe('logged-in-after-mfa')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { fail?: boolean; pending?: boolean; slow?: boolean } = {}) {
|
||||
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = [], now = new Date().toISOString()
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'task-history-fixture'))
|
||||
const task = (i: number, extra = {}) => ({ id: `task-${i}`, kind: 'desktop', action: 'start', targetId: `desk-${i}`, targetName: `历史桌面 ${i}`, actor: 'root', status: 'success', message: '真实回执已保存', providerRef: '', createdAt: now, updatedAt: now, ...extra })
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
|
||||
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'task-ui', ...extra } })
|
||||
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送业务写请求' } }); return }
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '任务查看员', role: options.pending ? 'operator' : 'viewer' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['task-history'], pveConfigured: false } }); return }
|
||||
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: options.pending ? 1 : 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
|
||||
if (path === '/tasks/export') { exports.push(url.searchParams); await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff任务标识,状态\r\ntask-1,success\r\n' }); return }
|
||||
if (path === '/tasks') {
|
||||
queries.push(url.searchParams)
|
||||
const p = Number(url.searchParams.get('page') || 1), size = Number(url.searchParams.get('pageSize') || 20), active = url.searchParams.get('active') === 'true'
|
||||
if (active) {
|
||||
const total = options.pending ? 101 : 0
|
||||
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task(2000 + (p - 1) * size + i, { status: 'queued', targetId: (p - 1) * size + i === 100 ? 'desk-pending' : `pending-${i}` })), { total, page: p, pageSize: size }); return
|
||||
}
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
if (options.fail && keyword) { await route.fulfill({ status: 500, json: { code: 'database_error', message: '任务查询暂不可用', requestId: 'task-ui' } }); return }
|
||||
if (options.slow && keyword === 'slow') await new Promise(resolve => setTimeout(resolve, 700))
|
||||
const filtered = !!url.searchParams.get('actor') || !!keyword, total = options.pending ? 1500 : filtered ? 1 : 45
|
||||
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task((p - 1) * size + i + 1, filtered ? { targetName: keyword || '筛选桌面', actor: url.searchParams.get('actor') || 'root' } : {})), { total, page: p, pageSize: size }); return
|
||||
}
|
||||
if (path === '/desktops' && options.pending) { await list([{ id: 'desk-pending', name: '有旧任务的桌面', vmid: 9000, node: 'test', os: 'Ubuntu', pool: '', provider: 'proxmox', powerState: 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.1', width: 1920, height: 1080, fps: 30, updatedAt: now }]); return }
|
||||
if (['/resources', '/desktops', '/users', '/terminals', '/audit-logs'].includes(path)) { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
|
||||
})
|
||||
return { queries, exports, writes }
|
||||
}
|
||||
|
||||
test('任务历史分页、详情和深浅窄屏,历史读取不依赖全局近期任务', async ({ page }, info) => {
|
||||
const calls = await fixture(page); await page.goto('/tasks')
|
||||
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
|
||||
const initialPages = calls.queries.filter(q => q.get('pageSize') === '20')
|
||||
const cutoffBeforePaging = initialPages[initialPages.length - 1]?.get('to')
|
||||
await page.locator('.el-pagination .btn-next').click(); await expect(page.getByRole('button', { name: '历史桌面 21', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '历史桌面 21', exact: true }).click()
|
||||
const drawer = page.getByRole('dialog', { name: '任务详情' })
|
||||
await expect(drawer.getByText('task-21', { exact: true })).toBeVisible()
|
||||
await expect(drawer.getByRole('button', { name: /核对/ })).toHaveCount(0)
|
||||
await drawer.locator('.el-drawer__close-btn').click()
|
||||
await page.screenshot({ path: info.outputPath('task-history-dark.png'), fullPage: true, animations: 'disabled' })
|
||||
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.screenshot({ path: info.outputPath('task-history-mobile.png'), fullPage: true, animations: 'disabled' })
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||
const pages = calls.queries.filter(q => q.get('pageSize') === '20')
|
||||
expect(pages[pages.length - 1]?.get('page')).toBe('2'); expect(pages[pages.length - 1]?.get('to')).toBe(cutoffBeforePaging)
|
||||
expect(calls.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('任务筛选和导出沿用已应用条件,修改草稿后导出不混用', async ({ page }) => {
|
||||
const calls = await fixture(page); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '任务操作者' }).fill('root_%')
|
||||
await page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '任务状态筛选' }) }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
|
||||
await page.getByRole('combobox', { name: '任务动作' }).click(); await page.getByRole('option', { name: '开机', exact: true }).click()
|
||||
await page.getByRole('combobox', { name: '任务类型' }).click(); await page.getByRole('option', { name: '桌面电源', exact: true }).click()
|
||||
await page.getByRole('combobox', { name: '任务时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
|
||||
await page.getByRole('button', { name: '查询任务', exact: true }).click(); await expect(page.getByRole('button', { name: '筛选桌面', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '搜索任务' }).fill('尚未应用')
|
||||
const wait = page.waitForEvent('download'); await page.getByRole('button', { name: '导出任务 CSV' }).click()
|
||||
expect((await wait).suggestedFilename()).toMatch(/执行任务.*\.csv$/)
|
||||
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'root_%', status: 'success', action: 'start', kind: 'desktop', keyword: '' })
|
||||
expect(calls.exports[0]?.get('from')).toBeTruthy(); expect(calls.exports[0]?.get('page')).toBeNull()
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
|
||||
expect(calls.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('查询失败清理旧结果并禁用导出,旧响应不能覆盖新条件', async ({ page }) => {
|
||||
await fixture(page, { fail: true }); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '搜索任务' }).fill('failure'); await page.getByRole('button', { name: '查询任务', exact: true }).click()
|
||||
await expect(page.getByText(/任务查询暂不可用/)).toBeVisible(); await expect(page.getByRole('button', { name: '导出任务 CSV' })).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toHaveCount(0)
|
||||
await page.unrouteAll(); await fixture(page, { slow: true }); await page.reload(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: '搜索任务' }).fill('slow'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
|
||||
await page.getByRole('textbox', { name: '搜索任务' }).fill('new'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
|
||||
await expect(page.getByRole('button', { name: 'new', exact: true })).toBeVisible()
|
||||
await expect.poll(async () => { await page.waitForTimeout(800); return page.getByRole('button', { name: 'slow', exact: true }).count() }).toBe(0)
|
||||
})
|
||||
|
||||
test('全局只取近期一页并分页拉全旧活动任务,旧任务仍阻止操作桌面', async ({ page }) => {
|
||||
const calls = await fixture(page, { pending: true }); await page.goto('/desktops')
|
||||
await expect(page.getByRole('button', { name: '任务执行中', exact: true })).toBeDisabled()
|
||||
expect(calls.queries.some(q => q.get('active') === 'true' && q.get('page') === '2')).toBe(true)
|
||||
expect(calls.queries.some(q => q.get('active') !== 'true' && q.get('page') === '2')).toBe(false)
|
||||
expect(calls.writes).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function fixture(page: Page, options: { role?: string; stale?: boolean; resolved?: boolean; fail?: boolean } = {}) {
|
||||
const writes: { path: string; body: Record<string, unknown> }[] = [], now = new Date().toISOString()
|
||||
let acknowledged = false
|
||||
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'terminal-alert-fixture'))
|
||||
const rules: Record<string, unknown>[] = [{ id: 'temp-rule', name: '终端温度高水位', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true }]
|
||||
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: 100, requestId: 'alert-ui', ...extra } })
|
||||
if (request.method() !== 'GET') {
|
||||
const body = request.postDataJSON(); writes.push({ path, body })
|
||||
if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return }
|
||||
if (path.startsWith('/monitor/rules')) {
|
||||
if (options.fail) { await route.fulfill({ status: 400, json: { message: '温度阈值超出范围', code: 'invalid_request', requestId: 'alert-ui' } }); return }
|
||||
const rule = { ...body, id: path.endsWith('/temp-rule') ? 'temp-rule' : 'created-rule' }
|
||||
if (path.endsWith('/temp-rule')) rules[0] = rule; else rules.push(rule)
|
||||
await route.fulfill({ json: rule }); return
|
||||
}
|
||||
await route.fulfill({ status: 403, json: { message: '不应出现的写操作' } }); return
|
||||
}
|
||||
if (path === '/auth/me') { await route.fulfill({ json: { id: 'alerts-reader', username: 'reader', displayName: '告警人员', role: options.role || 'super_admin' } }); return }
|
||||
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['terminal-metric-alerts'], pveConfigured: false } }); 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 === '/monitor/rules') { await list(rules); return }
|
||||
if (path === '/monitor/alerts') {
|
||||
await list([{ id: 'temp-alert', ruleId: 'temp-rule', ruleName: '终端温度高水位', metric: 'terminal_temperature', targetId: 'box1', targetName: '测试盒子', value: 86.5, threshold: 80, status: options.resolved ? 'resolved' : 'firing', startedAt: now, openedAt: now, resolvedAt: options.resolved ? now : null, resolveReason: options.resolved ? 'condition_cleared' : '', acknowledgedAt: acknowledged ? now : null, acknowledgedBy: acknowledged ? 'reader' : '', dataFresh: !options.stale }], { firingCount: options.resolved ? 0 : 1, unacknowledgedCount: acknowledged || options.resolved ? 0 : 1 }); return
|
||||
}
|
||||
if (['/resources', '/desktops', '/users', '/terminals', '/tasks', '/audit-logs'].includes(path)) { await list([]); return }
|
||||
await route.fulfill({ status: 404, json: { message: '测试接口未定义', requestId: 'alert-ui' } })
|
||||
})
|
||||
return writes
|
||||
}
|
||||
|
||||
test('新增 SoC 温度规则使用摄氏度和独立上限,切换内存使用百分比', async ({ page }, info) => {
|
||||
const writes = await fixture(page); await page.goto('/monitor')
|
||||
await expect(page.getByRole('cell', { name: '86.5 / 80 °C', exact: true })).toBeVisible()
|
||||
await page.getByRole('tab', { name: '告警规则' }).click()
|
||||
await expect(page.getByRole('cell', { name: '80°C / 120 秒', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '新增规则', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新增告警规则' })
|
||||
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端 SoC 温度', exact: true }).click()
|
||||
await expect(dialog.getByText('SoC 温度阈值(°C)', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '150')
|
||||
await expect(dialog.getByText(/重复读取不推进/)).toBeVisible()
|
||||
await dialog.getByRole('textbox', { name: '规则名称' }).fill('盒子过热')
|
||||
await page.screenshot({ path: info.outputPath('terminal-alert-create-dark.png'), fullPage: true, animations: 'disabled' })
|
||||
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(writes[0]?.body).toMatchObject({ name: '盒子过热', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true })
|
||||
await page.getByRole('button', { name: '新增规则', exact: true }).click()
|
||||
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端内存', exact: true }).click()
|
||||
await expect(dialog.getByText('使用率阈值(%)', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '100')
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('过期数据保留告警和最近值,确认不表示恢复', async ({ page }, info) => {
|
||||
const writes = await fixture(page, { role: 'operator', stale: true }); await page.goto('/monitor')
|
||||
await expect(page.getByText('数据已过期或缺失;显示最近告警值,等待有效采样', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '确认告警' }).click()
|
||||
await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '切换浅色主题' }).click()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.screenshot({ path: info.outputPath('terminal-alert-stale-mobile-light.png'), fullPage: true, animations: 'disabled' })
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||
await page.getByRole('tab', { name: '告警规则' }).click()
|
||||
await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0)
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('只读人员可看恢复事件和摄氏度,无确认或规则写入口', async ({ page }) => {
|
||||
const writes = await fixture(page, { role: 'viewer', stale: true, resolved: true }); await page.goto('/monitor')
|
||||
await expect(page.getByRole('cell', { name: '已恢复', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('指标恢复', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/数据已过期或缺失/)).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
|
||||
await page.getByRole('tab', { name: '告警规则' }).click()
|
||||
await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test('保存错误保留表单和数值,取消不改变规则', async ({ page }) => {
|
||||
const writes = await fixture(page, { fail: true }); await page.goto('/monitor')
|
||||
await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '编辑告警规则' })
|
||||
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
|
||||
await expect(page.getByText(/温度阈值超出范围/)).toBeVisible()
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click(); await expect(dialog).toBeHidden()
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
@@ -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 小时')
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const auditActionLabels: Record<string, string> = { export_diagnostics: '导出诊断包', assistant_grant_desktop: '助手分配桌面', assistant_revoke_desktop: '助手撤销桌面', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' }
|
||||
+136
-2
@@ -1,19 +1,73 @@
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths, components } from './schema'
|
||||
|
||||
export type PveConnectionInput = components['schemas']['PveConnectionInput']
|
||||
export type DiagnosticReport = components['schemas']['DiagnosticReport']
|
||||
export type DiagnosticEvent = components['schemas']['DiagnosticEvent']
|
||||
export type DiagnosticQuery = NonNullable<paths['/diagnostics']['get']['parameters']['query']>
|
||||
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 MFAStatus = components['schemas']['MFAStatus']
|
||||
export type MFASetup = components['schemas']['MFASetup']
|
||||
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 AdministratorSession = components['schemas']['AdministratorSession']
|
||||
export type CreateAdministrator = components['schemas']['CreateAdministrator']
|
||||
export type UpdateAdministrator = components['schemas']['UpdateAdministrator']
|
||||
export type Desktop = components['schemas']['Desktop']
|
||||
export type DesktopReadiness = components['schemas']['DesktopReadiness']
|
||||
export type ImageTemplate = components['schemas']['ImageTemplate']
|
||||
export type DesktopPool = components['schemas']['DesktopPool']
|
||||
export type PoolInput = components['schemas']['PoolInput']
|
||||
export type TemplateInput = components['schemas']['TemplateInput']
|
||||
export type TemplateUpdate = components['schemas']['TemplateUpdate']
|
||||
export type CloneInput = components['schemas']['CloneInput']
|
||||
export type Storage = components['schemas']['Storage']
|
||||
export type ConnectionInput = components['schemas']['ConnectionInput']
|
||||
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 GPUExclusiveStatus = components['schemas']['GPUExclusiveStatus']
|
||||
export type TerminalLogArchivePage = components['schemas']['TerminalLogArchivePage']
|
||||
export type Task = components['schemas']['Task']
|
||||
export type Audit = components['schemas']['Audit']
|
||||
export type AuditQuery = NonNullable<paths['/audit-logs']['get']['parameters']['query']>
|
||||
export type TaskQuery = NonNullable<paths['/tasks']['get']['parameters']['query']>
|
||||
export type Overview = components['schemas']['Overview']
|
||||
export type Settings = components['schemas']['Settings']
|
||||
export type BindingInput = components['schemas']['BindingInput']
|
||||
export type ImportInput = components['schemas']['ImportInput']
|
||||
export type EnrollmentInput = components['schemas']['EnrollmentInput']
|
||||
export type DiscoveredVM = components['schemas']['DiscoveredVM']
|
||||
export type AssistantPlanResult = components['schemas']['AssistantPlanResult']
|
||||
export type AssistantExecuteResult = components['schemas']['AssistantExecuteResult']
|
||||
export type DesktopAction = components['schemas']['DesktopAction']['action']
|
||||
export type TerminalAction = components['schemas']['TerminalAction']['action']
|
||||
let accessToken = ''
|
||||
@@ -40,9 +94,62 @@ 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 = {
|
||||
login: (username: string, password: string) => result(client.POST('/auth/login', { body: { username, password } })),
|
||||
diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })),
|
||||
assistantPlan: (text: string) => result(client.POST('/assistant/plan', { body: { text } })),
|
||||
assistantExecute: (runId: string, key = crypto.randomUUID()) => result(client.POST('/assistant/execute', { params: { header: { 'Idempotency-Key': key } }, body: { runId } })),
|
||||
assistantRun: (id: string) => result(client.GET('/assistant/runs/{id}', { params: { path: { id } } })),
|
||||
exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })),
|
||||
collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })),
|
||||
gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })),
|
||||
gpuExclusiveStatus: (node: string) => result(client.GET('/resources/gpu-exclusive', { params: { query: { node } } })),
|
||||
terminalLogArchives: (filters: NonNullable<paths['/terminal-logs']['get']['parameters']['query']> = {}) => result(client.GET('/terminal-logs', { params: { query: filters } })),
|
||||
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 } } })),
|
||||
reconcileGPUPrepare: (id: string) => result(client.POST('/tasks/{id}/gpu-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, code?: string) => result(client.POST('/auth/login', { body: { username, password, ...(code ? { code } : {}) } })),
|
||||
mfaStatus: () => result(client.GET('/auth/mfa')),
|
||||
beginMFA: (password: string) => result(client.POST('/auth/mfa/setup', { body: { password } })),
|
||||
confirmMFA: (code: string) => result(client.POST('/auth/mfa/confirm', { body: { code } })),
|
||||
rotateMFARecovery: (password: string, code: string) => result(client.POST('/auth/mfa/recovery', { body: { password, code } })),
|
||||
disableMFA: (password: string, code: string) => result(client.DELETE('/auth/mfa', { body: { password, code } })),
|
||||
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 } } })),
|
||||
administratorSessions: (id: string, page = 1) => result(client.GET('/administrators/{id}/sessions', { params: { path: { id }, query: { page, pageSize: 20 } } })),
|
||||
revokeAdministratorSession: (id: string, sessionKey: string) => result(client.DELETE('/administrators/{id}/sessions/{sessionKey}', { params: { path: { id, sessionKey } } })),
|
||||
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 } })),
|
||||
@@ -50,13 +157,36 @@ export const api = {
|
||||
terminals: (page = 1) => result(client.GET('/terminals', { params: { query: { ...query, page } } })),
|
||||
users: (page = 1) => result(client.GET('/users', { params: { query: { ...query, page } } })),
|
||||
tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })),
|
||||
activeTasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page, active: true } } })),
|
||||
taskHistory: (filters: TaskQuery = {}) => result(client.GET('/tasks', { params: { query: filters } })),
|
||||
exportTasks: (filters: TaskQuery) => result(client.GET('/tasks/export', { params: { query: filters }, parseAs: 'blob' })),
|
||||
taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })),
|
||||
audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })),
|
||||
reconcileProvision: (id: string) => result(client.POST('/tasks/{id}/reconcile', { params: { path: { id } } })),
|
||||
audits: (filters: AuditQuery = {}) => result(client.GET('/audit-logs', { params: { query: filters } })),
|
||||
exportAudits: (filters: AuditQuery) => result(client.GET('/audit-logs/export', { params: { query: filters }, parseAs: 'blob' })),
|
||||
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, body: TemplateUpdate) => result(client.PATCH('/templates/{id}', { params: { path: { id } }, body })),
|
||||
validateTemplate: (id: string) => result(client.POST('/templates/{id}/validate', { params: { path: { id } } })),
|
||||
pools: (page = 1) => result(client.GET('/pools', { params: { query: { ...query, page } } })),
|
||||
savePool: (id: string, body: PoolInput) => id ? result(client.PUT('/pools/{id}', { params: { path: { id } }, body })) : result(client.POST('/pools', { body })),
|
||||
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 })),
|
||||
checkDesktopReadiness: (id: string) => result(client.POST('/desktops/{id}/readiness/check', { params: { path: { id } } })),
|
||||
sync: () => result(client.POST('/resources/sync', { params: { header: commandHeaders() } })),
|
||||
importDesktop: (body: ImportInput) => result(client.POST('/desktops/import', { body })),
|
||||
desktopAction: (id: string, action: DesktopAction) => result(client.POST('/desktops/{id}/actions', { params: { path: { id }, header: commandHeaders() }, body: { action } })),
|
||||
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 })),
|
||||
@@ -71,4 +201,8 @@ export async function allPages<T>(loader: (page: number) => Promise<{ items: T[]
|
||||
}
|
||||
return items
|
||||
}
|
||||
export async function recentAndActiveTasks(): Promise<Task[]> {
|
||||
const [recent, active] = await Promise.all([api.tasks(), allPages(api.activeTasks)])
|
||||
return [...new Map([...recent.items, ...active].map(task => [task.id, task])).values()]
|
||||
}
|
||||
export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试'
|
||||
Vendored
+10789
-1
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
.simple-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.simple-stats .panel{padding:22px}.simple-stats span{color:var(--vdi-muted);font-size:13px}.simple-stats strong{display:block;font-size:32px;margin-top:14px}.simple-stats small{font-size:12px;color:var(--vdi-muted);font-weight:400}.simple-section{padding:24px;margin-top:22px}.simple-section h2{font-size:18px;margin-bottom:12px}.simple-shortcuts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:22px}.simple-shortcuts a{padding:22px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2)}.simple-shortcuts strong{display:block;color:var(--vdi-accent);font-size:15px}.simple-shortcuts span{display:block;margin-top:12px;font-size:12px;color:var(--vdi-muted);line-height:1.8}.simple-issues{display:grid;gap:18px;margin:22px 0}.simple-issues a{font-size:14px}.simple-check{display:flex;align-items:flex-start;gap:14px;border-top:1px solid var(--vdi-border);padding:18px 0}.simple-check:first-of-type{margin-top:20px}.simple-check p{color:var(--vdi-muted);line-height:1.8;margin:7px 0 0;font-size:13px}.simple-check strong{font-size:14px}.simple-check .el-tag{flex-shrink:0}.simple-checks{display:grid;margin:20px 0;max-height:45vh;overflow:auto}.simple-checks .el-checkbox{white-space:normal;height:auto;min-height:36px}.simple-checks .el-checkbox__label{white-space:normal}.simple-plan{line-height:1.9;padding:16px;border-radius:12px;background:var(--vdi-panel-2);margin-top:16px}.simple-plan ul{padding-left:20px}.simple-row{display:flex;align-items:center;justify-content:space-between;gap:12px}.simple-section>.el-select{max-width:450px;margin-bottom:18px}@media(max-width:1000px){.simple-shortcuts{grid-template-columns:1fr}.simple-stats{gap:10px}.simple-stats .panel{padding:16px}.simple-stats strong{font-size:24px}}@media(max-width:550px){.simple-stats{grid-template-columns:1fr}.simple-stats strong{margin-top:8px}.simple-section{padding:18px}.simple-check{gap:10px}.simple-section h2{font-size:17px}}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { diagnoseTerminal } from './diagnosis'
|
||||
import type { Desktop, Terminal, User } from '../api'
|
||||
const terminal = { id: 't', name: '盒子', status: 'online', enabled: true } as Terminal
|
||||
const user = { id: 'u', displayName: '员工', enabled: true, terminalId: 't', desktopIds: ['d'] } as User
|
||||
const desktop = { id: 'd', name: '桌面', powerState: 'running', serviceState: 'ready', setupRequired: false } as Desktop
|
||||
describe('终端诊断的连接前提', () => {
|
||||
test('运行但服务未检测时不能报告全部通过', () => {
|
||||
const result = diagnoseTerminal(terminal, [user], [{ ...desktop, serviceState: 'unknown' }])
|
||||
expect(result.slice(-1)[0]?.ok).toBe(false)
|
||||
expect(result.slice(-1)[0]?.detail).toContain('不代表一定不可用')
|
||||
})
|
||||
test('已停用设备、员工与待初始化桌面分别说明', () => {
|
||||
const result = diagnoseTerminal({ ...terminal, enabled: false }, [{ ...user, enabled: false }], [{ ...desktop, setupRequired: true }])
|
||||
expect(result.filter(c => !c.ok).map(c => c.title)).toEqual(['设备已启用', '员工已启用', '桌面'])
|
||||
})
|
||||
test('缺少关联时不猜测使用人;失效桌面授权不能报告已分配', () => {
|
||||
expect(diagnoseTerminal(terminal, [], [desktop])).toHaveLength(3)
|
||||
expect(diagnoseTerminal(terminal, [user], []).slice(-1)[0]?.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Desktop, Terminal, User } from '../api'
|
||||
|
||||
export interface Check { title: string; ok: boolean; detail: string }
|
||||
export function diagnoseTerminal(terminal: Terminal, users: User[], desktops: Desktop[]): Check[] {
|
||||
const user = users.find(u => u.terminalId === terminal.id)
|
||||
const assigned = desktops.filter(d => user?.desktopIds.includes(d.id))
|
||||
const checks: Check[] = [
|
||||
{ title: '设备已启用', ok: terminal.enabled !== false && terminal.status !== 'disabled', detail: terminal.enabled === false || terminal.status === 'disabled' ? '设备已停用,请联系管理员重新激活。' : '设备未被停用。' },
|
||||
{ title: '设备在线', ok: terminal.status === 'online', detail: terminal.status === 'online' ? '平台收到设备在线心跳。' : terminal.status === 'unregistered' ? '设备尚未激活,请在盒子上配置登记时的激活信息。' : '未收到在线心跳,请检查盒子电源、网络和平台地址。' },
|
||||
{ title: '已关联员工', ok: !!user, detail: user ? `使用人:${user.displayName}` : '请在员工与桌面中关联此设备。' },
|
||||
]
|
||||
if (!user) return checks
|
||||
checks.push({ title: '员工已启用', ok: user.enabled, detail: user.enabled ? '员工可申请桌面连接。' : '员工已停用,请由管理员核查。' })
|
||||
checks.push({ title: '已分配桌面', ok: assigned.length > 0, detail: assigned.length ? `已分配 ${assigned.length} 台桌面。` : '请为员工分配已有桌面;失效授权需由管理员清理。' })
|
||||
for (const desktop of assigned) {
|
||||
checks.push({ title: desktop.name, ok: desktop.powerState === 'running' && desktop.serviceState === 'ready' && !desktop.setupRequired,
|
||||
detail: desktop.setupRequired ? '桌面仍需完成初始化,请联系运维。' : desktop.powerState !== 'running' ? '桌面尚未运行,请先开机。' : desktop.serviceState !== 'ready' ? '桌面已运行,但平台尚未确认连接服务就绪;不代表一定不可用,请检查连接配置或联系运维。' : '桌面运行且连接服务就绪;实际网络和画面仍需连接后验证。' })
|
||||
}
|
||||
return checks
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<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'
|
||||
import AdministratorSessions from './AdministratorSessions.vue'
|
||||
const auth = useAuth(), { confirm } = useAction()
|
||||
const items = ref<AdministratorAccount[]>([]), loading = ref(false), busy = ref(false), error = ref(''), formError = ref(''), keyword = ref('')
|
||||
const sessionTarget = ref<AdministratorAccount>()
|
||||
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 && !sessionTarget.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="240"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑</el-button><el-button link type="primary" :disabled="busy" @click="sessionTarget = 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>
|
||||
<AdministratorSessions :account="sessionTarget" @close="sessionTarget = undefined" />
|
||||
</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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type AdministratorAccount, type AdministratorSession } from '../api'
|
||||
import { useAction } from '../composables/useAction'
|
||||
const props = defineProps<{ account?: AdministratorAccount }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { confirm } = useAction()
|
||||
const items = ref<AdministratorSession[]>([]), total = ref(0), page = ref(1), loading = ref(false), busy = ref(false), error = ref('')
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '升级前登录,时间未知'
|
||||
async function load() {
|
||||
const account = props.account, request = ++generation
|
||||
if (!account) return
|
||||
loading.value = true; error.value = ''; items.value = []
|
||||
try {
|
||||
const result = await api.administratorSessions(account.id, page.value)
|
||||
if (request !== generation) return
|
||||
total.value = result.total
|
||||
if (page.value > 1 && !result.items.length && result.total <= (page.value - 1) * 20) { page.value = Math.max(1, Math.ceil(result.total / 20)); await load(); return }
|
||||
items.value = result.items
|
||||
} catch (e) { if (request === generation) { error.value = errorMessage(e); total.value = 0 } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
watch(() => props.account, account => { generation++; items.value = []; total.value = 0; page.value = 1; error.value = ''; if (account) void load() }, { immediate: true })
|
||||
async function revoke(session: AdministratorSession) {
|
||||
const account = props.account
|
||||
if (!account || busy.value || session.current) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (!await confirm(`撤销登录 · ${account.username}`, `撤销来自 ${session.clientIp || '未知来源'} 的这次登录?该登录将无法继续请求管理接口,需要重新登录。`)) return
|
||||
await api.revokeAdministratorSession(account.id, session.key)
|
||||
ElMessage.success('已撤销指定登录'); await load()
|
||||
} catch (e) { error.value = errorMessage(e); items.value = []; total.value = 0 }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="!!account" :title="`在线登录 · ${account?.username || ''}`" width="920px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @close="emit('close')">
|
||||
<p class="session-note">显示尚未退出、撤销或到期的登录凭据,不代表浏览器此刻仍在使用。当前登录请通过侧栏退出;撤销其他登录需要确认。来源信息仅供排查。</p>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<div class="session-toolbar"><span>共 {{ total }} 次有效登录</span><el-button :disabled="busy" :loading="loading" @click="load">刷新登录列表</el-button></div>
|
||||
<el-table v-loading="loading" :data="items" empty-text="没有有效登录">
|
||||
<el-table-column label="来源地址" min-width="155"><template #default="{ row }"><span>{{ row.clientIp || '升级前登录,来源未知' }}</span><el-tag v-if="row.current" size="small" type="success" class="current-session">当前登录</el-tag></template></el-table-column>
|
||||
<el-table-column label="登录 / 到期时间" min-width="205"><template #default="{ row }"><div>{{ time(row.createdAt) }}</div><div class="secondary">到期 {{ time(row.expiresAt) }}</div></template></el-table-column>
|
||||
<el-table-column prop="userAgent" label="浏览器 / 客户端" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ row.userAgent || '未知客户端' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button link type="danger" :disabled="row.current || busy || loading" @click="revoke(row)">撤销登录</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page" :disabled="busy || loading" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load" />
|
||||
<template #footer><el-button :disabled="busy" @click="emit('close')">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.session-note{color:var(--vdi-muted);font-size:12px;line-height:1.8;margin-bottom:18px}.session-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:18px 0;font-size:12px;color:var(--vdi-muted)}.current-session{display:block;width:fit-content;margin-top:7px}.el-pagination{margin-top:20px;flex-wrap:wrap}.secondary{margin-top:5px;font-size:11px}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type Audit, type AuditQuery } from '../api'
|
||||
import { auditActionLabels } from '../api/audit-labels'
|
||||
import StatusTag from './StatusTag.vue'
|
||||
|
||||
const rows = ref<Audit[]>([]), total = ref(0), page = ref(1), busy = ref(false), exporting = ref(false), error = ref(''), loaded = ref(false)
|
||||
const keyword = ref(''), actor = ref(''), action = ref(''), result = ref(''), range = ref<[Date, Date] | null>(null)
|
||||
const applied = ref<AuditQuery>({})
|
||||
const shortcuts = [1, 7, 30, 90].map(days => ({ text: days === 1 ? '最近 24 小时' : `最近 ${days} 天`, value: () => { const end = new Date(); return [new Date(end.getTime() - days * 86400000), end] } }))
|
||||
const results: Record<string, string> = { success: '成功', submitted: '已提交', failed: '失败', timeout: '结果未确认', denied: '无权限' }
|
||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
async function load(reset = false) {
|
||||
if (reset) {
|
||||
if (range.value && range.value[1] <= range.value[0]) { ElMessage.warning('结束时间必须晚于开始时间'); return }
|
||||
applied.value = { keyword: keyword.value, actor: actor.value, action: action.value, result: result.value, from: range.value?.[0].toISOString(), to: range.value?.[1].toISOString() || new Date().toISOString() }
|
||||
page.value = 1
|
||||
}
|
||||
const request = ++generation
|
||||
busy.value = true; error.value = ''; loaded.value = false; rows.value = []
|
||||
try {
|
||||
const response = await api.audits({ ...applied.value, page: page.value, pageSize: 20 })
|
||||
if (request !== generation) return
|
||||
rows.value = response.items; total.value = response.total; loaded.value = true
|
||||
} catch (e) { if (request === generation) { error.value = errorMessage(e); total.value = 0 } }
|
||||
finally { if (request === generation) busy.value = false }
|
||||
}
|
||||
function clear() { keyword.value = ''; actor.value = ''; action.value = ''; result.value = ''; range.value = null; void load(true) }
|
||||
async function download() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.exportAudits(applied.value)
|
||||
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||
link.href = url; link.download = `操作审计-${new Date().toISOString().slice(0, 10)}.csv`; link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
ElMessage.success('已生成筛选结果的 CSV 文件')
|
||||
} catch (e) { ElMessage.error(errorMessage(e)) } finally { exporting.value = false }
|
||||
}
|
||||
onMounted(() => { void load(true) })
|
||||
</script>
|
||||
<template>
|
||||
<div class="audit-filters">
|
||||
<el-input v-model="keyword" aria-label="审计关键词" placeholder="对象 / 请求标识 / 动作代码" maxlength="200" clearable @keyup.enter="load(true)" />
|
||||
<el-input v-model="actor" aria-label="审计操作者" placeholder="操作者包含" maxlength="200" clearable @keyup.enter="load(true)" />
|
||||
<el-select v-model="action" aria-label="审计动作" placeholder="全部动作" filterable allow-create clearable><el-option v-for="(label, value) in auditActionLabels" :key="value" :value="value" :label="label" /></el-select>
|
||||
<el-select v-model="result" aria-label="审计结果" placeholder="全部结果" filterable allow-create clearable><el-option v-for="(label, value) in results" :key="value" :value="value" :label="label" /></el-select>
|
||||
<el-date-picker v-model="range" type="datetimerange" aria-label="审计时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" />
|
||||
<div class="audit-actions"><el-button type="primary" :loading="busy" @click="load(true)">查询审计</el-button><el-button :disabled="busy" @click="clear">重置</el-button><el-button :loading="exporting" :disabled="busy || !loaded || !total || total > 10000" @click="download">导出查询结果 CSV</el-button></div>
|
||||
</div>
|
||||
<p class="subtext audit-hint">不选时间可查询全部历史。导出按已查询条件,包含全部匹配页,单次最多 10000 条。CSV 时间为 UTC,动作与结果保留原始代码。</p>
|
||||
<p v-if="loaded && applied.to" class="subtext audit-hint">查询结果截至 {{ time(applied.to) }},更新条件后点击“查询审计”。</p>
|
||||
<el-alert v-if="loaded && total > 10000" title="匹配记录超过 10000 条,请缩小时间范围或增加筛选条件后导出。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-table v-loading="busy" :data="rows" empty-text="没有匹配的审计记录">
|
||||
<el-table-column prop="actor" label="操作者" min-width="110" />
|
||||
<el-table-column label="操作" min-width="150"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}<div class="mono secondary">{{ row.action }}</div></template></el-table-column>
|
||||
<el-table-column prop="target" label="对象" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="结果" width="125"><template #default="{ row }"><StatusTag :value="row.result" /></template></el-table-column>
|
||||
<el-table-column label="时间" min-width="180"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="requestId" label="请求标识" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page" :disabled="busy" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.audit-filters{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.audit-filters>.el-input{width:220px}.audit-filters>.el-select{width:180px}.audit-filters :deep(.el-date-editor){width:390px;flex-grow:0;max-width:100%;box-sizing:border-box}.audit-actions{display:flex;gap:10px;flex-wrap:wrap}.audit-actions .el-button{margin:0}.audit-hint{line-height:1.7;margin:10px 0}.el-alert{margin:12px 0}.el-pagination{margin-top:20px;flex-wrap:wrap}.mono{font-size:11px;margin-top:4px;overflow-wrap:anywhere}
|
||||
@media(max-width:600px){.audit-filters>.el-input,.audit-filters>.el-select{width:100%}.audit-filters :deep(.el-date-editor){width:100%;min-width:0}.audit-actions{width:100%}.audit-actions .el-button:last-child{width:100%}}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type MFAStatus, type MFASetup } from '../api'
|
||||
import { useAction } from '../composables/useAction'
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const { confirm } = useAction()
|
||||
const status = ref<MFAStatus>(), setup = ref<MFASetup>(), recovery = ref<string[]>([]), password = ref(''), code = ref(''), error = ref(''), loading = ref(false), busy = ref(false), action = ref<'disable' | 'rotate'>()
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
watch(() => props.modelValue, open => {
|
||||
generation++; status.value = undefined; setup.value = undefined; recovery.value = []; password.value = ''; code.value = ''; error.value = ''; action.value = undefined
|
||||
if (open) void load()
|
||||
})
|
||||
async function load() {
|
||||
const request = ++generation; loading.value = true; error.value = ''
|
||||
try { const result = await api.mfaStatus(); if (request === generation) status.value = result }
|
||||
catch (e) { if (request === generation) error.value = errorMessage(e) }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function begin() {
|
||||
if (busy.value || !password.value) return
|
||||
busy.value = true; error.value = ''
|
||||
try { setup.value = await api.beginMFA(password.value); password.value = ''; code.value = '' }
|
||||
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
|
||||
}
|
||||
async function enable() {
|
||||
if (busy.value || !/^\d{6}$/.test(code.value)) return
|
||||
busy.value = true; error.value = ''
|
||||
try { recovery.value = (await api.confirmMFA(code.value)).recoveryCodes; setup.value = undefined; code.value = ''; await load(); ElMessage.success('双重验证已开启,其他登录已失效') }
|
||||
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
|
||||
}
|
||||
async function change() {
|
||||
if (busy.value || !action.value || !password.value || !code.value.trim()) return
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const disable = action.value === 'disable'
|
||||
if (!await confirm(disable ? '关闭双重验证' : '重新生成恢复码', disable ? '关闭后登录只需密码,其他登录将失效。确认关闭?' : '现有恢复码将全部作废,其他登录将失效。确认重新生成?')) return
|
||||
if (disable) { await api.disableMFA(password.value, code.value.trim()); ElMessage.success('已关闭双重验证') }
|
||||
else { recovery.value = (await api.rotateMFARecovery(password.value, code.value.trim())).recoveryCodes; ElMessage.success('已更新恢复码,旧恢复码全部作废') }
|
||||
action.value = undefined; password.value = ''; code.value = ''; await load()
|
||||
} catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
|
||||
}
|
||||
function choose(value: 'disable' | 'rotate') { action.value = value; password.value = ''; code.value = ''; error.value = '' }
|
||||
function download() {
|
||||
const blob = new Blob([`VDI Cloud 一次性恢复码\n每条只能使用一次,请离线妥善保存。\n\n${recovery.value.join('\n')}\n`], { type: 'text/plain;charset=utf-8' }), url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||
link.href = url; link.download = 'vdi-cloud-recovery-codes.txt'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" title="本人双重验证" width="560px" :close-on-click-modal="false" :close-on-press-escape="!busy && !recovery.length" :show-close="!busy && !recovery.length" @update:model-value="emit('update:modelValue', $event)">
|
||||
<p class="mfa-note">使用身份验证器生成的 6 位动态码,或一次性恢复码完成登录。仅修改本人账号;开启、关闭或更新恢复码会撤销其他登录。</p>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
|
||||
<div v-loading="loading" class="mfa-body">
|
||||
<template v-if="recovery.length">
|
||||
<el-alert type="warning" title="恢复码仅显示这一次,请先保存" :closable="false" show-icon />
|
||||
<p class="mfa-note">身份验证器不可用时,可用密码和一条恢复码登录。每条只能使用一次;关闭此窗口后无法再次查看。</p>
|
||||
<div class="recovery-codes"><code v-for="value in recovery" :key="value">{{ value }}</code></div>
|
||||
<el-button @click="download">下载恢复码</el-button>
|
||||
</template>
|
||||
<template v-else-if="setup">
|
||||
<p class="mfa-note">在身份验证器中手动添加账号:名称为 VDI Cloud,密钥如下,使用基于时间的 6 位验证码(每 30 秒更新)。</p>
|
||||
<div class="mfa-secret" aria-label="身份验证器密钥">{{ setup.secret }}</div>
|
||||
<p class="mfa-note">绑定有效至 {{ new Date(setup.expiresAt).toLocaleTimeString('zh-CN') }}。输入动态码确认后才会启用。</p>
|
||||
<el-form label-position="top" :disabled="busy" @submit.prevent="enable"><el-form-item label="身份验证器动态码"><el-input v-model="code" aria-label="绑定动态码" autocomplete="one-time-code" inputmode="numeric" maxlength="6" /></el-form-item></el-form>
|
||||
<el-button type="primary" :disabled="!/^\d{6}$/.test(code)" :loading="busy" @click="enable">验证并开启</el-button>
|
||||
</template>
|
||||
<template v-else-if="status">
|
||||
<div class="mfa-state"><strong>当前状态</strong><el-tag :type="status.enabled ? 'success' : 'info'">{{ status.enabled ? '已开启' : '未开启' }}</el-tag></div>
|
||||
<p v-if="status.enabled" class="mfa-note">剩余 {{ status.recoveryRemaining }} 条恢复码。同一动态码只能成功使用一次,刚使用后请等待验证器刷新。</p>
|
||||
<div v-if="status.enabled && !action" class="mfa-actions"><el-button @click="choose('rotate')">重新生成恢复码</el-button><el-button type="danger" plain @click="choose('disable')">关闭双重验证</el-button></div>
|
||||
<el-form v-if="!status.enabled || action" label-position="top" :disabled="busy" @submit.prevent="status.enabled ? change() : begin()">
|
||||
<el-form-item label="当前密码"><el-input v-model="password" type="password" show-password autocomplete="current-password" aria-label="双重验证当前密码" maxlength="200" /></el-form-item>
|
||||
<el-form-item v-if="action" label="动态码或恢复码"><el-input v-model="code" autocomplete="one-time-code" aria-label="双重验证确认码" maxlength="80" /></el-form-item>
|
||||
<el-button v-if="!status.enabled" type="primary" :loading="busy" :disabled="!password" @click="begin">开始绑定身份验证器</el-button>
|
||||
<template v-else><el-button type="primary" :loading="busy" :disabled="!password || !code.trim()" @click="change">{{ action === 'disable' ? '确认关闭双重验证' : '生成新恢复码' }}</el-button><el-button :disabled="busy" @click="action = undefined; password = ''; code = ''">返回</el-button></template>
|
||||
</el-form>
|
||||
</template>
|
||||
<el-button v-else-if="error" @click="load">重新加载验证设置</el-button>
|
||||
</div>
|
||||
<template #footer><el-button :disabled="busy" @click="emit('update:modelValue', false)">{{ recovery.length ? '已保存,关闭' : '关闭' }}</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.mfa-note{font-size:12px;color:var(--vdi-muted);line-height:1.8;margin:14px 0}.mfa-body{min-height:80px;margin-top:18px}.mfa-state{display:flex;justify-content:space-between;align-items:center;margin:18px 0}.mfa-secret{padding:16px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:10px;word-break:break-all;letter-spacing:2px;font-family:monospace;user-select:all}.mfa-actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}.mfa-actions .el-button{margin:0}.recovery-codes{display:grid;gap:10px;margin:18px 0;font-size:12px;overflow-wrap:anywhere}.recovery-codes code{padding:8px 10px;background:var(--vdi-panel-2);border-radius:6px;user-select:all}.mfa-body .el-form{margin-top:20px}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { api, errorMessage, type Task, type TaskQuery } from '../api'
|
||||
import { auditActionLabels as labels } from '../api/audit-labels'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useAction } from '../composables/useAction'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import StatusTag from './StatusTag.vue'
|
||||
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
|
||||
const confirmingGPU = ref(false)
|
||||
const rows = ref<Task[]>([]), total = ref(0), page = ref(1), loading = ref(false), loaded = ref(false), error = ref(''), exporting = ref(false), selected = ref<Task | null>(null)
|
||||
const keyword = ref(''), actor = ref(''), status = ref(''), action = ref(''), kind = ref(''), range = ref<[Date, Date] | null>(null), applied = ref<TaskQuery>({})
|
||||
const statuses: Record<string, string> = { queued: '排队中', running: '执行中', success: '成功', failed: '失败', timeout: '结果未确认' }
|
||||
const kinds: Record<string, string> = { desktop: '桌面电源', terminal: '终端命令', provision: '克隆发放', hardware: '规格调整', snapshot: '快照', 'batch-desktop': '批量电源子任务', 'gpu-prepare': '显卡桌面准备', resource: '资源同步' }
|
||||
const shortcuts = [1, 7, 30, 90].map(days => ({ text: days === 1 ? '最近 24 小时' : `最近 ${days} 天`, value: () => { const end = new Date(); return [new Date(end.getTime() - days * 86400000), end] } }))
|
||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
async function load(reset = false, background = false) {
|
||||
if (reset) {
|
||||
if (range.value && range.value[1] <= range.value[0]) { ElMessage.warning('结束时间必须晚于开始时间'); return }
|
||||
applied.value = { keyword: keyword.value, actor: actor.value, status: status.value, action: action.value, kind: kind.value, from: range.value?.[0].toISOString(), to: range.value?.[1].toISOString() || new Date().toISOString() }
|
||||
page.value = 1
|
||||
}
|
||||
const request = ++generation
|
||||
if (!background) { loading.value = true; rows.value = []; loaded.value = false }
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await api.taskHistory({ ...applied.value, page: page.value, pageSize: 20 })
|
||||
if (request !== generation) return
|
||||
rows.value = response.items; total.value = response.total; loaded.value = true
|
||||
const current = rows.value.find(task => task.id === selected.value?.id)
|
||||
if (current) selected.value = current
|
||||
} catch (e) { if (request === generation) { error.value = errorMessage(e); rows.value = []; total.value = 0; loaded.value = false } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
watch(() => data.updatedAt, () => { if (!loading.value) void load(false, true) })
|
||||
function clear() { keyword.value = ''; actor.value = ''; status.value = ''; action.value = ''; kind.value = ''; range.value = null; void load(true) }
|
||||
async function reconcile(work: () => Promise<unknown>, message: string) { if (await run(work, message)) { selected.value = null; await load() } }
|
||||
async function reconcileGPU() {
|
||||
const task = selected.value
|
||||
if (!task || task.kind !== 'gpu-prepare' || task.status !== 'timeout' || !auth.canOperate || busy.value || confirmingGPU.value) return
|
||||
confirmingGPU.value = true
|
||||
try {
|
||||
if (!await confirm(`核对显卡准备结果 · ${task.targetName}`, '只查询原 PVE 任务及组内桌面的实际电源状态,不重发电源操作。核对完成会结束此未确认流程并释放预留;原任务标记为失败,需要重新准备时请另行操作。')) return
|
||||
await reconcile(() => api.reconcileGPUPrepare(task.id), '已核对原任务并结束未确认流程,未重发电源操作')
|
||||
} finally { confirmingGPU.value = false }
|
||||
}
|
||||
async function downloadDiagnostics(id: string) {
|
||||
try {
|
||||
const diagnostic = await api.taskDiagnostics(id), url = URL.createObjectURL(new Blob([JSON.stringify(diagnostic, null, 2)], { type: 'application/json' }))
|
||||
const link = document.createElement('a'); link.href = url; link.download = `${id}-diagnostics.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (e) { ElMessage.error(errorMessage(e)) }
|
||||
}
|
||||
async function download() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.exportTasks(applied.value), url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||
link.href = url; link.download = `执行任务-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
ElMessage.success('已生成筛选任务的 CSV 文件')
|
||||
} catch (e) { ElMessage.error(errorMessage(e)) } finally { exporting.value = false }
|
||||
}
|
||||
onMounted(() => { void load(true) })
|
||||
</script>
|
||||
<template>
|
||||
<div class="task-filters">
|
||||
<el-input v-model="keyword" aria-label="搜索任务" placeholder="目标 / 任务标识 / 执行结果" maxlength="200" clearable @keyup.enter="load(true)" />
|
||||
<el-input v-model="actor" aria-label="任务操作者" placeholder="操作者包含" maxlength="200" clearable @keyup.enter="load(true)" />
|
||||
<el-select v-model="status" aria-label="任务状态筛选" placeholder="全部任务状态" clearable><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="value" /></el-select>
|
||||
<el-select v-model="action" aria-label="任务动作" placeholder="全部动作" filterable allow-create clearable><el-option v-for="(label, value) in labels" :key="value" :label="label" :value="value" /></el-select>
|
||||
<el-select v-model="kind" aria-label="任务类型" placeholder="全部类型" filterable allow-create clearable><el-option v-for="(label, value) in kinds" :key="value" :label="label" :value="value" /></el-select>
|
||||
<el-date-picker v-model="range" type="datetimerange" aria-label="任务时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" />
|
||||
<div class="task-actions"><el-button type="primary" :loading="loading" @click="load(true)">查询任务</el-button><el-button :disabled="loading" @click="clear">重置</el-button><el-button :loading="exporting" :disabled="loading || !loaded || !total || total > 10000" @click="download">导出任务 CSV</el-button></div>
|
||||
</div>
|
||||
<p class="subtext task-hint">不选时间可查全部历史;按提交时间筛选。任务状态随平台刷新,新增任务请重新查询。导出包含全部匹配页,单次最多 10000 条,时间为 UTC。</p>
|
||||
<p v-if="loaded && applied.to" class="subtext task-hint">查询范围截至 {{ time(applied.to) }},修改条件后点击“查询任务”。</p>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
|
||||
<el-alert v-if="loaded && total > 10000" type="warning" title="匹配任务超过 10000 条,请缩小时间范围或增加筛选条件后导出。" :closable="false" show-icon />
|
||||
<el-table v-loading="loading" :data="rows" empty-text="没有匹配的执行任务">
|
||||
<el-table-column label="操作目标 / 任务" min-width="220"><template #default="{ row }"><el-button link type="primary" @click="selected = row">{{ row.targetName }}</el-button><div class="mono secondary">{{ row.id }}</div></template></el-table-column>
|
||||
<el-table-column label="操作 / 类型" min-width="150"><template #default="{ row }">{{ labels[row.action] || row.action }}<div class="secondary">{{ kinds[row.kind] || row.kind }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag :value="row.status === 'running' ? '执行中' : row.status" /></template></el-table-column>
|
||||
<el-table-column prop="actor" label="操作者" width="120" />
|
||||
<el-table-column label="提交 / 更新时间" min-width="185"><template #default="{ row }">{{ time(row.createdAt) }}<div class="secondary">{{ time(row.updatedAt) }}</div></template></el-table-column>
|
||||
<el-table-column prop="message" label="执行结果" min-width="240" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page" :disabled="loading" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" />
|
||||
<el-drawer :model-value="!!selected" title="任务详情" size="580px" @close="selected = null"><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="reconcile(() => 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="reconcile(() => 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="reconcile(() => 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="reconcile(() => api.reconcileBatchPower(selected!.id), '已核对原电源任务,未重发操作')">核对电源结果</el-button><el-button v-if="selected?.kind === 'gpu-prepare' && selected.status === 'timeout' && auth.canOperate" :loading="busy" :disabled="confirmingGPU" style="margin-top:20px" @click="reconcileGPU">核对显卡准备结果</el-button><p v-if="selected && auth.canOperate && ['terminal', 'desktop', 'gpu-prepare', 'batch-desktop', 'hardware', 'snapshot', 'provision'].includes(selected.kind)" style="margin-top:20px"><router-link :to="{ path: '/diagnostics', query: { targetType: selected.kind === 'terminal' ? 'terminal' : 'desktop', targetId: selected.targetId } }">查看操作对象关联诊断 →</router-link></p><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
|
||||
</template>
|
||||
<style scoped>
|
||||
.task-filters{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.task-filters>.el-input{width:220px}.task-filters>.el-select{width:180px}.task-filters :deep(.el-date-editor){width:390px;flex-grow:0;max-width:100%;box-sizing:border-box}.task-actions{display:flex;gap:10px;flex-wrap:wrap}.task-actions .el-button{margin:0}.task-hint{line-height:1.7;margin:10px 0}.el-alert{margin:12px 0}.el-pagination{margin-top:20px;flex-wrap:wrap}.mono{font-size:11px;overflow-wrap:anywhere;margin-top:4px}
|
||||
@media(max-width:600px){.task-filters>.el-input,.task-filters>.el-select{width:100%}.task-filters :deep(.el-date-editor){width:100%;min-width:0}.task-actions{width:100%}.task-actions .el-button:last-child{width:100%}}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,27 +1,49 @@
|
||||
<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, Lock } from '@element-plus/icons-vue'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useEdition, type Edition } from '../stores/edition'
|
||||
import '../common/common.css'
|
||||
import ThemeToggle from '../components/ThemeToggle.vue'
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog.vue'
|
||||
import MFADialog from '../components/MFADialog.vue'
|
||||
import brandMark from '../assets/brand-mark.svg'
|
||||
const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter()
|
||||
const edition = useEdition()
|
||||
const commonItems = [
|
||||
{ path: '/workbench', title: '工作台', icon: DataAnalysis, group: 'work' },
|
||||
{ path: '/people', title: '员工与桌面', icon: User, group: 'work' },
|
||||
{ path: '/devices', title: '终端设备', icon: Monitor, group: 'work' },
|
||||
{ path: '/help', title: '问题处理', icon: Connection, group: 'work' },
|
||||
{ path: '/records', title: '操作记录', icon: List, group: 'work' },
|
||||
]
|
||||
const items = [
|
||||
{ path: '/overview', title: '云桌面总览', icon: DataAnalysis, group: 'work' },
|
||||
{ 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: '/users', title: '用户与授权', icon: User, 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: 'work' },
|
||||
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
|
||||
{ path: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' },
|
||||
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
|
||||
]
|
||||
const navigation = computed(() => edition.mode === 'common' ? commonItems : items)
|
||||
const groups = computed(() => edition.mode === 'common' ? [{ id: 'work', label: '日常管理' }] : [{ id: 'work', label: '云桌面工作台' }, { id: 'manage', label: '资源与管理' }])
|
||||
async function changeEdition(value: Edition) { edition.select(value); await router.push(edition.home) }
|
||||
const roles: Record<string, string> = { super_admin: '超级管理员', operator: '运维人员', viewer: '只读人员' }
|
||||
const pending = computed(() => data.tasks.filter(t => t.status === 'queued' || t.status === 'running').length)
|
||||
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), mfaVisible = 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) })
|
||||
@@ -32,19 +54,24 @@ async function logout() { try { await auth.logout() } catch { /* Local session i
|
||||
<div class="console-shell">
|
||||
<button v-if="navOpen" class="nav-scrim" aria-label="关闭导航遮罩" @click="navOpen = false" />
|
||||
<aside id="console-navigation" class="sidebar" :class="{ 'is-open': navOpen }">
|
||||
<div class="brand-row"><router-link to="/overview" class="brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></router-link><el-button class="mobile-close" text aria-label="关闭导航" @click="navOpen = false"><el-icon><Close /></el-icon></el-button></div>
|
||||
<div class="brand-row"><router-link :to="edition.home" class="brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></router-link><el-button class="mobile-close" text aria-label="关闭导航" @click="navOpen = false"><el-icon><Close /></el-icon></el-button></div>
|
||||
<div class="organization"><small>云桌面管理平台</small><strong>VDI Cloud · 管理控制台</strong><span><i :class="{ connected: data.settings?.pveConfigured }" />{{ data.settings?.pveConfigured ? 'Proxmox 已接入' : '等待接入计算资源' }}</span></div>
|
||||
<nav aria-label="主导航"><template v-for="group in [{ id: 'work', label: '云桌面工作台' }, { id: 'manage', label: '资源与管理' }]" :key="group.id"><div class="nav-label">{{ group.label }}</div><router-link v-for="item in items.filter(i => i.group === group.id && (i.path !== '/settings' || auth.canAdmin))" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }" :aria-current="route.path === item.path ? 'page' : undefined"><el-icon :size="18"><component :is="item.icon" /></el-icon><span>{{ item.title }}</span><span v-if="item.path === '/tasks' && pending" class="nav-count">{{ pending }}</span></router-link></template></nav>
|
||||
<div class="edition-switch" role="group" aria-label="控制台版本"><button :class="{ selected: edition.mode === 'common' }" :aria-pressed="edition.mode === 'common'" @click="changeEdition('common')">通用版</button><button :class="{ selected: edition.mode === 'advanced' }" :aria-pressed="edition.mode === 'advanced'" @click="changeEdition('advanced')">高级版</button></div>
|
||||
<nav aria-label="主导航"><template v-for="group in groups" :key="group.id"><div class="nav-label">{{ group.label }}</div><router-link v-for="item in navigation.filter(i => i.group === group.id && (i.path !== '/settings' || auth.canAdmin) && (i.path !== '/diagnostics' || auth.canOperate))" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }" :aria-current="route.path === item.path ? 'page' : undefined"><el-icon :size="18"><component :is="item.icon" /></el-icon><span>{{ item.title }}</span><span v-if="item.path === '/tasks' && pending" class="nav-count">{{ pending }}</span></router-link></template></nav>
|
||||
<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><el-button text aria-label="双重验证" title="本人双重验证" @click="mfaVisible = true"><el-icon :size="18"><Lock /></el-icon></el-button><el-button v-if="auth.canAdmin && edition.mode === 'common'" text aria-label="系统设置" title="系统设置" @click="router.push('/settings')"><el-icon :size="18"><Setting /></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="edition.mode === 'common' ? '/records' : '/tasks'">{{ pending ? `${pending} 个任务等待回执` : (edition.mode === 'common' ? '查看操作记录' : '查看任务与审计') }} <el-icon><ArrowRight /></el-icon></router-link></footer>
|
||||
</div>
|
||||
<ChangePasswordDialog v-model="passwordVisible" />
|
||||
<MFADialog v-model="mfaVisible" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.edition-switch{display:flex;gap:4px;background:var(--vdi-panel-2);padding:4px;border:1px solid var(--vdi-border);border-radius:10px}.edition-switch button{flex:1;min-height:34px;border:0;border-radius:7px;color:var(--vdi-muted);background:transparent;cursor:pointer;font:inherit;font-size:12px}.edition-switch button.selected{color:var(--vdi-accent);background:var(--vdi-accent-soft);font-weight:650}
|
||||
|
||||
.console-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--vdi-panel);border-right:1px solid var(--vdi-border);display:flex;flex-direction:column;padding:28px 18px 18px;position:sticky;top:0;height:100vh;overflow-y:auto;z-index:20}.brand-row{display:flex;align-items:center;justify-content:space-between}.brand{display:flex;gap:10px;align-items:center;color:var(--vdi-text);padding:0 10px;font-weight:750;font-size:21px;letter-spacing:1px}.brand img{width:34px;height:34px;object-fit:contain}.brand small{display:block;color:var(--vdi-muted);font-size:9px;font-weight:500;letter-spacing:2.2px;margin-top:5px}.organization{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:16px;padding:15px 14px;margin:26px 0 20px}.organization small{color:var(--vdi-muted);display:block;margin-bottom:8px;font-size:11px}.organization strong{font-size:13px;display:block;font-weight:600}.organization>span{display:flex;align-items:center;gap:6px;color:var(--vdi-muted);font-size:10px;margin-top:12px}.organization i,.environment i{width:6px;height:6px;display:inline-block;background:var(--vdi-muted);border-radius:50%}.organization i.connected{background:var(--vdi-ok)}.nav-label{font-size:10px;letter-spacing:1.4px;color:var(--vdi-muted);margin:18px 14px 12px}nav a{display:flex;align-items:center;gap:12px;padding:13px 15px;color:var(--vdi-muted);border-radius:12px;margin-bottom:6px;font-size:14px;min-height:47px}nav a:hover{background:var(--vdi-panel-2);color:var(--vdi-text)}nav a.active{background:var(--vdi-accent-soft);color:var(--vdi-accent);font-weight:650}.nav-count{margin-left:auto;font-size:11px;background:var(--vdi-brand);color:white;min-width:19px;text-align:center;border-radius:5px;padding:2px}.sidebar-bottom{margin-top:auto;padding-top:32px}.profile{background:var(--vdi-accent-soft);border-radius:16px;padding:12px;display:flex;align-items:center;gap:10px}.avatar{width:36px;height:36px;border-radius:11px;flex-shrink:0;background:linear-gradient(145deg,#f5ca91,#c97235);display:grid;place-items:center;color:#fff;font-weight:650}.profile-name{min-width:0;flex:1}.profile strong{font-size:13px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.profile small{display:block;font-size:11px;color:var(--vdi-muted);margin-top:3px}.profile .el-button{padding:4px;color:var(--vdi-muted)}.sidebar-footnote{display:flex;justify-content:space-between;color:var(--vdi-muted);font-size:10px;margin:18px 4px 0}.workspace{min-width:0;display:flex;flex-direction:column}.topbar{min-height:82px;padding:20px 42px 0;display:flex;justify-content:space-between;align-items:center;gap:16px}.topbar-left,.topbar-right,.breadcrumbs{display:flex;align-items:center;gap:16px}.breadcrumbs{font-size:11px;color:var(--vdi-muted);letter-spacing:.5px;gap:12px}.topbar-right{gap:12px}.environment{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--vdi-muted)}.environment i{background:var(--vdi-ok)}.environment.is-demo i{background:var(--vdi-warn)}.header-refresh{color:var(--vdi-muted)}.page-container{padding:16px 42px 32px;flex:1;min-width:0;width:100%;max-width:1920px;margin:0 auto}.workspace-footer{padding:16px 42px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--vdi-muted)}.workspace-footer a{display:flex;gap:5px;align-items:center}.refresh-dot{width:5px;height:5px;border-radius:50%;background:var(--vdi-ok);display:inline-block;margin-right:6px}.mobile-menu,.mobile-close{display:none}.nav-scrim{display:none}
|
||||
@media(max-width:1200px){.console-shell{grid-template-columns:222px minmax(0,1fr)}.sidebar{padding:25px 14px 16px}.brand{font-size:20px;padding:0 6px}.page-container{padding:18px 26px 26px}.topbar{padding:18px 26px 0}.workspace-footer{padding:16px 26px}.organization{padding:14px 12px}}
|
||||
@media(max-width:760px){.console-shell{grid-template-columns:minmax(0,1fr)}.sidebar{position:fixed;left:0;top:0;bottom:0;width:248px;transform:translateX(-100%);transition:transform .2s ease}.sidebar.is-open{transform:translateX(0)}.mobile-menu,.mobile-close{display:inline-flex}.mobile-menu{padding:8px;width:38px;height:38px;min-height:38px}.mobile-close{color:var(--vdi-muted);padding:5px}.nav-scrim{display:block;position:fixed;inset:0;background:rgb(0 0 0 / 50%);border:0;z-index:19}.page-container{padding:20px 18px}.topbar{padding:16px 18px 0;min-height:68px}.topbar-left,.topbar-right{gap:10px}.breadcrumbs{gap:7px;font-size:10px}.environment{font-size:10px}.header-refresh{display:none}.workspace-footer{padding:16px 18px}.organization{margin-top:25px}}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useEdition } from '../stores/edition'
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes: [
|
||||
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/', component: () => import('../layouts/ConsoleLayout.vue'), children: [
|
||||
{ path: '', redirect: '/overview' },
|
||||
{ path: '', component: () => import('../views/CommonWorkbench.vue') },
|
||||
{ path: 'workbench', component: () => import('../views/CommonWorkbench.vue'), meta: { title: '工作台', edition: 'common' } },
|
||||
{ path: 'people', component: () => import('../views/CommonPeople.vue'), meta: { title: '员工与桌面', edition: 'common' } },
|
||||
{ path: 'devices', component: () => import('../views/CommonDevices.vue'), meta: { title: '终端设备', edition: 'common' } },
|
||||
{ path: 'help', component: () => import('../views/CommonHelp.vue'), meta: { title: '问题处理', edition: 'common' } },
|
||||
{ path: 'records', component: () => import('../views/CommonRecords.vue'), meta: { title: '操作记录', edition: 'common' } },
|
||||
{ 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: 'users', component: () => import('../views/Users.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: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } },
|
||||
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },
|
||||
{ path: 'forbidden', component: () => import('../views/Forbidden.vue'), meta: { title: '无访问权限' } },
|
||||
{ path: ':pathMatch(.*)*', component: () => import('../views/NotFound.vue'), meta: { title: '页面不存在' } },
|
||||
@@ -20,6 +32,11 @@ export const router = createRouter({ history: createWebHistory(), routes: [
|
||||
router.beforeEach(async to => {
|
||||
const auth = useAuth(); await auth.restore()
|
||||
if (!to.meta.public && !auth.user) return { path: '/login', query: { redirect: to.fullPath } }
|
||||
if (to.path === '/login' && auth.user) return '/overview'
|
||||
if (to.path === '/' && auth.user) return useEdition().home
|
||||
if (to.path === '/login' && auth.user) return useEdition().home
|
||||
if (to.meta.adminOnly && !auth.canAdmin) return '/forbidden'
|
||||
if (to.meta.operateOnly && !auth.canOperate) return '/forbidden'
|
||||
if (auth.user && !to.meta.public && !['/settings', '/forbidden'].includes(to.path) && to.matched.some(r => r.path === to.path && !r.path.includes(':'))) {
|
||||
useEdition().select(to.meta.edition === 'common' ? 'common' : 'advanced')
|
||||
}
|
||||
})
|
||||
@@ -12,8 +12,8 @@ export const useAuth = defineStore('auth', () => {
|
||||
const canOperate = computed(() => !!user.value && user.value.role !== 'viewer')
|
||||
const canAdmin = computed(() => user.value?.role === 'super_admin')
|
||||
function clear() { user.value = null; token.value = ''; initialized.value = true; sessionStorage.removeItem(tokenKey); setAccessToken('') }
|
||||
async function login(username: string, password: string) {
|
||||
const data = await api.login(username, password)
|
||||
async function login(username: string, password: string, code?: string) {
|
||||
const data = await api.login(username, password, code)
|
||||
token.value = data.accessToken; user.value = data.user; initialized.value = true
|
||||
setAccessToken(token.value); sessionStorage.setItem(tokenKey, token.value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
export type Edition = 'common' | 'advanced'
|
||||
export const useEdition = defineStore('edition', () => {
|
||||
const auth = useAuth(), mode = ref<Edition>('common')
|
||||
const key = () => `vdi.console-mode:${auth.user?.id || ''}`
|
||||
watch(() => auth.user?.id, id => {
|
||||
mode.value = 'common'
|
||||
if (id) { try { if (localStorage.getItem(key()) === 'advanced') mode.value = 'advanced' } catch { /* Storage can be unavailable. */ } }
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
function select(value: Edition) {
|
||||
mode.value = value
|
||||
if (auth.user) { try { localStorage.setItem(key(), value) } catch { /* Navigation still works without persistence. */ } }
|
||||
}
|
||||
const home = computed(() => mode.value === 'common' ? '/workbench' : '/overview')
|
||||
return { mode, home, select }
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api, allPages, errorMessage, type Desktop, type Terminal, type User, type Task, type Audit, type Resource, type Settings, type Overview } from '../api'
|
||||
import { api, allPages, recentAndActiveTasks, errorMessage, type Desktop, type Terminal, type User, type Task, type Resource, type Settings, type Overview } from '../api'
|
||||
|
||||
export const usePlatform = defineStore('platform', () => {
|
||||
const desktops = ref<Desktop[]>([]), terminals = ref<Terminal[]>([]), users = ref<User[]>([])
|
||||
const tasks = ref<Task[]>([]), audits = ref<Audit[]>([]), resources = ref<Resource[]>([])
|
||||
const tasks = ref<Task[]>([]), resources = ref<Resource[]>([])
|
||||
const settings = ref<Settings | null>(null), overview = ref<Overview | null>(null)
|
||||
const loading = ref(false), error = ref(''), updatedAt = ref('')
|
||||
let inFlight: Promise<void> | undefined
|
||||
@@ -15,15 +15,15 @@ export const usePlatform = defineStore('platform', () => {
|
||||
const startedGeneration = generation
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const [d, t, u, k, a, r, s, o] = await Promise.all([allPages(api.desktops), allPages(api.terminals), allPages(api.users), allPages(api.tasks), allPages(api.audits), api.resources(), api.settings(), api.overview()])
|
||||
const [d, t, u, k, r, s, o] = await Promise.all([allPages(api.desktops), allPages(api.terminals), allPages(api.users), recentAndActiveTasks(), api.resources(), api.settings(), api.overview()])
|
||||
if (startedGeneration !== generation) return
|
||||
desktops.value = d; terminals.value = t; users.value = u; tasks.value = k; audits.value = a; resources.value = r.items; settings.value = s; overview.value = o
|
||||
desktops.value = d; terminals.value = t; users.value = u; tasks.value = k; resources.value = r.items; settings.value = s; overview.value = o
|
||||
updatedAt.value = new Date().toLocaleTimeString('zh-CN'); error.value = ''
|
||||
} catch (e) { if (startedGeneration === generation) error.value = errorMessage(e) }
|
||||
finally { if (startedGeneration === generation) { loading.value = false; inFlight = undefined } }
|
||||
})()
|
||||
return inFlight
|
||||
}
|
||||
function clear() { generation++; inFlight = undefined; loading.value = false; desktops.value = []; terminals.value = []; users.value = []; tasks.value = []; audits.value = []; resources.value = []; settings.value = null; overview.value = null; error.value = ''; updatedAt.value = '' }
|
||||
return { desktops, terminals, users, tasks, audits, resources, settings, overview, loading, error, updatedAt, refresh, clear }
|
||||
function clear() { generation++; inFlight = undefined; loading.value = false; desktops.value = []; terminals.value = []; users.value = []; tasks.value = []; resources.value = []; settings.value = null; overview.value = null; error.value = ''; updatedAt.value = '' }
|
||||
return { desktops, terminals, users, tasks, resources, settings, overview, loading, error, updatedAt, refresh, clear }
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAction } from '../composables/useAction'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
const auth = useAuth(), data = usePlatform(), { busy, run } = useAction()
|
||||
const search = ref(''), page = ref(1), creating = ref(false), secret = ref(''), activationSN = ref('')
|
||||
const form = reactive({ name: '', sn: '' })
|
||||
const filtered = computed(() => data.terminals.filter(t => `${t.name} ${t.sn}`.toLowerCase().includes(search.value.toLowerCase())))
|
||||
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
|
||||
async function enroll() {
|
||||
if (!auth.canAdmin || !form.name.trim() || !form.sn.trim()) return
|
||||
if (await run(async () => { const result = await api.enroll({ name: form.name.trim(), sn: form.sn.trim(), group: '' }); secret.value = result.activationSecret; activationSN.value = form.sn.trim() }, '设备已登记,请在盒子上完成激活')) { creating.value = false; form.name = ''; form.sn = '' }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>终端设备</h1><p class="subtext">查看盒子是否在线,以及当前使用人</p></div><el-button v-if="auth.canAdmin" type="primary" @click="creating = true">登记新设备</el-button></div>
|
||||
<section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找设备" placeholder="设备名称或机身 SN" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无设备,请登记新设备"><el-table-column label="设备" min-width="180"><template #default="{ row }">{{ row.name }}<div class="secondary">{{ row.sn }}</div></template></el-table-column><el-table-column label="在线状态" min-width="105"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column label="使用人" min-width="135"><template #default="{ row }">{{ data.users.find(u => u.terminalId === row.id)?.displayName || '尚未关联' }}</template></el-table-column><el-table-column label="当前桌面" min-width="150"><template #default="{ row }">{{ data.desktops.find(d => d.id === row.sessionDesktopId)?.name || '无活动连接' }}</template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><router-link :to="{ path: '/help', query: { terminal: row.id } }">检查问题</router-link></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section>
|
||||
<p class="subtext">登记后需要在设备上填写激活信息。设备关联、远程重启、收集日志和策略下发位于高级版。</p>
|
||||
<el-dialog v-model="creating" 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="enroll"><el-form-item label="设备名称" required><el-input v-model="form.name" aria-label="新设备名称" maxlength="80" /></el-form-item><el-form-item label="机身 SN" required><el-input v-model="form.sn" aria-label="新设备 SN" maxlength="100" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.name.trim() || !form.sn.trim()" @click="enroll">登记并获取激活信息</el-button></template></el-dialog>
|
||||
<el-dialog :model-value="!!secret" title="请保存设备激活信息" width="500px" :close-on-click-modal="false" @close="secret = ''"><p>设备 SN:{{ activationSN }}</p><p class="subtext">激活密钥仅显示这一次,请由设备管理员填入盒子配置。</p><el-input :model-value="secret" readonly type="password" show-password aria-label="设备激活密钥" /><template #footer><el-button type="primary" @click="secret = ''">已保存,关闭</el-button></template></el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, errorMessage, type AlertRecord } from '../api'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { diagnoseTerminal } from '../common/diagnosis'
|
||||
const data = usePlatform(), route = useRoute(), selected = ref(''), alerts = ref<AlertRecord[]>([]), total = ref(0), loading = ref(false), error = ref('')
|
||||
watch(() => route.query.terminal, id => { selected.value = typeof id === 'string' ? id : '' }, { immediate: true })
|
||||
const terminal = computed(() => data.terminals.find(t => t.id === selected.value))
|
||||
const checks = computed(() => terminal.value ? diagnoseTerminal(terminal.value, data.users, data.desktops) : [])
|
||||
async function load() {
|
||||
if (loading.value) return
|
||||
loading.value = true; error.value = ''
|
||||
try { const result = await api.alerts(1, 'firing'); alerts.value = result.items; total.value = result.total }
|
||||
catch (e) { error.value = errorMessage(e); alerts.value = [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
watch(() => data.updatedAt, load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>问题处理</h1><p class="subtext">先找原因,再决定下一步;检查过程不会修改设备</p></div></div>
|
||||
<section class="panel simple-section"><h2>盒子为什么没有桌面?</h2><el-select v-model="selected" aria-label="选择要检查的设备" placeholder="选择设备" filterable clearable><el-option v-for="t in data.terminals" :key="t.id" :value="t.id" :label="`${t.name} / ${t.sn}`" /></el-select><p v-if="!selected" class="subtext">选择后检查在线状态、员工关联与桌面条件。</p><el-alert v-else-if="!terminal" title="该设备已不存在,请重新选择" type="warning" :closable="false" /><div v-for="(check, i) in checks" :key="i" class="simple-check"><el-tag :type="check.ok ? 'success' : 'warning'">{{ check.ok ? '已通过' : '需处理' }}</el-tag><div><strong>{{ check.title }}</strong><p>{{ check.detail }}</p></div></div><p v-if="terminal" class="subtext">检查依据为最近一次成功刷新;平台数据刷新失败时请先重新加载。全部通过仍不能替代实际连接验收。</p></section>
|
||||
<section class="panel simple-section"><div class="simple-row"><h2>当前告警</h2><el-button :loading="loading" @click="load">刷新告警</el-button></div><el-alert v-if="error" type="error" :closable="false" :title="error" /><el-empty v-else-if="!loading && !alerts.length" description="当前没有触发中的告警" :image-size="64" /><div v-for="alert in alerts" :key="alert.id" class="simple-check"><el-tag type="warning">需关注</el-tag><div><strong>{{ alert.targetName }} · {{ alert.ruleName }}</strong><p>{{ new Date(alert.openedAt).toLocaleString('zh-CN') }} 开始 · {{ alert.acknowledgedAt ? '已有人关注' : '尚未确认' }}</p></div></div><p class="subtext">{{ error ? '告警读取失败,不能判断是否正常。' : `当前 ${total} 条,展示前 20 条。` }}历史、阈值调整和确认操作请进入高级版的监控告警。</p></section>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api, type User, type Desktop, type DesktopAction } from '../api'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useAction } from '../composables/useAction'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
|
||||
const search = ref(''), page = ref(1), creating = ref(false), editing = ref(false), reviewing = ref(false), person = ref<User | null>(null)
|
||||
const profileVisible = ref(false), profileId = ref(''), profileUsername = ref('')
|
||||
const profile = reactive({ displayName: '', enabled: true })
|
||||
const form = reactive({ username: '', displayName: '' }), selected = ref<string[]>([]), action = ref<'grant' | 'revoke'>('grant')
|
||||
const filtered = computed(() => data.users.filter(u => `${u.displayName} ${u.username}`.toLowerCase().includes(search.value.toLowerCase())))
|
||||
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
|
||||
const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || '已失效的桌面'
|
||||
const choices = computed(() => data.desktops.filter(d => action.value === 'grant' ? !person.value?.desktopIds.includes(d.id) : person.value?.desktopIds.includes(d.id)))
|
||||
const desktopSearch = ref(''), desktopPage = ref(1)
|
||||
const desktops = computed(() => data.desktops.filter(d => d.name.toLowerCase().includes(desktopSearch.value.toLowerCase())))
|
||||
const desktopRows = computed(() => desktops.value.slice((desktopPage.value - 1) * 10, desktopPage.value * 10))
|
||||
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running', 'timeout'].includes(t.status))
|
||||
async function power(desktop: Desktop, operation: DesktopAction) {
|
||||
if (!auth.canOperate || pending(desktop.id) || busy.value || data.error) return
|
||||
const label = { start: '开机', shutdown: '正常关机', reboot: '重启桌面' }[operation]
|
||||
if (!await confirm(`${label} · ${desktop.name}`, operation === 'start' ? '将提交开机任务,连接服务仍需稍后就绪。' : '会中断该桌面上的使用,请先通知使用人保存文件。')) return
|
||||
if (pending(desktop.id) || data.error) return
|
||||
await run(() => api.desktopAction(desktop.id, operation), '操作已提交,请在操作记录中查看实际结果')
|
||||
}
|
||||
function open(user: User, operation: 'grant' | 'revoke') { person.value = { ...user, desktopIds: [...user.desktopIds] }; action.value = operation; selected.value = []; reviewing.value = false; editing.value = true }
|
||||
async function create() {
|
||||
if (!auth.canAdmin || !form.username.trim() || !form.displayName.trim()) return
|
||||
if (await run(() => api.createUser({ username: form.username.trim(), displayName: form.displayName.trim() }), '员工已添加,请继续分配桌面')) { creating.value = false; form.username = ''; form.displayName = '' }
|
||||
}
|
||||
function editProfile(user: User) {
|
||||
profileId.value = user.id
|
||||
profileUsername.value = user.username
|
||||
Object.assign(profile, { displayName: user.displayName, enabled: user.enabled })
|
||||
profileVisible.value = true
|
||||
}
|
||||
async function saveProfile() {
|
||||
if (!auth.canAdmin || !profile.displayName.trim() || busy.value) return
|
||||
const existing = data.users.find(user => user.id === profileId.value)
|
||||
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更会作废该员工未消费的连接票据。停用会阻止后续连接,但保留桌面授权和已有串流。')) return
|
||||
if (await run(() => api.updateUser(profileId.value, { displayName: profile.displayName.trim(), enabled: profile.enabled }), '员工资料已保存')) profileVisible.value = false
|
||||
}
|
||||
async function save() {
|
||||
if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return
|
||||
const userId = person.value.id, desktopIds = [...selected.value], operation = action.value
|
||||
if (await run(() => api.batchBindings({ userIds: [userId], desktopIds, action: operation }), operation === 'grant' ? '桌面授权已分配' : '指定桌面授权已撤销')) editing.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="creating = true">添加员工</el-button></div>
|
||||
<section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找员工" placeholder="输入姓名或账号" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无员工,请先添加员工"><el-table-column label="员工" min-width="150"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }} · {{ row.enabled ? '已启用' : '已停用' }}</div></template></el-table-column><el-table-column label="已分配桌面" min-width="210"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }} <StatusTag v-if="data.desktops.find(d => d.id === id)" :value="data.desktops.find(d => d.id === id)!.powerState" /></div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="使用设备" min-width="145"><template #default="{ row }">{{ data.terminals.find(t => t.id === row.terminalId)?.name || '尚未关联设备' }}</template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="270"><template #default="{ row }"><div class="row-actions"><el-button link type="primary" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" @click="open(row, 'grant')">分配桌面</el-button><el-button link :disabled="!row.desktopIds.length" @click="open(row, 'revoke')">撤销授权</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section>
|
||||
<section class="panel simple-section"><h2>桌面日常操作</h2><div class="toolbar"><el-input v-model="desktopSearch" aria-label="查找桌面" placeholder="桌面名称" clearable @input="desktopPage = 1" /></div><el-table :data="desktopRows" empty-text="暂无已有桌面,请由运维纳管"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column label="使用人" min-width="150"><template #default="{ row }">{{ data.users.filter(u => u.desktopIds.includes(row.id)).map(u => u.displayName).join('、') || '尚未分配' }}</template></el-table-column><el-table-column label="状态" min-width="180"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary">{{ row.setupRequired ? '等待初始化' : row.serviceState === 'ready' ? '连接服务就绪' : '连接服务尚未确认就绪' }}</div><el-tag v-if="pending(row.id)" type="warning" size="small">任务未完成,请先核对</el-tag></template></el-table-column><el-table-column v-if="auth.canOperate" label="操作" min-width="200"><template #default="{ row }"><el-button v-if="row.powerState === 'stopped'" link type="primary" :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'start')">开机</el-button><template v-if="row.powerState === 'running'"><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'shutdown')">正常关机</el-button><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'reboot')">重启桌面</el-button></template></template></el-table-column></el-table><el-pagination v-model:current-page="desktopPage" :total="desktops.length" :page-size="10" layout="prev, pager, next" /></section>
|
||||
<p class="subtext">分配表示允许后续连接,不会新建桌面或自动开机。关联设备、批量导入和批量授权等集中管理功能可在高级版中完成。</p>
|
||||
<el-dialog v-model="creating" title="添加员工" width="460px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="create"><el-form-item label="员工姓名" required><el-input v-model="form.displayName" aria-label="员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="form.username" aria-label="员工账号" maxlength="80" /></el-form-item><p class="subtext">此账号用于关联盒子与桌面授权,桌面系统密码单独管理。</p></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.displayName.trim() || !form.username.trim()" @click="create">添加员工</el-button></template></el-dialog>
|
||||
<el-dialog v-model="profileVisible" title="编辑员工资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="员工账号(固定)"><el-input :model-value="profileUsername" disabled readonly /></el-form-item><el-form-item label="姓名" required><el-input v-model="profile.displayName" aria-label="编辑员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑员工启用状态" /></el-form-item><p class="subtext">员工账号用于授权关系,创建后不能修改;停用会阻止后续连接,但保留已有桌面授权。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存员工资料</el-button></template></el-dialog>
|
||||
<el-dialog v-model="editing" :title="action === 'grant' ? '分配已有桌面' : '撤销指定授权'" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><template v-if="person"><h3>{{ person.displayName }} · {{ person.username }}</h3><p v-if="!person.enabled" class="subtext">员工目前已停用,保存授权后仍需管理员启用才能连接。</p><template v-if="!reviewing"><p class="subtext">选择要{{ action === 'grant' ? '分配' : '撤销' }}的桌面:</p><el-checkbox-group v-model="selected" class="simple-checks"><el-checkbox v-for="d in choices" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><el-empty v-if="!choices.length" description="没有符合条件的桌面" :image-size="64" /></template><div v-else class="simple-plan"><h3>确认变更方案</h3><p>为 {{ person.displayName }} {{ action === 'grant' ? '追加分配' : '撤销以下' }} {{ selected.length }} 台桌面的使用权限:</p><ul><li v-for="id in selected" :key="id">{{ desktopName(id) }}</li></ul><p>其他桌面授权、员工状态和设备关联保持原值。已有串流不会被强制断开。</p></div></template><template #footer><el-button :disabled="busy" @click="reviewing ? reviewing = false : editing = false">{{ reviewing ? '返回修改' : '取消' }}</el-button><el-button v-if="!reviewing" type="primary" :disabled="!selected.length" @click="reviewing = true">查看方案</el-button><el-button v-else type="primary" :loading="busy" @click="save">确认并保存授权</el-button></template></el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row-actions{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { api, errorMessage, type Task, type Audit } from '../api'
|
||||
import { auditActionLabels } from '../api/audit-labels'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
const data = usePlatform(), rows = ref<Task[]>([]), page = ref(1), total = ref(0), loading = ref(false), error = ref(''), search = ref(''), keyword = ref('')
|
||||
const tab = ref('changes'), changes = ref<Audit[]>([])
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
async function load(reset = false) {
|
||||
if (reset) { page.value = 1; keyword.value = search.value }
|
||||
const request = ++generation; loading.value = true; error.value = ''
|
||||
try {
|
||||
const filters = { page: page.value, pageSize: 10, keyword: keyword.value }
|
||||
if (tab.value === 'changes') { const result = await api.audits(filters); if (request === generation) { changes.value = result.items; total.value = result.total } }
|
||||
else { const result = await api.taskHistory(filters); if (request === generation) { rows.value = result.items; total.value = result.total } }
|
||||
}
|
||||
catch (e) { if (request === generation) { error.value = errorMessage(e); rows.value = []; changes.value = []; total.value = 0 } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
watch(() => data.updatedAt, () => { void load() })
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>操作记录</h1><p class="subtext">查看员工授权、配置变更与桌面任务结果</p></div></div><section class="panel"><el-tabs v-model="tab" @tab-change="load(true)"><el-tab-pane label="人员与配置变更" name="changes" /><el-tab-pane label="设备与桌面任务" name="tasks" /></el-tabs><div class="toolbar"><el-input v-model="search" aria-label="查找操作记录" placeholder="对象名称或结果" clearable @keyup.enter="load(true)" /><el-button type="primary" :loading="loading" @click="load(true)">查询记录</el-button></div><el-alert v-if="error" type="error" :title="error" :closable="false" /><el-table v-if="tab === 'changes'" v-loading="loading" :data="changes" empty-text="没有匹配的变更记录"><el-table-column prop="target" label="对象" min-width="180" /><el-table-column label="操作" min-width="140"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}</template></el-table-column><el-table-column prop="result" label="记录结果" min-width="210" /><el-table-column prop="actor" label="操作人" min-width="110" /><el-table-column label="时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column></el-table><el-table v-else v-loading="loading" :data="rows" empty-text="没有匹配的任务"><el-table-column prop="targetName" label="对象" min-width="150" /><el-table-column label="操作" min-width="120"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}</template></el-table-column><el-table-column label="结果" min-width="110"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column prop="message" label="说明" min-width="240" /><el-table-column prop="actor" label="操作人" min-width="110" /><el-table-column label="时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column></el-table><el-pagination v-model:current-page="page" :disabled="loading" :total="total" :page-size="10" layout="total, prev, pager, next" @current-change="load()" /></section><p class="subtext">结果未确认不代表失败或成功,应由运维核对。更详细的筛选、结果核对和 CSV 导出位于高级版“任务与审计”。</p>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { api, errorMessage, type AssistantPlanResult } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAction } from '../composables/useAction'
|
||||
const data = usePlatform(), auth = useAuth(), { busy, confirm } = useAction()
|
||||
const running = computed(() => data.desktops.filter(d => d.powerState === 'running').length)
|
||||
const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length)
|
||||
const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length)
|
||||
const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length)
|
||||
const request = ref(''), plan = ref<AssistantPlanResult | null>(null), runningPlan = ref(false), executed = ref(false), assistantError = ref('')
|
||||
async function generatePlan() {
|
||||
const text = request.value.trim()
|
||||
if (!text || runningPlan.value) return
|
||||
runningPlan.value = true; assistantError.value = ''; executed.value = false
|
||||
try { plan.value = await api.assistantPlan(text) } catch (e) { assistantError.value = errorMessage(e) }
|
||||
finally { runningPlan.value = false }
|
||||
}
|
||||
function chooseCandidate(candidate: { label: string }) { request.value = `${request.value.trim()} ${candidate.label}`.trim(); void generatePlan() }
|
||||
async function executePlan() {
|
||||
if (!plan.value || plan.value.plan.kind !== 'action' || !auth.canAdmin || busy.value || executed.value) return
|
||||
if (!await confirm('确认执行方案', plan.value.plan.summary)) return
|
||||
try { await api.assistantExecute(plan.value.runId); await data.refresh(); executed.value = true; ElMessage.success('方案已执行,操作已保存') }
|
||||
catch (e) { ElMessage.error(errorMessage(e)); await generatePlan() }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div>
|
||||
<div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div>
|
||||
<section class="panel simple-section assistant-box"><h2>你想完成什么?</h2><p class="subtext">用一句话描述需求,平台会生成可核对的业务方案。执行前会再次检查权限和最新状态。</p><div class="assistant-input"><el-input v-model="request" aria-label="描述需求" placeholder="例如:给张三分配一台办公桌面" clearable @keyup.enter="generatePlan" /><el-button type="primary" :loading="runningPlan" :disabled="!request.trim()" @click="generatePlan">生成方案</el-button></div><el-alert v-if="assistantError" type="error" :title="assistantError" :closable="false" class="notice" /><article v-if="plan" class="assistant-plan" :class="`is-${plan.plan.kind}`"><div><el-tag :type="plan.plan.kind === 'action' ? 'success' : plan.plan.kind === 'blocked' ? 'warning' : 'info'">{{ plan.plan.kind === 'action' ? '可执行方案' : plan.plan.kind === 'route' ? '查询方案' : '需要补充信息' }}</el-tag><h3>{{ plan.plan.title }}</h3><p>{{ plan.plan.summary }}</p><p v-if="plan.plan.reason" class="secondary">原因:{{ plan.plan.reason }}</p></div><div v-if="plan.plan.candidates.length" class="assistant-candidates"><p class="secondary">请选择准确对象:</p><el-button v-for="candidate in plan.plan.candidates" :key="`${candidate.kind}-${candidate.id}`" size="small" @click="chooseCandidate(candidate)">{{ candidate.label }}</el-button></div><div class="assistant-plan-actions"><el-button v-if="plan.plan.kind === 'action'" type="primary" :loading="busy" :disabled="!auth.canAdmin || executed" @click="executePlan">{{ executed ? '已执行' : auth.canAdmin ? '确认并执行' : '当前角色无权执行' }}</el-button><router-link v-else-if="plan.plan.kind === 'route' && plan.plan.path" :to="plan.plan.path"><el-button>打开处理页面</el-button></router-link></div><dl v-if="plan.plan.kind === 'action'"><div><dt>员工</dt><dd>{{ plan.plan.userName }} · {{ plan.plan.userId }}</dd></div><div><dt>桌面</dt><dd>{{ plan.plan.desktopName }} · VM {{ plan.plan.desktopVmid }}</dd></div><div><dt>执行影响</dt><dd>{{ plan.plan.action === 'grant_desktop' ? '只追加授权,不开机、不建机、不终止已有连接' : '只撤销授权,不关机、不终止已有连接' }}</dd></div><div><dt>方案有效期</dt><dd>{{ new Date(plan.expiresAt).toLocaleTimeString('zh-CN') }}</dd></div></dl></article><div class="simple-shortcuts"><router-link to="/people"><strong>员工与桌面 →</strong><span>添加员工,分配或撤销已有桌面</span></router-link><router-link to="/devices"><strong>查看终端设备 →</strong><span>看在线状态,登记新盒子</span></router-link><router-link to="/help"><strong>桌面打不开 →</strong><span>检查设备、使用人和桌面连接条件</span></router-link></div></section>
|
||||
<section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.assistant-box{border-color:var(--vdi-accent)}.assistant-input{display:flex;gap:10px;margin:18px 0}.assistant-input .el-input{min-width:0}.assistant-plan{margin:18px 0;padding:18px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2);display:grid;gap:16px}.assistant-plan.is-grant{border-color:var(--vdi-ok)}.assistant-plan.is-blocked{border-color:var(--vdi-warn)}.assistant-plan h3{font-size:16px;margin:12px 0 8px}.assistant-plan p{line-height:1.8;font-size:13px}.assistant-plan-actions{display:flex;justify-content:flex-end}.assistant-plan dl{display:grid;gap:8px;margin:0;padding-top:14px;border-top:1px solid var(--vdi-border);font-size:12px}.assistant-plan dl>div{display:flex;gap:18px}.assistant-plan dt{color:var(--vdi-muted);min-width:64px}.assistant-plan dd{margin:0}.assistant-box .simple-shortcuts{margin-top:18px}@media(max-width:600px){.assistant-input{align-items:stretch;flex-direction:column}.assistant-plan-actions{justify-content:stretch}.assistant-plan-actions .el-button,.assistant-plan-actions a{width:100%}.assistant-plan-actions .el-button{width:100%}.assistant-plan dl>div{display:block}.assistant-plan dd{margin-top:5px}}
|
||||
</style>
|
||||
@@ -1,18 +1,41 @@
|
||||
<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 { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type Desktop, type DesktopAction, type DesktopReadiness, type DiscoveredVM, type ImportInput, type ConnectionInput } from '../api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAction } from '../composables/useAction'
|
||||
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 readiness = ref<DesktopReadiness>(), readinessBusy = ref(false)
|
||||
const connection = reactive<ConnectionInput>({ endpoint: '', width: 2560, height: 1440, fps: 30, initialized: false })
|
||||
function openConnection(d: Desktop) { connectionDesktop.value = d; readiness.value = undefined; Object.assign(connection, { endpoint: d.endpoint, width: d.width, height: d.height, fps: d.fps, initialized: false }) }
|
||||
async function saveConnection() { if (connectionDesktop.value && await run(() => api.configureDesktop(connectionDesktop.value!.id, { ...connection }), '连接配置已保存')) connectionDesktop.value = undefined }
|
||||
async function checkReadiness(d: Desktop) {
|
||||
readinessBusy.value = true
|
||||
try { readiness.value = await api.checkDesktopReadiness(d.id); await data.refresh(); ElMessage.success(readiness.value.ready ? '桌面已满足连接条件' : '检查完成,请按结果处理未通过项') }
|
||||
catch (e) { ElMessage.error(errorMessage(e)) }
|
||||
finally { readinessBusy.value = false }
|
||||
}
|
||||
watch(selectedId, () => { readiness.value = undefined })
|
||||
const selected = computed(() => data.desktops.find(d => d.id === selectedId.value))
|
||||
const filtered = computed(() => data.desktops.filter(d => `${d.name} ${d.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === d.powerState)))
|
||||
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
|
||||
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 +49,25 @@ 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><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'desktop', targetId: selected.id } }">查看此桌面关联诊断 →</router-link><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.setupRequired ? '待初始化,暂不能连接' : selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><div class="readiness-box"><div class="readiness-head"><strong>连接就绪检查</strong><el-tag v-if="readiness" :type="readiness.ready ? 'success' : 'warning'">{{ readiness.ready ? '可连接' : '未就绪' }}</el-tag></div><p v-if="!readiness" class="subtext">检查真实电源状态、桌面地址、初始化确认和 Sunshine serverinfo。</p><ul v-else class="readiness-list"><li v-for="check in readiness.checks" :key="check.name"><el-tag size="small" :type="check.status === 'pass' ? 'success' : check.status === 'unknown' ? 'info' : 'danger'">{{ check.status === 'pass' ? '通过' : check.status === 'unknown' ? '待确认' : '未通过' }}</el-tag><span>{{ check.message }}</span></li></ul><el-button v-if="auth.canOperate" :loading="readinessBusy" @click="checkReadiness(selected)">检测连接就绪</el-button></div><p class="subtext" style="margin:20px 0">从模板创建后需完成系统身份、硬件编码、Sunshine 服务与终端配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer>
|
||||
<el-dialog v-model="importVisible" title="纳管已有 VM" width="520px"><el-form label-position="top" @submit.prevent="submitImport"><el-form-item label="选择 VM" required><el-select v-model="vmKey" placeholder="选择节点和虚拟机" @change="chooseVM"><el-option v-for="v in discovered" :key="`${v.node}/${v.vmid}`" :value="`${v.node}/${v.vmid}`" :label="`${v.node} / ${v.vmid} / ${v.name}`" /></el-select></el-form-item><el-form-item label="桌面名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in (['Windows', 'Ubuntu', '麒麟'] as const)" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="桌面连接地址(可后续补充)"><el-input v-model="form.endpoint" placeholder="VM 的 IP 或主机名" /></el-form-item><p class="subtext">纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="importVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.node || !form.name.trim()">确认纳管</el-button></div></el-form></el-dialog>
|
||||
<DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" />
|
||||
<el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.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}
|
||||
.readiness-box{margin:20px 0;padding:16px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2)}.readiness-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.readiness-list{display:grid;gap:9px;margin:14px 0;padding:0;list-style:none}.readiness-list li{display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--vdi-muted);line-height:1.5}.readiness-list .el-tag{flex-shrink:0}
|
||||
.desktop-batch-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 20px;font-size:12px}.desktop-batch-bar .el-button{margin:0}.desktop-select{position:absolute;top:2px;right:8px;z-index:2}.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px}
|
||||
@media(min-width:1700px){.desktop-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
||||
@media(max-width:1200px){.desktop-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@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}}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task, type TerminalLogArchivePage } from '../api'
|
||||
import { auditActionLabels } from '../api/audit-labels'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useAction } from '../composables/useAction'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
const data = usePlatform(), auth = useAuth(), route = useRoute(), { confirm } = useAction()
|
||||
const end = new Date()
|
||||
const form = reactive({ kind: 'terminal' as 'terminal' | 'desktop', id: '', source: '' as NonNullable<DiagnosticQuery['source']>, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null })
|
||||
const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' }
|
||||
const lifecycle: Record<string, string> = { 'session-reserved': '开始连接', 'session-connected': '连接已建立', 'session-ended': '连接结束', 'alert-opened': '告警触发', 'alert-resolved': '告警恢复' }
|
||||
const report = ref<DiagnosticReport | null>(null), archives = ref<TerminalLogArchivePage | null>(null), archiveKeyword = ref(''), archiveLoading = ref(false), applied = ref<DiagnosticQuery | null>(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false)
|
||||
const collection = ref<Task | null>(null), collectionTarget = ref('')
|
||||
const currentCollection = computed(() => data.tasks.find(t => t.id === collection.value?.id) || collection.value)
|
||||
const objects = computed(() => form.kind === 'terminal' ? data.terminals.map(t => ({ id: t.id, label: `${t.name} / ${t.sn}` })) : data.desktops.map(d => ({ id: d.id, label: `${d.name} / VM ${d.vmid}` })))
|
||||
const terminal = computed(() => report.value?.subject.kind === 'terminal' ? data.terminals.find(t => t.id === report.value!.subject.id) : undefined)
|
||||
const pending = computed(() => !!terminal.value && data.tasks.some(t => t.kind === 'terminal' && t.targetId === terminal.value!.id && ['queued', 'running', 'timeout'].includes(t.status)))
|
||||
const canCollect = computed(() => auth.canOperate && report.value?.subject.kind === 'terminal' && report.value.subject.state === 'online' && terminal.value?.status === 'online' && terminal.value?.enabled !== false && !data.error && !pending.value)
|
||||
const shortcuts = [1, 24, 168].map(hours => ({ text: hours === 1 ? '最近 1 小时' : hours === 24 ? '最近 24 小时' : '最近 7 天', value: () => { const to = new Date(); return [new Date(to.getTime() - hours * 3600000), to] } }))
|
||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
let generation = 0, exportGeneration = 0, collectionKey = '', collectionKeyTarget = ''
|
||||
onBeforeUnmount(() => { generation++; exportGeneration++ })
|
||||
watch(() => [form.kind, form.id, form.source, form.range], () => { generation++; exportGeneration++; report.value = null; applied.value = null; error.value = ''; loading.value = false }, { flush: 'sync', deep: true })
|
||||
function changeKind() { form.id = '' }
|
||||
async function load(reset = false) {
|
||||
if (reset) {
|
||||
if (!form.id || !form.range || form.range[1] <= form.range[0] || form.range[1].getTime() - form.range[0].getTime() > 7 * 86400000) { ElMessage.warning('请选择对象和有效时间范围,最长 7 天'); return }
|
||||
applied.value = { targetType: form.kind, targetId: form.id, source: form.source, from: form.range[0].toISOString(), to: form.range[1].toISOString() }; page.value = 1
|
||||
}
|
||||
if (!applied.value) return
|
||||
const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 }
|
||||
loading.value = true; error.value = ''; report.value = null
|
||||
try { const result = await api.diagnostics(filters); if (version === generation) { report.value = result; archives.value = result.subject.kind === 'terminal' ? await api.terminalLogArchives({ terminalId: result.subject.id, from: filters.from, to: filters.to, page: 1, pageSize: 20 }) : null } }
|
||||
catch (e) { if (version === generation) error.value = errorMessage(e) }
|
||||
finally { if (version === generation) loading.value = false }
|
||||
}
|
||||
async function searchArchives() {
|
||||
if (!report.value || report.value.subject.kind !== 'terminal') return
|
||||
archiveLoading.value = true
|
||||
try { archives.value = await api.terminalLogArchives({ terminalId: report.value.subject.id, keyword: archiveKeyword.value || undefined, from: applied.value?.from, to: applied.value?.to, page: 1, pageSize: 20 }) }
|
||||
catch (e) { ElMessage.error(errorMessage(e)) }
|
||||
finally { archiveLoading.value = false }
|
||||
}
|
||||
async function download() {
|
||||
if (!report.value || !applied.value || exporting.value || loading.value) return
|
||||
const filters = { ...applied.value }, version = exportGeneration
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.exportDiagnostics(filters)
|
||||
if (version !== exportGeneration) return
|
||||
const url = URL.createObjectURL(blob), link = document.createElement('a'); link.href = url; link.download = `vdi-diagnostics-${filters.targetType}-${new Date().toISOString().slice(0, 10)}.zip`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
ElMessage.success('诊断包已生成,包含当前筛选的全部匹配事件')
|
||||
} catch (e) { if (version === exportGeneration) ElMessage.error(errorMessage(e)) }
|
||||
finally { exporting.value = false }
|
||||
}
|
||||
async function collect() {
|
||||
if (!canCollect.value || collecting.value || !report.value) return
|
||||
const subject = { ...report.value.subject }; collecting.value = true
|
||||
try {
|
||||
if (!await confirm(`收集终端日志 · ${subject.name}`, '将向此设备提交日志采集命令并等待回执。不会重启设备、断开串流或操作桌面电源;提交成功不代表已取得日志。')) return
|
||||
if (!canCollect.value || report.value?.subject.id !== subject.id) return
|
||||
if (!collectionKey || collectionKeyTarget !== subject.id) { collectionKey = crypto.randomUUID(); collectionKeyTarget = subject.id }
|
||||
const task = await api.collectTerminalLogs(subject.id, collectionKey)
|
||||
collection.value = task; collectionTarget.value = subject.id; collectionKey = ''
|
||||
ElMessage.success('日志采集任务已提交,等待设备回执'); await data.refresh()
|
||||
} catch (e) { ElMessage.error(`${errorMessage(e)};请刷新任务核对,重试同一设备时复用本次请求标识。`) }
|
||||
finally { collecting.value = false }
|
||||
}
|
||||
function showCollection() {
|
||||
form.kind = 'terminal'; form.id = collectionTarget.value; form.source = 'logs'; const to = new Date(); form.range = [new Date(to.getTime() - 3600000), to]; void load(true)
|
||||
}
|
||||
function details(event: DiagnosticEvent): Record<string, unknown> { try { return JSON.parse(event.details) } catch { return {} } }
|
||||
function metadata(event: DiagnosticEvent) { const value = { ...details(event) }; delete value.logTail; return JSON.stringify(value, null, 2) }
|
||||
function applyRoute() {
|
||||
const kind = route.query.targetType, id = route.query.targetId
|
||||
if ((kind === 'terminal' || kind === 'desktop') && typeof id === 'string') { form.kind = kind; form.id = id; void load(true) }
|
||||
}
|
||||
onMounted(applyRoute)
|
||||
watch(() => [route.query.targetType, route.query.targetId], applyRoute)
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>日志与诊断</h1><p class="subtext">按设备或桌面关联日志、会话、告警与操作,追查故障经过</p></div><el-button :loading="exporting" :disabled="!report || loading || report.total > 2000" @click="download">导出诊断包</el-button></div>
|
||||
<section class="panel diagnosis-filters"><el-radio-group v-model="form.kind" aria-label="诊断对象类型" @change="changeKind"><el-radio-button value="terminal">终端设备</el-radio-button><el-radio-button value="desktop">云桌面</el-radio-button></el-radio-group><div class="diagnosis-query"><el-select v-model="form.id" aria-label="诊断对象" :placeholder="form.kind === 'terminal' ? '选择设备名称或 SN' : '选择桌面名称或 VMID'" filterable clearable><el-option v-for="object in objects" :key="object.id" :value="object.id" :label="object.label" /></el-select><el-date-picker v-model="form.range" type="datetimerange" aria-label="诊断时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" /><el-select v-model="form.source" aria-label="诊断记录来源" placeholder="全部记录来源" clearable><el-option v-for="(label, key) in sources" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" :loading="loading" :disabled="!form.id" @click="load(true)">查询诊断</el-button></div><p class="subtext">最长查询 7 天。查询和导出使用相同对象、时间及来源;查询不会自动采集日志或修改设备。</p></section>
|
||||
<section v-if="currentCollection" class="panel diagnosis-collection"><strong>本次日志采集 · {{ currentCollection.targetName }}</strong><StatusTag :value="currentCollection.status" /><p>{{ currentCollection.message }}</p><span class="mono secondary">{{ currentCollection.id }}</span><el-button v-if="currentCollection.status === 'success'" link type="primary" @click="showCollection">查看本次采集</el-button><router-link v-else to="/tasks">查看采集任务 →</router-link></section>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon class="notice" />
|
||||
<section v-if="loading" class="panel diagnosis-empty"><el-skeleton :rows="6" animated /></section>
|
||||
<template v-else-if="report">
|
||||
<section class="panel diagnosis-subject"><div><h2>{{ report.subject.name }}</h2><p class="mono secondary">{{ report.subject.id }}<span v-if="report.subject.node"> · {{ report.subject.node }} / VM {{ report.subject.vmid }}</span></p><p class="secondary">当前快照 {{ time(report.observedAt) }}<span v-if="report.subject.lastSeen"> · 最近心跳 {{ time(report.subject.lastSeen) }}</span></p></div><div class="diagnosis-subject-actions"><StatusTag :value="report.subject.state" /><el-button v-if="report.subject.kind === 'terminal' && auth.canOperate" :loading="collecting" :disabled="!canCollect" @click="collect">收集此设备日志</el-button></div><p v-if="report.subject.kind === 'terminal'" class="diagnosis-footnote">{{ pending ? '该设备有未完成或结果未知的命令,请先到任务中心核对。' : report.subject.state !== 'online' ? '设备当前不在线,恢复在线后再采集新日志。' : '采集需要设备回执;已存在的日志可在下方查看。' }}</p></section>
|
||||
<div class="diagnosis-counts"><div v-for="(label, key) in sources" :key="key" class="panel"><span>{{ label }}</span><strong>{{ report.counts[key] }}</strong></div></div>
|
||||
<section class="panel diagnosis-timeline"><div class="diagnosis-timeline-heading"><div><h2>关联事件</h2><p class="subtext">{{ time(report.from) }} — {{ time(report.to) }} · {{ report.source ? sources[report.source as keyof typeof sources] : '全部来源' }} · 共 {{ report.total }} 条</p></div><el-tag type="info">按事件时间倒序</el-tag></div><el-alert v-if="report.total > 2000" type="warning" title="事件超过 2000 条,请缩小时间范围或选择记录来源后导出" :closable="false" class="notice" /><el-empty v-if="!report.items.length" description="此范围内没有匹配记录;未采集或未上报不代表没有故障" :image-size="90" /><el-timeline v-else><el-timeline-item v-for="event in report.items" :key="event.id" :timestamp="time(event.time)" placement="top"><article class="diagnosis-event"><div class="diagnosis-event-heading"><el-tag size="small" type="info">{{ sources[event.source] }}</el-tag><strong>{{ lifecycle[event.action] || auditActionLabels[event.action] || event.action }}</strong><StatusTag :value="event.status" /></div><p>{{ event.message }}</p><p class="secondary">{{ event.targetName }}<span v-if="event.actor"> · {{ event.actor }}</span></p><el-collapse><el-collapse-item title="查看关联标识与详情" :name="event.id"><pre v-if="typeof details(event).logTail === 'string'" class="diagnosis-log">{{ details(event).logTail || '设备回传的日志片段为空' }}</pre><pre>{{ metadata(event) }}</pre></el-collapse-item></el-collapse></article></el-timeline-item></el-timeline><el-pagination v-model:current-page="page" :disabled="loading" :total="report.total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" /></section>
|
||||
<section v-if="report.subject.kind === 'terminal'" class="panel diagnosis-archives"><div class="diagnosis-timeline-heading"><div><h2>持续日志归档</h2><p class="subtext">终端回传的每次日志快照独立归档,保留 {{ archives?.retentionDays || 30 }} 天,可按关键词检索</p></div><div class="archive-search"><el-input v-model="archiveKeyword" clearable placeholder="搜索日志关键词" @keyup.enter="searchArchives" /><el-button :loading="archiveLoading" @click="searchArchives">检索</el-button></div></div><el-empty v-if="!archives?.items.length" description="没有匹配的归档快照;持续归档从终端回传日志后开始" :image-size="80" /><div v-else class="archive-list"><article v-for="item in archives.items" :key="item.id" class="archive-item"><div class="archive-item-heading"><strong>{{ time(item.capturedAt) }}</strong><el-tag size="small" type="info">{{ item.kioskState || '未知状态' }}</el-tag><span class="secondary">Agent {{ item.agentVersion || '未报告' }} · boot {{ item.bootId || '未报告' }}</span></div><pre>{{ item.logText || '日志为空' }}</pre><p class="secondary mono">任务 {{ item.taskId }}</p></article></div></section>
|
||||
<section class="panel diagnosis-notes"><h3>查询范围说明</h3><p v-for="note in report.notes" :key="note" class="subtext">{{ note }}</p></section>
|
||||
</template>
|
||||
<section v-else-if="!error" class="panel diagnosis-empty"><el-empty description="选择需要排查的设备或桌面,再查询诊断记录" :image-size="110" /></section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.diagnosis-filters{padding:22px}.diagnosis-query{display:grid;grid-template-columns:minmax(190px,1fr) minmax(320px,1.5fr) 160px auto;gap:12px;margin:18px 0}.diagnosis-query :deep(.el-date-editor){width:100%;min-width:0}.diagnosis-subject{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap;margin-top:20px}.diagnosis-subject h2{font-size:19px;margin-bottom:8px}.diagnosis-subject p{line-height:1.8;font-size:12px}.diagnosis-subject-actions{display:flex;align-items:center;gap:14px}.diagnosis-footnote{width:100%;color:var(--vdi-muted)}.diagnosis-counts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:20px 0}.diagnosis-counts .panel{padding:18px;margin:0}.diagnosis-counts span{font-size:12px;color:var(--vdi-muted)}.diagnosis-counts strong{display:block;font-size:26px;margin-top:12px}.diagnosis-timeline-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:26px;flex-wrap:wrap}.diagnosis-timeline h2{font-size:18px}.diagnosis-timeline :deep(.el-timeline){padding-left:4px}.diagnosis-event{border:1px solid var(--vdi-border);border-radius:12px;padding:16px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.diagnosis-event-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.diagnosis-event-heading strong{font-size:14px}.diagnosis-event p{font-size:13px;line-height:1.9;margin-top:9px}.diagnosis-event pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.8;max-height:380px;overflow:auto}.diagnosis-log{padding:16px;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:8px;margin:10px 0}.diagnosis-event :deep(.el-collapse){margin-top:12px;--el-collapse-header-bg-color:transparent;--el-collapse-content-bg-color:transparent}.diagnosis-collection{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diagnosis-collection p{width:100%;font-size:13px}.diagnosis-archives{margin-top:20px}.archive-search{display:flex;gap:8px;min-width:300px}.archive-list{display:grid;gap:12px}.archive-item{border:1px solid var(--vdi-border);border-radius:10px;padding:14px;background:var(--vdi-panel-2)}.archive-item-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.archive-item pre{white-space:pre-wrap;overflow:auto;max-height:260px;margin:12px 0;padding:12px;background:var(--vdi-bg);border-radius:8px;font-size:12px;line-height:1.7}.diagnosis-notes{margin-top:20px}.diagnosis-notes h3{font-size:14px}.diagnosis-notes p{margin-top:12px}.diagnosis-empty{margin-top:20px}@media(max-width:1250px){.diagnosis-query{grid-template-columns:1fr 1fr}.diagnosis-query :deep(.el-date-editor){grid-column:1/-1;grid-row:2}.diagnosis-query>.el-button{grid-column:1/-1}}@media(max-width:600px){.diagnosis-query{grid-template-columns:minmax(0,1fr)}.diagnosis-query :deep(.el-date-editor){height:62px;flex-wrap:wrap;gap:4px}.diagnosis-filters{padding:18px}.diagnosis-counts{grid-template-columns:repeat(2,minmax(0,1fr))}.diagnosis-event{padding:12px}.diagnosis-subject-actions{flex-wrap:wrap}.diagnosis-counts strong{font-size:23px}}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { Cpu, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { api, errorMessage, type GPUExclusiveStatus, 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>(), exclusive = ref<GPUExclusiveStatus>(), 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, group] = await Promise.all([api.gpuInventory(target), api.gpuExclusiveStatus(target)]); if (version === generation) { inventory.value = value; exclusive.value = group; 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('、')
|
||||
const exclusiveLabel = computed(() => !exclusive.value ? '' : exclusive.value.allocationReady ? '可分配' : exclusive.value.conflicts.length ? '需处理冲突' : '当前占用')
|
||||
const memberLabel = (member: GPUExclusiveStatus['members'][number]) => member.eligible ? '可用' : member.conflicts.some(item => item.includes('仍在运行')) ? '占用中' : '冲突'
|
||||
const memberTone = (member: GPUExclusiveStatus['members'][number]) => member.eligible ? 'success' : member.conflicts.some(item => item.includes('仍在运行')) ? 'info' : 'warning'
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>GPU 资源</h1><p class="subtext">节点显卡清单与虚拟机直通配置引用</p></div><el-button :icon="Refresh" :loading="busy" :disabled="!node" @click="load">刷新 GPU 清单</el-button></div>
|
||||
<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">配置引用包含关机虚拟机、模板和待应用变更。健康数据来自 PVE PCI 清单;标准 PVE API 未提供温度、利用率和显存时显示“未采集”。</p></section>
|
||||
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice"><el-button link type="primary" @click="load">重试读取</el-button></el-alert>
|
||||
<template v-if="inventory">
|
||||
<section v-if="exclusive?.enabled" class="panel gpu-exclusive"><div class="gpu-exclusive-heading"><div><h2>独占资源状态</h2><p class="subtext">分配前检查与占用释放条件</p></div><el-tag :type="exclusive.allocationReady ? 'success' : exclusive.conflicts.length ? 'warning' : 'info'">{{ exclusiveLabel }}</el-tag></div><div class="gpu-exclusive-summary"><span>锁 {{ exclusive.lockAvailable ? '可用' : '占用' }}</span><span>成员 {{ exclusive.members.length }}/{{ exclusive.vmids.length }}</span><span>释放 {{ exclusive.releaseReady ? '可检查' : '不可释放' }}</span></div><el-alert v-if="exclusive.conflicts.length" type="warning" :closable="false" title="当前冲突"><ul class="gpu-conflicts"><li v-for="(item, index) in exclusive.conflicts" :key="index">{{ item }}</li></ul></el-alert><el-table :data="exclusive.members" size="small" style="margin-top:14px"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column prop="vmid" label="VMID" width="80" /><el-table-column label="实际电源" width="110"><template #default="scope"><el-tag :type="scope.row.powerObserved ? (scope.row.powerState === 'stopped' ? 'success' : 'warning') : 'info'">{{ scope.row.powerObserved ? scope.row.powerState : '未采集' }}</el-tag></template></el-table-column><el-table-column label="会话 / 任务" width="110"><template #default="scope">{{ scope.row.activeSessions }} / {{ scope.row.activeTasks }}</template></el-table-column><el-table-column label="分配" width="90"><template #default="scope"><el-tag :type="memberTone(scope.row)">{{ memberLabel(scope.row) }}</el-tag></template></el-table-column></el-table><p class="secondary gpu-release-note">释放仅表示当前无会话和未完成任务,平台不会自动关机或重放切换操作;需从终端准备或任务核对流程完成实际变更。</p></section>
|
||||
<div class="gpu-summary"><span>检测显卡 <b>{{ inventory.items.length }}</b></span><span>有配置引用 <b>{{ referencedCount }}</b></span><span>待核查 / 多 VM 引用 <b>{{ uncertainCount }}</b></span><span>已检查 {{ inventory.scannedVmCount }} / {{ inventory.visibleVmCount }} 台可见 VM</span><el-tag :type="stale ? 'warning' : 'info'">{{ stale ? '数据已过期,请刷新' : '读取于 ' + new Date(inventory.observedAt).toLocaleTimeString('zh-CN') }}</el-tag></div>
|
||||
<section v-if="inventory.issues.length" class="panel gpu-issues"><h2>检查未完整</h2><p class="subtext">以下情况影响引用检查范围,未发现引用的显卡仍需核查。</p><ul><li v-for="(issue, index) in inventory.issues" :key="index"><span v-if="issue.vmid">VM {{ issue.vmid }}:</span>{{ issue.message }}</li></ul></section>
|
||||
<div v-if="items.length" class="gpu-grid"><article v-for="gpu in items" :key="gpu.id" class="panel gpu-card"><div class="gpu-card-top"><div class="gpu-symbol"><el-icon :size="26"><Cpu /></el-icon></div><el-tag :type="tones[gpu.state]">{{ labels[gpu.state] }}</el-tag></div><p class="eyebrow">{{ gpu.vendor || gpu.vendorId }}</p><h2>{{ gpu.name || '显卡 ' + gpu.deviceId }}</h2><dl><div><dt>PCI 地址</dt><dd class="mono">{{ gpu.address }}</dd></div><div><dt>节点</dt><dd>{{ inventory.node }}</dd></div><div><dt>IOMMU 分组</dt><dd>{{ gpu.iommuGroup === null ? '未报告' : gpu.iommuGroup }}</dd></div><div><dt>驱动绑定</dt><dd><el-tag size="small" :type="gpu.health.driverBinding === 'bound' ? 'success' : 'info'">{{ gpu.health.driverBinding === 'bound' ? gpu.health.driver || '已绑定' : '未采集' }}</el-tag></dd></div><div><dt>配置引用</dt><dd>{{ owners(gpu) || (gpu.state === 'unknown' ? '检查范围不完整' : '未发现') }}</dd></div></dl><div class="gpu-card-footer"><span class="secondary">温度 / 利用率 / 显存:{{ gpu.health.status === 'not_collected' ? '未采集' : '部分采集' }}</span><el-button link type="primary" @click="selected = gpu">查看 GPU 详情</el-button></div></article></div>
|
||||
<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-item label="驱动绑定">{{ selected.health.driverBinding === 'bound' ? selected.health.driver : '未采集' }}</el-descriptions-item><el-descriptions-item label="温度 / 利用率 / 显存">未采集(标准 PVE PCI 接口不提供运行指标)</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">驱动字段由 PVE PCI 清单提供;温度、利用率和显存需后续接入节点采集代理。</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}}
|
||||
.gpu-exclusive{margin:20px 0}.gpu-exclusive-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.gpu-exclusive-heading h2{font-size:17px}.gpu-exclusive-summary{display:flex;gap:18px;flex-wrap:wrap;color:var(--vdi-muted);font-size:12px;margin:16px 0}.gpu-conflicts{padding-left:20px;line-height:1.8}.gpu-release-note{margin-top:14px}</style>
|
||||
@@ -1,24 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Monitor, Connection, Lock, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { errorMessage } from '../api'
|
||||
import { ApiError, errorMessage } from '../api'
|
||||
import ThemeToggle from '../components/ThemeToggle.vue'
|
||||
import brandMark from '../assets/brand-mark.svg'
|
||||
const auth = useAuth(), router = useRouter(), route = useRoute()
|
||||
const form = reactive({ username: '', password: '' }), busy = ref(false), error = ref('')
|
||||
const form = reactive({ username: '', password: '', code: '' }), busy = ref(false), error = ref(''), mfaRequired = ref(false)
|
||||
watch(() => form.username, () => { mfaRequired.value = false; form.code = ''; error.value = '' })
|
||||
const demo = import.meta.env.VITE_API_MODE === 'mock' || import.meta.env.VITE_DEMO_HINT === 'true'
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.password) { error.value = '请输入用户名和密码'; return }
|
||||
busy.value = true; error.value = ''
|
||||
try { await auth.login(form.username.trim(), form.password); const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') && !route.query.redirect.startsWith('//') ? route.query.redirect : '/overview'; await router.replace(redirect) }
|
||||
catch (e) { error.value = errorMessage(e) }
|
||||
try { await auth.login(form.username.trim(), form.password, form.code.trim()); form.password = ''; form.code = ''; const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') && !route.query.redirect.startsWith('//') ? route.query.redirect : '/'; await router.replace(redirect) }
|
||||
catch (e) { if (e instanceof ApiError && (e.code === 'mfa_required' || e.code === 'mfa_invalid')) { mfaRequired.value = true; form.code = '' }; error.value = errorMessage(e) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="login-page"><header class="login-header"><a href="/" class="login-brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></a><ThemeToggle /></header><div class="login-main"><section class="login-intro"><div class="eyebrow">VDI CLOUD / MANAGEMENT PLATFORM</div><h1>让每一台桌面<br>有序连接<span>。</span></h1><p>从桌面资源到终端运维,<br>让云端的每一次连接,尽在掌握。</p><div class="intro-capabilities"><span><el-icon><Monitor /></el-icon>桌面管理</span><span><el-icon><Connection /></el-icon>终端接入</span><span><el-icon><Lock /></el-icon>安全授权</span></div><div class="login-art" aria-hidden="true"><div class="art-orbit orbit-one" /><div class="art-orbit orbit-two" /><div class="art-screen screen-back"><div class="art-dots"><i /><i /><i /></div><div class="art-rows"><b /><b /><b /></div></div><div class="art-screen screen-front"><div class="art-dots"><i /><i /><i /></div><div class="art-logo"><img :src="brandMark" alt="" /><div>VDI CLOUD<small>连接工作新方式</small></div></div><div class="art-progress"><i /></div></div><div class="art-badge"><i />云 · 管 · 端</div></div></section><section class="login-form-area"><div class="login-card"><div class="section-kicker">WELCOME BACK</div><h2>登录管理控制台</h2><p class="subtext">使用平台管理员账号,开启工作。</p><el-alert v-if="error" class="notice" style="margin-top:24px" type="error" :title="error" :closable="false" show-icon /><el-form label-position="top" @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" name="username" autocomplete="username" size="large" placeholder="请输入用户名" :disabled="busy" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" name="password" type="password" autocomplete="current-password" size="large" show-password placeholder="请输入密码" :disabled="busy" /></el-form-item><el-button type="primary" size="large" native-type="submit" :loading="busy" class="login-submit">登录<el-icon><ArrowRight /></el-icon></el-button></el-form><p class="login-security"><el-icon><Lock /></el-icon>仅限已授权的平台管理人员访问</p><p v-if="demo" class="demo-hint">演示账号:admin / operator / viewer<br>演示密码:Demo@2026<br><span>仅用于演示模式,正式环境使用独立账号。</span></p></div></section></div><footer class="login-footer"><span>百工万象 · VDI Cloud</span><span>云端服务 / 管理控制台 / 云终端</span></footer></main>
|
||||
<main class="login-page"><header class="login-header"><a href="/" class="login-brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></a><ThemeToggle /></header><div class="login-main"><section class="login-intro"><div class="eyebrow">VDI CLOUD / MANAGEMENT PLATFORM</div><h1>让每一台桌面<br>有序连接<span>。</span></h1><p>从桌面资源到终端运维,<br>让云端的每一次连接,尽在掌握。</p><div class="intro-capabilities"><span><el-icon><Monitor /></el-icon>桌面管理</span><span><el-icon><Connection /></el-icon>终端接入</span><span><el-icon><Lock /></el-icon>安全授权</span></div><div class="login-art" aria-hidden="true"><div class="art-orbit orbit-one" /><div class="art-orbit orbit-two" /><div class="art-screen screen-back"><div class="art-dots"><i /><i /><i /></div><div class="art-rows"><b /><b /><b /></div></div><div class="art-screen screen-front"><div class="art-dots"><i /><i /><i /></div><div class="art-logo"><img :src="brandMark" alt="" /><div>VDI CLOUD<small>连接工作新方式</small></div></div><div class="art-progress"><i /></div></div><div class="art-badge"><i />云 · 管 · 端</div></div></section><section class="login-form-area"><div class="login-card"><div class="section-kicker">WELCOME BACK</div><h2>登录管理控制台</h2><p class="subtext">使用平台管理员账号,开启工作。</p><el-alert v-if="error" class="notice" style="margin-top:24px" type="error" :title="error" :closable="false" show-icon /><el-form label-position="top" @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" name="username" autocomplete="username" size="large" placeholder="请输入用户名" :disabled="busy" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" name="password" type="password" autocomplete="current-password" size="large" show-password placeholder="请输入密码" :disabled="busy" /></el-form-item><el-form-item v-if="mfaRequired" label="动态码或恢复码"><el-input v-model="form.code" name="code" aria-label="登录动态码或恢复码" autocomplete="one-time-code" size="large" maxlength="80" placeholder="输入身份验证器动态码或恢复码" :disabled="busy" /></el-form-item><el-button type="primary" size="large" native-type="submit" :loading="busy" class="login-submit">登录<el-icon><ArrowRight /></el-icon></el-button></el-form><p class="login-security"><el-icon><Lock /></el-icon>仅限已授权的平台管理人员访问</p><p v-if="demo" class="demo-hint">演示账号:admin / operator / viewer<br>演示密码:Demo@2026<br><span>仅用于演示模式,正式环境使用独立账号。</span></p></div></section></div><footer class="login-footer"><span>百工万象 · VDI Cloud</span><span>云端服务 / 管理控制台 / 云终端</span></footer></main>
|
||||
</template>
|
||||
<style scoped>
|
||||
.login-page{min-height:100vh;display:flex;flex-direction:column;background:radial-gradient(ellipse at 8% 60%,var(--vdi-accent-soft),transparent 53%),var(--vdi-bg)}.login-header{padding:30px 5%;display:flex;justify-content:space-between;align-items:center}.login-brand{display:flex;align-items:center;gap:11px;color:var(--vdi-text);font-size:23px;font-weight:750;letter-spacing:1px}.login-brand img{width:36px;height:36px}.login-brand small{display:block;color:var(--vdi-muted);font-size:9px;letter-spacing:2.7px;font-weight:500;margin-top:4px}.login-main{flex:1;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:8%;width:min(1160px,88%);margin:35px auto}.login-intro{position:relative;padding-top:12px}.eyebrow{font-size:11px;letter-spacing:2px;margin-bottom:24px}.login-intro h1{font-size:48px;line-height:1.45;letter-spacing:1px;font-weight:750}.login-intro h1 span{color:var(--vdi-brand)}.login-intro>p{color:var(--vdi-muted);line-height:1.9;margin-top:20px;font-size:15px}.intro-capabilities{display:flex;gap:23px;margin-top:25px;flex-wrap:wrap}.intro-capabilities>span{display:flex;align-items:center;gap:7px;color:var(--vdi-muted);font-size:12px}.intro-capabilities .el-icon{color:var(--vdi-accent)}.login-form-area{display:flex;justify-content:center}.login-card{width:440px;max-width:100%;background:var(--vdi-panel);border:1px solid var(--vdi-border);padding:40px;border-radius:22px;box-shadow:var(--vdi-shadow)}.login-card h2{font-size:25px;margin-bottom:10px}.section-kicker{color:var(--vdi-accent);font-size:10px;letter-spacing:2px;margin-bottom:14px}.login-card .el-form{margin-top:34px}.login-card :deep(.el-form-item){margin-bottom:25px}.login-card :deep(.el-input__wrapper){min-height:46px;background:var(--vdi-bg)}.login-submit{width:100%;height:46px;margin-top:7px;font-weight:600}.login-submit .el-icon{margin-left:12px}.login-security{display:flex;justify-content:center;align-items:center;gap:6px;font-size:11px;color:var(--vdi-muted);margin-top:23px}.demo-hint{font-size:12px;line-height:1.9;color:var(--vdi-muted);margin-top:24px;padding-top:20px;border-top:1px solid var(--vdi-border)}.demo-hint span{font-size:11px}.login-footer{display:flex;justify-content:space-between;padding:25px 5%;color:var(--vdi-muted);font-size:11px;gap:16px;flex-wrap:wrap}.login-art{position:relative;width:360px;height:200px;margin-top:32px}.art-orbit{position:absolute;border:1px solid var(--vdi-border);border-radius:50%;width:340px;height:135px;bottom:4px;left:0;transform:rotate(-14deg)}.orbit-two{transform:rotate(15deg);width:300px;bottom:17px;left:20px}.art-screen{position:absolute;background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:13px;box-shadow:var(--vdi-shadow);overflow:hidden}.screen-back{width:220px;height:133px;left:8px;top:9px;transform:rotate(-7deg)}.screen-front{width:247px;height:152px;left:89px;top:40px;transform:rotate(5deg)}.art-dots{display:flex;gap:4px;padding:11px;border-bottom:1px solid var(--vdi-border);background:var(--vdi-panel-2)}.art-dots i{width:4px;height:4px;border-radius:50%;background:var(--vdi-muted);opacity:.5}.art-rows{padding:20px 18px}.art-rows b{display:block;height:7px;background:var(--vdi-border);margin-bottom:9px;width:65%;border-radius:3px}.art-rows b:nth-child(2){width:88%}.art-rows b:nth-child(3){width:43%;background:var(--vdi-brand);opacity:.6}.art-logo{display:flex;gap:13px;align-items:center;padding:26px 25px 17px;font-size:14px;letter-spacing:1px;font-weight:600}.art-logo img{width:33px;height:33px}.art-logo small{font-size:9px;display:block;color:var(--vdi-muted);letter-spacing:1px;margin-top:6px;font-weight:400}.art-progress{margin:0 25px;height:4px;background:var(--vdi-panel-2);border-radius:4px}.art-progress i{display:block;height:100%;width:68%;background:linear-gradient(90deg,var(--vdi-brand),#f1ad62);border-radius:4px}.art-badge{position:absolute;right:0;bottom:0;background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:9px;padding:9px 14px;font-size:10px;box-shadow:var(--vdi-shadow)}.art-badge i{width:5px;height:5px;border-radius:50%;display:inline-block;background:var(--vdi-ok);margin-right:6px}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<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: '终端离线', terminal_cpu: '终端 CPU', terminal_memory: '终端内存', terminal_temperature: '终端 SoC 温度' }
|
||||
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 temperature = computed(() => form.metric === 'terminal_temperature')
|
||||
const terminalMetric = computed(() => ['terminal_cpu', 'terminal_memory', 'terminal_temperature'].includes(form.metric))
|
||||
const unit = (metric: string) => ['node_unavailable', 'terminal_offline'].includes(metric) ? '秒' : metric === 'terminal_temperature' ? '°C' : '%'
|
||||
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 : temperature.value ? 80 : 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 }} {{ unit(row.metric) }}<div v-if="row.status === 'firing' && row.dataFresh === false" class="data-unknown" role="status">数据已过期或缺失;显示最近告警值,等待有效采样</div><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) ? ' 秒离线' : `${unit(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 ? '离线阈值(秒)' : temperature ? 'SoC 温度阈值(°C)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : temperature ? 150 : 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><p v-if="terminalMetric" class="subtext terminal-alert-note">对已激活且启用的终端生效;SoC 温度只使用 soc-thermal 传感器。持续时间按新采样累计,重复读取不推进;采样超过 90 秒或缺失会重新计时,已有告警等待有效正常采样才恢复。此规则只在控制台告警,不发送外部通知。</p><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}.data-unknown{color:var(--el-color-warning);font-size:12px;line-height:1.7;margin-top:6px}.terminal-alert-note{margin:14px 0;font-size:12px}
|
||||
@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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 批量导入', '节点趋势与告警', '终端系统指标、历史与阈值告警', '诊断日志采集', '日志诊断关联与诊断包', '终端策略版本、下发与应用回执', '任务跟踪与操作审计']" :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>
|
||||
@@ -1,33 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePlatform } from '../stores/platform'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
import { api, errorMessage } from '../api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
const auth = useAuth()
|
||||
async function downloadDiagnostics(id: string) {
|
||||
try {
|
||||
const diagnostic = await api.taskDiagnostics(id)
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(diagnostic, null, 2)], { type: 'application/json' }))
|
||||
const link = document.createElement('a'); link.href = url; link.download = `${id}-diagnostics.json`; link.click(); URL.revokeObjectURL(url)
|
||||
} catch(e) { ElMessage.error(errorMessage(e)) }
|
||||
}
|
||||
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 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())))
|
||||
const auditRows = computed(() => auditFiltered.value.slice((page.value - 1) * 10, page.value * 10))
|
||||
import { ref } from 'vue'
|
||||
import AuditHistory from '../components/AuditHistory.vue'
|
||||
import TaskHistory from '../components/TaskHistory.vue'
|
||||
const tab = ref('tasks')
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div><el-button @click="data.refresh">刷新</el-button></div>
|
||||
<div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div></div>
|
||||
<section class="panel">
|
||||
<el-tabs v-model="tab" @tab-change="page = 1; keyword = ''; status = ''"><el-tab-pane label="执行任务" name="tasks" /><el-tab-pane label="操作审计" name="audit" /></el-tabs><div class="toolbar"><el-input v-model="keyword" placeholder="搜索目标 / 操作者 / 标识" aria-label="搜索任务" clearable @input="page = 1" /><el-select v-if="tab === 'tasks'" v-model="status" aria-label="任务状态筛选" placeholder="全部任务状态" clearable @change="page = 1"><el-option v-for="s in [{ value: 'queued', label: '排队中' }, { value: 'running', label: '执行中' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'timeout', label: '结果未确认' }]" :key="s.value" :label="s.label" :value="s.value" /></el-select></div>
|
||||
<el-table v-if="tab === 'tasks'" :data="rows" empty-text="尚无执行任务"><el-table-column label="操作目标 / 任务" min-width="200"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.targetName }}</el-button><div class="mono secondary">{{ row.id.slice(0, 18) }}…</div></template></el-table-column><el-table-column label="操作" width="110"><template #default="{ row }">{{ labels[row.action] || row.action }}</template></el-table-column><el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag :value="row.status === 'running' ? '执行中' : row.status" /></template></el-table-column><el-table-column prop="actor" label="操作者" width="110" /><el-table-column label="提交时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column><el-table-column prop="message" label="执行结果" min-width="240" /></el-table>
|
||||
<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" />
|
||||
<el-tabs v-model="tab"><el-tab-pane label="执行任务" name="tasks" /><el-tab-pane label="操作审计" name="audit" /></el-tabs>
|
||||
<AuditHistory v-if="tab === 'audit'" />
|
||||
<TaskHistory v-else />
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<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, hardwareEncodingVerified: false })
|
||||
const filtered = computed(() => items.value.filter(t => `${t.name} ${t.version} ${t.os} ${t.vmid}`.toLowerCase().includes(keyword.value.toLowerCase())))
|
||||
const candidates = computed(() => discovered.value.filter(v => !items.value.some(t => t.node === v.node && t.vmid === v.vmid)))
|
||||
async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.templates); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
|
||||
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, hardwareEncodingVerified: false })
|
||||
try { discovered.value = await allPages(api.discoverTemplates) } catch (e) { discoverError.value = errorMessage(e) } finally { busy.value = false }
|
||||
}
|
||||
function choose() { const vm = candidates.value.find(v => `${v.node}/${v.vmid}` === vmKey.value); if (vm) Object.assign(form, { node: vm.node, vmid: vm.vmid, name: vm.name }) }
|
||||
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, { enabled: !item.enabled }); ElMessage.success(validate ? '模板配置已重新验证' : '模板状态已更新'); await load() }
|
||||
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
|
||||
}
|
||||
async function toggleEncoding(item: ImageTemplate) {
|
||||
busy.value = true
|
||||
try { await api.updateTemplate(item.id, { hardwareEncodingVerified: !item.hardwareEncodingVerified }); ElMessage.success(item.hardwareEncodingVerified ? '已取消硬件编码验收' : '已确认硬件编码'); await load() }
|
||||
catch (e) { ElMessage.error(errorMessage(e)) } finally { busy.value = false }
|
||||
}
|
||||
function clone(item: ImageTemplate) { templateId.value = item.id; cloneVisible.value = true }
|
||||
</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="状态" min-width="150"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag><el-tag v-if="row.hardwareEncodingVerified" type="success" effect="plain" style="margin-left:6px">硬件编码已验收</el-tag><el-tag v-else type="warning" effect="plain" style="margin-left:6px">待验收</el-tag></template></el-table-column>
|
||||
<el-table-column label="最近校验" min-width="180"><template #default="{ row }">{{ new Date(row.validatedAt).toLocaleString('zh-CN') }}</template></el-table-column>
|
||||
<el-table-column v-if="auth.canAdmin" label="操作" min-width="330"><template #default="{ row }"><el-button link type="primary" :disabled="busy || !row.enabled || !row.hardwareEncodingVerified" @click="clone(row)">创建桌面</el-button><el-button link :disabled="busy" @click="update(row, true)">重新验证</el-button><el-button link :disabled="busy" @click="toggleEncoding(row)">{{ row.hardwareEncodingVerified ? '取消编码验收' : '确认硬件编码' }}</el-button><el-button link :disabled="busy" @click="update(row)">{{ row.enabled ? '停用' : '启用' }}</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<p class="catalog-note">模板登记会校验 PVE 模板标记、磁盘与直通配置。模板修改后需重新验证;停用会阻止新的创建请求。现有业务虚拟机不会自动转换为模板。</p>
|
||||
</section>
|
||||
<el-dialog v-model="visible" title="登记镜像模板" width="560px" :close-on-click-modal="false">
|
||||
<el-alert v-if="discoverError" type="error" :title="discoverError" :closable="false" /><el-alert v-else-if="!busy && !candidates.length" type="info" title="未发现可登记的模板,请先在 PVE 准备专用模板;现有运行桌面不能直接作为模板登记。" :closable="false" />
|
||||
<el-form label-position="top" :disabled="busy"><el-form-item label="PVE 模板"><el-select v-model="vmKey" placeholder="选择发现的模板" filterable @change="choose"><el-option v-for="v in candidates" :key="`${v.node}/${v.vmid}`" :label="`${v.name} · ${v.node} / ${v.vmid}`" :value="`${v.node}/${v.vmid}`" /></el-select></el-form-item><el-form-item label="模板名称"><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="版本"><el-input v-model="form.version" maxlength="40" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in ['Windows', 'Ubuntu', '麒麟']" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="模板能力验收"><el-checkbox v-model="form.hardwareEncodingVerified">已验证硬件编码可用(Sunshine 使用 GPU 硬件编码)</el-checkbox><p class="subtext">只有完成硬件编码验收的模板才允许创建新桌面。Windows、Ubuntu 和麒麟均按同一规则处理。</p></el-form-item></el-form>
|
||||
<template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!vmKey || !form.name.trim() || !form.version.trim()" @click="register">校验并登记</el-button></template>
|
||||
</el-dialog>
|
||||
<CloneDesktopDialog v-model="cloneVisible" :template-id="templateId" />
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'terminal', targetId: selected.id } }">查看此设备日志与诊断 →</router-link><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>
|
||||
@@ -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())))
|
||||
@@ -14,13 +20,36 @@ const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.val
|
||||
const terminalName = (id: string | null) => data.terminals.find(t => t.id === id)?.name || '未关联'
|
||||
const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || id
|
||||
function edit(u: User) { selectedId.value = u.id; binding.terminalId = u.terminalId; binding.desktopIds = [...u.desktopIds]; binding.enabled = u.enabled; bindingVisible.value = true }
|
||||
async function create() { if (!createForm.username.trim() || !createForm.displayName.trim()) return; if (await run(() => api.createUser(createForm), '用户已创建')) { createVisible.value = false; createForm.username = ''; createForm.displayName = '' } }
|
||||
async function save() { if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false }
|
||||
async function create() { if (!createForm.username.trim() || !createForm.displayName.trim()) return; if (await run(() => api.createUser(createForm), '员工已创建')) { createVisible.value = false; createForm.username = ''; createForm.displayName = '' } }
|
||||
async function save() { if (busy.value) return; if (!await confirm('保存员工授权', '更新关联终端、员工状态和所选桌面。未消费的旧连接票据将失效;已有串流不会被强制中断。')) return; if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false }
|
||||
const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id)
|
||||
function editProfile(u: User) { profileId.value = u.id; profileUsername.value = u.username; Object.assign(profile, { displayName: u.displayName, enabled: u.enabled }); profileVisible.value = true }
|
||||
async function saveProfile() {
|
||||
if (!profile.displayName.trim() || busy.value) return
|
||||
const existing = data.users.find(u => u.id === profileId.value)
|
||||
if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更将作废该员工未消费的连接票据。停用阻止后续连接,保留授权关系和已有串流。')) return
|
||||
if (await run(() => api.updateUser(profileId.value, profile), '员工资料已保存')) profileVisible.value = false
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
<div class="page-heading"><div><h1>员工与授权</h1><p class="subtext">管理员工资料、终端关联和桌面使用授权</p></div><div v-if="auth.canAdmin" class="actions"><el-button @click="csvKind = 'users'; csvVisible = true">导入员工</el-button><el-button @click="csvKind = 'bindings'; csvVisible = true">导入授权</el-button><el-button :disabled="!checked.length || busy" @click="openBatch">批量授权<span v-if="checked.length">({{ checked.length }})</span></el-button><el-button type="primary" @click="createVisible = true">新建员工</el-button></div></div>
|
||||
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 员工账号" aria-label="搜索员工" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" row-key="id" empty-text="暂无员工" @selection-change="selectUsers"><el-table-column v-if="auth.canAdmin" type="selection" width="44" /><el-table-column label="员工 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="220"><template #default="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="busy" @click="editProfile(row)">编辑资料</el-button><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑授权</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理云桌面员工。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p>
|
||||
<el-dialog v-model="createVisible" title="新建员工" width="480px"><el-form label-position="top" @submit.prevent="create"><el-form-item label="姓名" required><el-input v-model="createForm.displayName" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="createForm.username" maxlength="80" /></el-form-item><p class="subtext">首版使用设备绑定模式,此账号用于授权关系,不是桌面系统密码。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :disabled="!createForm.username.trim() || !createForm.displayName.trim()" :loading="busy">创建员工</el-button></div></el-form></el-dialog>
|
||||
<el-dialog v-model="profileVisible" title="编辑员工资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveProfile"><el-form-item label="员工账号(固定)"><el-input :model-value="profileUsername" disabled readonly /></el-form-item><el-form-item label="姓名"><el-input v-model="profile.displayName" maxlength="80" aria-label="编辑员工姓名" /></el-form-item><el-form-item label="员工状态"><el-switch v-model="profile.enabled" active-text="启用" inactive-text="停用" aria-label="编辑员工启用状态" /></el-form-item><p class="subtext">员工账号用于授权关系,创建后不能修改;停用保留桌面授权,阻止后续连接。</p></el-form><template #footer><el-button :disabled="busy" @click="profileVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!profile.displayName.trim()" @click="saveProfile">保存员工资料</el-button></template></el-dialog>
|
||||
<el-dialog v-model="batchVisible" title="批量桌面授权" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy"><el-form-item label="操作方式"><el-radio-group v-model="batch.action" aria-label="批量授权操作"><el-radio-button value="grant">追加分配</el-radio-button><el-radio-button value="revoke">撤销指定</el-radio-button></el-radio-group></el-form-item><el-form-item :label="`目标员工(${batchUsers.length} 位)`"><div class="batch-users"><el-tag v-for="u in batchUsers" :key="u.id" type="info">{{ u.displayName }} / {{ u.username }}</el-tag></div></el-form-item><el-form-item label="所选桌面"><el-checkbox-group v-model="batch.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><p class="subtext">只追加或撤销所选桌面,保留其他授权;停用员工仍需启用后才能连接。每次最多 100 位员工和 100 台桌面。</p></el-form><template #footer><el-button :disabled="busy" @click="batchVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!batchUsers.length || !batch.desktopIds.length || batchUsers.length > 100" @click="saveBatch">提交批量授权</el-button></template></el-dialog>
|
||||
<el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" @submit.prevent="save" :disabled="busy"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id) || (t.enabled === false && t.id !== binding.terminalId)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="员工状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog>
|
||||
<CsvImportDialog v-model="csvVisible" :kind="csvKind" @completed="data.refresh" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row-actions{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
.batch-users{display:flex;flex-wrap:wrap;gap:8px;max-height:140px;overflow:auto}
|
||||
</style>
|
||||
@@ -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}
|
||||
@@ -34,6 +35,9 @@ services:
|
||||
PVE_TOKEN_ID: ${PVE_TOKEN_ID:?required}
|
||||
PVE_TOKEN_SECRET: ${PVE_TOKEN_SECRET:?required}
|
||||
PVE_CA_FILE: /run/secrets/pve-ca.pem
|
||||
VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-}
|
||||
VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-}
|
||||
VDI_GPU_AGENT_TOKEN: ${VDI_GPU_AGENT_TOKEN:-}
|
||||
volumes:
|
||||
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
|
||||
depends_on:
|
||||
|
||||
+1
-1
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+32
-1
@@ -5,6 +5,7 @@
|
||||
| 文档 | 查什么 |
|
||||
| --- | --- |
|
||||
| [真实云管接入](cloud-live-integration.md) | 正式环境启动、现场 PVE/盒子、凭据与回退 |
|
||||
| [云管功能核对](cloud-feature-progress-20261110.md) | 对照原需求的已实现/剩余项、模板与桌面池使用及验收条件 |
|
||||
| [当前状态](current-status.md) | 当前架构、三种桌面配置、已完成与未验收事项 |
|
||||
| [终端说明](terminal-guide.md) | 开机直达选择页、进入/退出交接、快捷键、显示恢复、构建入口 |
|
||||
| [云桌面说明](desktop-guide.md) | Windows/Linux 编码与画质、原生光标、电源按钮、U 盘 |
|
||||
@@ -13,6 +14,12 @@
|
||||
|
||||
另保留正在讨论的[云端服务与 Web 控制台功能设计](cloud-console-functional-design-20261109.md),用于产品/UI/后端任务拆分;它是讨论稿,不属于本次清理的历史实施记录。
|
||||
|
||||
[通用版与高级版首批实现](cloud-editions-20261111.md):两套真实界面、员工授权向导、终端诊断与高级 GPU 任务核对;[助手与后续流程设计](cloud-editions-and-assistant-design-20261111.md)保留后续方案,模型本轮暂不接入。
|
||||
|
||||
[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按设备/桌面关联任务、会话、告警和审计,设备日志采集回执与完整诊断包导出;已完成真实盒子采集与页面验收。
|
||||
|
||||
[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md):先不接模型,通用工作台可生成并确认真实员工授权方案;未知对象和未支持场景会阻止写操作,已部署到 5182。
|
||||
|
||||
[云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。
|
||||
|
||||
[控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。
|
||||
@@ -21,7 +28,7 @@
|
||||
|
||||
`D:\DAIMA\vdi-monitor` 负责日常监控页面、指标采集与日志解读,具体维护方法见该项目的 [README](../../vdi-monitor/README.md) 和 [运维说明与已知限制](../../vdi-monitor/docs/运维说明与已知限制.md)。
|
||||
|
||||
监控当前约每 30 秒采集一次盒子数据,保存最新原始快照和状态,并保留约 7 天指标历史。这不等于完整历史日志备份,也不覆盖开发决策、测试场景、失败实验和回退配置。`vdi-cloud` 保留这些资料,监控页面的使用说明以后在 `vdi-monitor` 维护。
|
||||
监控当前约每 30 秒采集一次盒子数据,保存最新原始快照和状态,并保留约 7 天指标历史。这不等于完整历史日志备份,也不覆盖开发决策、测试场景、失败实验和回退配置。`vdi-cloud` 保留这些资料;11-10 另在云管控制台新增 PVE 节点曲线和平台告警,见[范围说明](cloud-operations-20261110.md),不替代独立监控的诊断采集。
|
||||
|
||||
## 历史记录与原始资料
|
||||
|
||||
@@ -35,3 +42,27 @@
|
||||
## 后续维护方式
|
||||
|
||||
日常变更更新对应主题文档,注明日期、实际部署状态、证据位置和限制;计划统一写入 `roadmap.md`。测试原始输出放在 `evidence/<专题-日期>/`,一般不再新增一份顶层日报文档。发生配置冲突时,以较新的部署证据核对现场,不把历史测试成功直接视为当前状态。
|
||||
|
||||
- [终端生命周期与用户维护](cloud-lifecycle-20261110.md):设备改名/分组、停用/重新激活、用户资料/启停与批量桌面授权。
|
||||
|
||||
- [终端运维策略:配置模板、下发和应用回执](cloud-terminal-policies-20261110.md)
|
||||
|
||||
- [CSV 导入、会话历史与监控告警](cloud-operations-20261110.md):使用入口、格式、数据留存、迁移与验收边界。
|
||||
|
||||
- [PVE 接入配置、桌面规格与登录限流](cloud-configuration-20261110.md):真实配置保存、规格任务保护、可信代理及现场验收边界。
|
||||
|
||||
- [关机快照与手动还原](cloud-snapshots-20261110.md):列表、创建/删除/还原任务、未知结果保护及现场权限与磁盘范围限制。
|
||||
|
||||
- [桌面批量电源操作](cloud-batch-operations-20261110.md):跨页选择、逐台预检、批次任务、跳过原因与未确认结果核对;已部署,现场仅做只读预检和取消提交验收。
|
||||
|
||||
- [GPU 清单与配置引用](cloud-gpu-inventory-20261110.md):真实 PCI 清单、IOMMU 分组、当前/待应用直通引用及不完整检查提示;仅资源查看,绑定与健康采集尚未接入。
|
||||
|
||||
- [终端系统指标与历史](cloud-terminal-metrics-20261110.md):真实 CPU、内存、SoC 温度、运行时长和 30 天历史;串流 FPS/RTT 仍未采集。
|
||||
|
||||
- [终端系统指标告警](cloud-terminal-alerts-20261111.md):CPU、内存和 SoC 温度阈值、按真实采样持续判断、缺失提示、确认与恢复。
|
||||
|
||||
11-11 已补齐[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)及[执行任务历史查询与 CSV 导出](cloud-task-history-20261111.md),均支持数据库分页、多条件查询和全部匹配页导出;外部归档和防篡改仍待后续。
|
||||
|
||||
11-11 已补齐[管理员在线登录管理](cloud-admin-sessions-20261111.md):查看登录来源及到期时间,单独撤销指定登录并记录审计,保留当前登录保护。
|
||||
|
||||
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 管理员在线登录管理(2026-11-11)
|
||||
|
||||
已部署 `http://127.0.0.1:5182/settings` →「管理员账号」→ 对应账号的「在线登录」。仅超级管理员可查看与撤销。
|
||||
|
||||
## 行为
|
||||
|
||||
- 显示尚未退出、撤销或到期的登录凭据,支持分页和手动刷新。显示来源地址、登录时间、到期时间和浏览器/客户端,并标记当前登录。有效凭据不代表浏览器当前仍在使用。
|
||||
- 「撤销登录」确认后只撤销所选登录,其他登录与密码保留;该凭据后续管理 API 请求返回 401。当前登录按钮禁用,服务端也返回 409,需使用正常退出入口。
|
||||
- 新登录记录请求来源及最多 500 字符的 User-Agent;来源经过现有可信代理配置解析。代理/NAT 可能使地址显示为网关,User-Agent 可由客户端填写,均只作排查信息,不作为身份凭据。
|
||||
- 升级前会话继续有效,来源与登录时间明确显示未知;不推算或伪造旧元数据。到期会话不列入有效列表。
|
||||
- 查询或撤销失败显示错误并清除旧列表;取消确认不提交,切换账号不会被旧请求覆盖。
|
||||
|
||||
## 接口与数据
|
||||
|
||||
`GET /api/v1/administrators/{id}/sessions`:每页默认 20 条、最多 100 条,按到期时间及内部标识降序排列。
|
||||
|
||||
`DELETE /api/v1/administrators/{id}/sessions/{sessionKey}`:成功 204;不存在、已撤销、已到期或账号不匹配返回 404;本人当前登录返回 409;未登录 401,非超级管理员 403。并发撤销仅一次成功,成功撤销与 `revoke_administrator_session` 审计同事务提交。
|
||||
|
||||
公开的 key 为带独立用途前缀的 HMAC-SHA256 管理句柄。列表、路径和审计不暴露 JWT、内部会话标识或密码。句柄自身不能登录。事务内复查操作者当前会话及最新权限;等待期间被撤销的操作者不能继续撤销其他登录。原账号角色、启停和改密撤销全部登录的行为保持兼容。
|
||||
|
||||
新增 `admin_session_details` 附表,外键关联 `admin_sessions` 且删除级联。既有 `admin_sessions` 三列保持不变;旧 API 的插入仍兼容。登录与附表写入同一事务。附表无历史回填,现有管理员、密码和设备身份不重置。
|
||||
|
||||
## 验收
|
||||
|
||||
- 独立测试 PostgreSQL 的 Go 全量测试和 vet 通过,覆盖单独撤销、保留其他登录、三角色权限、当前登录保护、旧登录兼容、过期排除、来源清理与不可信代理、凭据不泄露、并发一次成功、等待锁期间操作者被撤销。
|
||||
- 前端 lint/typecheck、3 项单元测试和生产构建通过;新增 4 项登录管理及原有 4 项管理员浏览器回归通过,包含确认/取消、错误处理、分页、旧响应保护、深浅主题和 390px。
|
||||
- 真实临时账号建立两条登录,准确选取并撤销其中一条,旧凭据返回 401,另一条仍可读取本人资料;停用账号撤销剩余登录,审计记录仅含管理句柄。临时账号及会话已定向清理,审计保留。
|
||||
- 真实深浅主题、1440/390px 界面通过,无脚本异常、失败 API 或浏览器业务写请求。既有账号密码指纹、用户授权、设备身份、告警规则及 VM 配置/电源与本轮部署前基线一致,盒子在线。未执行 VM、磁盘、GPU 或终端控制。
|
||||
|
||||
证据:[真实 API 核验](evidence/cloud-admin-sessions-20261111/live-verification.json)、[真实浏览器核验](evidence/cloud-admin-sessions-20261111/browser-verification.json)。首次验收脚本误把按时间倒序的第一条当作首次登录,已改为根据指定凭据的管理句柄精确选择后重验通过,应用实现无需变更。
|
||||
|
||||
## 部署与回退
|
||||
|
||||
备份 `.local/live/backups/before-admin-sessions-20261111-150936.dump`,原镜像标签 `before-admin-sessions-20261111-150936`。独立打包基线二进制与部署前 SHA-256 相同:`78f454b057fd2de9510fa966ada07654692c609181faa62dc72c96391f0d95ec`;保留已部署 GPU 代码和配置。
|
||||
|
||||
回退前核对后续是否另有部署,恢复原 API/console 镜像标签,并用 `.local/admin-sessions-review/compose.release.yml`、原环境文件及 `--project-directory deploy` 执行 `up -d --no-build --no-deps api console`。附表可保留,不删除业务卷或恢复整个数据库覆盖其他工作。旧 API 登录不写附表,再次升级时这些登录也会显示来源未知。
|
||||
|
||||
此项仅管理控制台登录,桌面串流会话仍由「远程会话」管理。专用 VM、Windows 副本、存储准备及真实快照还原继续暂缓。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 通用版固定场景助手向导
|
||||
|
||||
日期:2026-11-12。第一阶段先实现不依赖模型的固定场景向导,入口为通用版「工作台」。
|
||||
|
||||
## 当前能力
|
||||
|
||||
工作台输入一句简短描述后生成方案卡片:
|
||||
|
||||
- “给张三分配备用桌面”这类请求会按员工姓名/账号和桌面名称/VMID匹配真实对象。
|
||||
- 方案展示员工、桌面、影响范围和执行边界;只追加授权,不新建 VM、不自动开机、不终止已有连接。
|
||||
- 点击执行后仍需确认,服务端复用现有 `POST /users/batch-bindings`,执行结果来自真实授权接口。
|
||||
- 员工不存在、同名无法唯一匹配、授权已经存在或没有可分配桌面时,只显示阻止原因,不发写请求。
|
||||
- “桌面打不开/设备离线”进入问题处理;“异常/告警/问题”进入操作记录。
|
||||
- viewer 和 operator 即使能看到工作台,也继续由后端 RBAC 控制;执行授权仍需超级管理员。
|
||||
|
||||
本阶段没有接入语言模型,没有让自然语言直接变成 Shell、PowerShell、SSH 或电源命令。未知句式会明确提示当前支持的固定场景。
|
||||
|
||||
## 验证
|
||||
|
||||
- TypeScript、ESLint、生产构建通过。
|
||||
- 通用版浏览器回归 15/15 通过,新增覆盖:可执行授权方案、确认后调用授权接口、未知员工阻止、已有授权阻止、同名桌面阻止及无写请求。
|
||||
- 既有 6 项前端单元测试通过。
|
||||
|
||||
已发布到真实 `http://127.0.0.1:5182`。本次仅更新控制台静态镜像;API、数据库表、管理员账号、终端、桌面和 PVE 配置没有变更。真实验收使用临时员工和已有桌面完成了一次页面授权,随后定向清理,临时员工和绑定均为 0。
|
||||
|
||||
后续可在同一方案对象上增加批量电源预检和终端无桌面诊断,再评估模型服务;模型必须只能选择受控业务动作,不能直接执行自由文本命令。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 操作审计历史查询与 CSV 导出(2026-11-11)
|
||||
|
||||
入口:控制台 `http://127.0.0.1:5182/tasks` →「操作审计」。本轮补齐真实 PostgreSQL 查询和导出,执行任务页维持原有行为。
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 支持关键词、操作者包含、动作、结果与自定义时间范围;时间控件提供最近 24 小时、7/30/90 天快捷项。不选时间查询全部已有历史,包含超过最近 1000 条的旧记录。
|
||||
- 动作、结果可以选择中文选项,也可输入原始代码;关键词查对象、操作者、动作、结果、请求标识和审计标识,`%`、`_`、反斜杠按普通文字匹配。
|
||||
- 点击「查询审计」应用条件。每页 20 条,翻页和导出沿用已应用的条件及截止时间,避免新记录不断插入导致页内容移动;修改输入后需重新查询。查询失败清除旧结果,禁止把旧结果当成新条件的导出。
|
||||
- 「导出查询结果 CSV」包含所有匹配页,最多 10000 条;超量时禁止导出并提示缩小范围,服务端也返回 413,绝不悄悄截取前几页。API 空结果返回表头。
|
||||
- 页面时间按浏览器本地时区显示;CSV 时间为 UTC,使用 UTF-8 BOM、CRLF 和 CSV 转义,动作与结果保留原始代码。可能被表格软件当公式的单元格增加前置单引号。
|
||||
|
||||
## 接口与边界
|
||||
|
||||
`GET /api/v1/audit-logs`:数据库分页,默认 `page=1&pageSize=20`,最多每页 100 条。支持可选 `from`、`to`(含时区的 RFC3339)、`keyword`、`actor`、`action`、`result`。动作和结果精确匹配,操作者包含匹配。时间边界含两端;起止同时提供时要求结束晚于开始。没有隐含 1000 条上限,也没有新加历史清理策略。
|
||||
|
||||
`GET /api/v1/audit-logs/export`:使用相同筛选条件,忽略分页;成功返回 `text/csv`、附件名和 `X-Export-Count`。只输出时间、操作者、动作、对象、结果、请求标识、审计标识。不会读取密码、设备令牌、PVE 密钥或任务原始参数。导出文件准备成功会记一条 `export_audit_logs` 审计;它表示服务端生成文件,不证明客户端已保存。
|
||||
|
||||
查询与导出均要求有效管理员会话;三角色均可访问,与原有审计读取权限一致。不会新增设备控制权限。单次列表的总数和记录来自同一 PostgreSQL 快照,排序为 `created_at DESC,id DESC`。页面固定截止时间,记录正常追加时保持翻页稳定;审计导入时若回填历史时间,仍可能改变后续查询。
|
||||
|
||||
审计不再加入全局定时刷新,进入标签页时按需查询。执行任务查询仍为原有最近 1000 条,本轮未改任务历史分页。既有操作是否产生日志仍取决于对应接口,未新增所有鉴权失败或操作前后差异记录;外部归档、签名防篡改、多租户隔离和日志保留策略仍待后续产品化。
|
||||
|
||||
## 验证与部署
|
||||
|
||||
验证结果和现场部署记录见同目录下 `evidence/cloud-audit-history-20261111/`。测试使用独立一次性 PostgreSQL,不对现场库执行测试清空。
|
||||
|
||||
仓库另有并行的独占显卡切换开发。本轮部署使用独立源码目录,只包含已部署的终端告警基线和本轮审计改动;排除这部分并行修改,不修改工作区原文件。通过重建基线与现场二进制比较确认部署基础,保留数据库备份、原镜像标签和源码校验清单。
|
||||
|
||||
专用 VM、Windows 模板副本、额外磁盘及真实快照还原继续暂缓。本轮不需要对 VM 或终端发控制命令。
|
||||
|
||||
|
||||
## 本轮验收结果
|
||||
|
||||
- 已部署到 5182 真实环境;Go 全量测试与 vet、前端 lint/typecheck/build、3 项单元测试、65 项安全 fixture 页面测试通过,其中新增审计页面 4 项。后端专用测试覆盖超过 1000 条历史记录、10000 条完整导出及 10001 条拒绝、三角色与未登录拒绝、筛选组合和 CSV 转义/公式防护。
|
||||
- 现场将 root 的 187 条匹配记录按每页 2 条查询,共 94 页;CSV 的 187 个审计标识及顺序与所有匹配页完全一致。另用真实界面按操作者和保存告警规则筛选并下载 CSV。
|
||||
- 深浅主题、1440 和 390px 真实界面通过;没有页面脚本异常、失败 API 或前端业务写请求。导出接口本身按设计新增审计,验收登录/退出也保留正常审计。
|
||||
- 账号/密码指纹、用户授权、终端身份及配置版本、告警规则一致;PVE 三台 VM 配置摘要和电源状态保持验收前基线,任务仍为 8 条。验证时 VM100/102 为 running、VM101 为 stopped;这是本轮只读观测,不代表本轮执行过开机。
|
||||
- 独立打包基线二进制 SHA-256 与部署前二进制一致:`8c87f4e2183bd6f96ff5d67544f0467dc832c6af524ba8f1426aa5ec54d82d75`。本次未部署仓库中并行的独占 GPU 逻辑和相关 Compose 配置。
|
||||
|
||||
部署前备份为 `.local/live/backups/before-audit-history-20261111-101812.dump`,原 API/console 镜像保留 `before-audit-history-20261111-101812` 标签。本轮没有新增数据库结构变更。需回退时先核对其他会话是否又有部署,再将两项镜像重新打回原名称,并使用 `.local/audit-review/compose.release.yml` 及 `--project-directory deploy`、原环境文件执行 `up -d --no-build --no-deps api console`。不要删除 PostgreSQL 业务卷,也不要把本轮只读验收的电源状态当成需要强制恢复的目标。
|
||||
|
||||
后续更新:执行任务也已补齐[完整历史分页与 CSV 导出](cloud-task-history-20261111.md),本文“最近 1000 条”描述仅为审计批次当时边界。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 桌面批量电源操作(2026-11-10)
|
||||
|
||||
已部署本机真实控制台 [5182](http://127.0.0.1:5182/desktops),入口为「桌面管理」。勾选卡片或列表中的桌面,选择「批量开机 / 批量关机 / 批量重启」,先逐台预检,再确认提交;「批量记录」查看最近 100 个批次及逐台结果。跨页保留选择,每批最多 50 台。超级管理员和运维可操作,只读角色可查看历史。
|
||||
|
||||
## 执行与保护
|
||||
|
||||
- 预检读取真实 PVE 配置、电源与 `VM.PowerMgmt` 权限;不存在、模板、锁定、状态不适用、活动会话、维护任务或原结果未确认的桌面逐台跳过。批量关机使用正常 shutdown,重启使用 reboot。
|
||||
- 预检结果签名并绑定当前账号、登录会话、动作和所选桌面,有效 5 分钟。提交时再次检查,只允许预检批准且标识/电源状态一致的桌面进入队列;原先不可用的目标不会后来自动加入。
|
||||
- 提交必须携带 `Idempotency-Key`。重复相同请求返回原批次,参数变化返回冲突;确认取消不创建批次或任务。所有跳过项及原因也保留在批次中。
|
||||
- 执行前再次读取状态和权限,成功需确认原 PVE UPID 完成且实际电源状态符合预期;开机完成不表示 Sunshine 已就绪。页面区分已提交、执行中、成功、失败、未确认和跳过。
|
||||
- 明确的 PVE 4xx 拒绝记为失败;发送后的通信异常、任务失败或缺少 UPID 保守记为未确认。重启恢复只查询已记录的原任务,不自动重发。未确认阻止后续桌面修改和新连接。
|
||||
- 对有原 UPID 且最终任务成功的未确认结果,可在批次或任务页「核对电源结果」。该操作只读取 PVE 并更新平台记录,不重新下发;缺少 UPID、PVE 失败或实际状态不符仍须管理员核查,当前无强制解除隔离入口。
|
||||
- 电源、规格、快照共用每桌面活动任务约束。批量与克隆/规格/快照共用一次一个的长任务执行通道,普通命令继续处理;排队超过 2 小时且尚未发送的任务失败,执行中超过恢复期限按原阶段保守处理。
|
||||
|
||||
外部直接在 PVE 操作 VM 不受平台行锁约束,预检与发送之间仍可能发生变化;平台会核对任务和最终状态,不承诺跨系统原子操作。当前只支持单个队列运行者,没有批量发放、批量规格修改或定时电源策略。
|
||||
|
||||
## 接口与持久化
|
||||
|
||||
路径均位于 `/api/v1`:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/desktop-batches/check` | 只读预检,返回逐台原因及签名凭证 |
|
||||
| POST | `/desktop-batches` | 确认后持久化批次与子任务,返回 202 |
|
||||
| GET | `/desktop-batches` | 最近 100 批分页列表 |
|
||||
| GET | `/desktop-batches/{id}` | 批次汇总、每台任务或跳过原因 |
|
||||
| POST | `/tasks/{id}/batch-power-reconcile` | 核对原 PVE 任务及实际状态 |
|
||||
|
||||
新增 `power_batches`、`power_batch_items` 和覆盖 `batch-desktop` 的活动任务唯一索引;子任务与审计同事务写入,启动幂等迁移,不增加原业务表字段。API 契约见 [OpenAPI](openapi/platform.v1.json)。
|
||||
|
||||
## 验证与现场边界
|
||||
|
||||
- 独立临时 PostgreSQL 与本地 PVE HTTP 测试服务验证三种电源动作、逐台跳过、角色与签名绑定、幂等并发、提交/执行复查、活动会话与维护互斥、网络未知、重启不重发、过期及单连接池核对。Go 全量测试和 `go vet` 通过。
|
||||
- 前端 ESLint、TypeScript、3 项单元测试和生产构建通过;8 项新增浏览器用例及 37 项已有回归全部通过,共 45/45,覆盖深浅主题、390px 窄屏、跨页选择、取消、重复提交和只读权限。接口全部拦截,不操作现场 VM。
|
||||
- API/控制台镜像已更新,业务 PostgreSQL 容器和卷保留。现场三台运行中的桌面:批量开机预检全部跳过,关机/重启预检各 3 台可执行。真实浏览器打开确认后取消,空批次历史验证通过;没有提交批次或执行 VM 电源操作。
|
||||
- 核查时 VM100/101/102 均运行,盒子心跳正常;原管理员及密码哈希指纹、桌面连接配置、用户授权与终端身份和更新前一致。批次数与批量任务数均为 0。真实批量电源执行尚未做现场验收。
|
||||
|
||||
证据:[45 项页面回归](evidence/cloud-batch-operations-20261110/playwright-report.json)、[真实接口核查](evidence/cloud-batch-operations-20261110/live-verification.json)、[真实页面核查](evidence/cloud-batch-operations-20261110/browser-verification.json)、[现场批量预检](evidence/cloud-batch-operations-20261110/live-batch-preview-dark.png)、[现场窄屏](evidence/cloud-batch-operations-20261110/live-batch-preview-mobile.png)。`fixture-` 图片为拦截接口的测试数据,不代表现场已有对应批次或执行结果。
|
||||
|
||||
## 备份与后续
|
||||
|
||||
更新前备份为私有 `.local/live/backups/before-batch-operations-20261110-163850.dump`,已检查备份目录可读;API/控制台旧镜像标签均为 `before-batch-operations-20261110-163850`。尚未做恢复演练。回退前须评估新增批量任务;旧后端不识别新任务与隔离状态,不可带着活动或未确认批次直接回退,不删除业务卷。
|
||||
|
||||
用户已明确暂缓专用测试 VM、Windows 模板副本、存储准备及依赖它们的真实快照内容还原验收;本批没有创建 VM、格式化或挂载额外磁盘、修改 PVE 权限。公共池自动领取/退出还原仍未实施,后续顺序见 [计划](roadmap.md)。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 通用版固定场景助手
|
||||
|
||||
通用版助手面向非技术用户,使用“自然语言输入 + 受控业务脚本 + 确认执行”的方式工作。当前不依赖 AI 模型;后端先用固定场景解析,后续可以只替换解析层接入模型。
|
||||
|
||||
## 当前脚本
|
||||
|
||||
- 分配已有桌面
|
||||
- 撤销桌面授权
|
||||
- 桌面连接问题处理
|
||||
- 设备与桌面状态查询入口
|
||||
|
||||
分配和撤销会先生成方案,要求员工和桌面唯一匹配;没有明确桌面时展示候选项,不再自动选择第一台空闲桌面。方案有效期 5 分钟,确认执行时后端会重新检查用户、桌面、授权、权限和并发状态。
|
||||
|
||||
## 后端接口
|
||||
|
||||
- `POST /api/v1/assistant/plan`:根据一句话生成方案
|
||||
- `POST /api/v1/assistant/execute`:确认并执行方案
|
||||
- `GET /api/v1/assistant/runs/{id}`:查看方案和执行结果
|
||||
|
||||
方案记录写入 `assistant_runs`,包含原始输入、结构化意图、状态、有效期、执行结果和操作者。执行沿用授权锁和审计日志;同一个方案重复提交会返回已保存结果,不会重复修改授权。
|
||||
|
||||
## 安全边界
|
||||
|
||||
助手只产生允许的业务动作,不能执行任意 Shell、SQL 或 PVE 命令。AI 接入后也只能输出受限结构化意图,最终权限判断和资源变更仍由平台后端完成。
|
||||
|
||||
## 验证
|
||||
|
||||
- Go 全量测试通过,包含缺少桌面时展示候选、明确方案执行、状态重新校验和重复确认幂等测试。
|
||||
- 前端 typecheck、lint 和生产构建通过。
|
||||
- 真实环境已验证:root 账号生成“撤销桌面授权”方案并执行,再生成“分配桌面”方案恢复原授权;重复提交同一方案返回已执行结果,没有重复写入。
|
||||
@@ -0,0 +1,54 @@
|
||||
# PVE 接入、桌面规格与登录限流(2026-11-10)
|
||||
|
||||
本批已部署到本机真实服务,入口仍为 `http://127.0.0.1:5182`,使用原管理员账号。
|
||||
|
||||
| 功能 | 使用入口 | 当前行为 |
|
||||
| --- | --- | --- |
|
||||
| PVE 接入 | 系统设置 → 平台接入 → 配置 Proxmox 接入 | 检查 HTTPS、Token 与资源读取;保存后热生效,重启保留;密钥不回显,留空保留同地址同 Token ID 的原密钥 |
|
||||
| CPU / 内存 | 桌面管理 → 查看详情 → 规格管理 | 读取真实规格;正常关机、无活动会话、无锁和待应用配置时,先预检再确认提交任务 |
|
||||
| 虚拟磁盘扩容 | 规格管理 → 磁盘扩容 | 选择现有磁盘,填写扩容后总容量;禁止缩容,客体分区与文件系统需另行扩展 |
|
||||
| 登录限流 | 登录时自动执行 | 5 分钟窗口内同账号最多 10 次、同来源 IP 最多 30 次,包含成功与失败;超限返回 429 和剩余等待秒数 |
|
||||
|
||||
## 接入配置
|
||||
|
||||
仅超级管理员可读取、检查和保存接入配置。没有数据库配置时读取原服务端环境;保存后以数据库配置为准。始终验证 TLS,可使用系统信任或输入 PEM CA 公证书,不提供忽略证书选项。保存前再次检查 `/cluster/resources` 至少能读取一个节点,检查失败保留原配置。
|
||||
|
||||
首次接入后,本期只支持维护相同地址的凭据和证书;不提供在线更换集群地址或多集群管理。这样可以避免同步中的旧集群资源混入新集群。计算任务排队或执行时,配置保存会被拒绝。并发编辑通过版本冲突提示重新加载,不覆盖他人的配置。
|
||||
|
||||
配置以 AES-GCM 加密保存在 `pve_connection`,随机 nonce,响应、错误和操作审计不记录 Token 密钥。加密密钥由服务端 `VDI_JWT_SECRET` 派生,备份数据库时必须一并安全保管对应服务端密钥。**本期没有在线密钥迁移工具,不可单独更换 JWT Secret 后继续使用原密文。** 失配会明确拒绝启动或返回配置不可用,需在维护窗口恢复匹配密钥或迁移密文。当前启动仍保留原有效环境配置与 CA 文件作为未保存时的基线。
|
||||
|
||||
## 规格任务
|
||||
|
||||
规格操作仅超级管理员可提交。CPU/内存和单块磁盘扩容分开执行。只写目标规格字段,保留 CPU 型号、GPU 直通和其他设备配置。CPU 每插槽 1~256 核,1~8 插槽,总核不超过 1024;内存 512~1048576 MiB,按 128 MiB 递增且不低于已有 balloon 最小值。带自定义 NUMA、PVE 锁或待应用配置的 VM 需先在 PVE 处理。
|
||||
|
||||
扩容仅支持可识别的 scsi/sata/virtio/ide 虚拟存储磁盘,不接受物理磁盘、ISO、cloud-init、EFI 或 TPM 设备。目标容量最大 65536 GiB,要求存储启用、在线且有扩容量加 1 GiB 余量。CPU、内存和磁盘分别预检 `VM.Config.CPU`、`VM.Config.Memory`、`VM.Config.Disk`;扩容另需对应存储的 `Datastore.AllocateSpace`。PVE 仍执行最终权限和资源校验。
|
||||
|
||||
提交和执行前都重新读取真实电源状态、配置摘要、权限和余量。相同请求标识返回原任务,参数或桌面变化不得复用标识。CPU/内存使用 PVE 配置异步任务,磁盘发送**绝对目标容量**,不发送增量扩容。收到 UPID 后等待结束,再读取实际规格确认成功。接口依据 [PVE Qemu 官方实现](https://github.com/proxmox/qemu-server/blob/master/src/PVE/API2/Qemu.pm)。
|
||||
|
||||
任务状态与原请求保存在 `hardware_jobs`。修改发送前先持久化提交阶段;发送后网络异常、服务中断或最终规格无法确认时显示「结果未确认」。恢复进程只查询原任务,不自动重发修改。此时后续规格、电源操作和新连接票据均被阻止;「任务与审计 → 目标任务 → 核对规格结果」只读取原 UPID 与目标规格,确认后解除保护。缺失 UPID 或实际规格未达到目标时保留未知状态,需在 PVE 人工核查并安排离线恢复,本期没有一键清除未知结果的入口。
|
||||
|
||||
排队过期且尚未发送的任务明确失败;已经进入提交阶段的过期任务保持未知结果。规格与电源任务共享同桌面的活动任务限制,长规格任务与克隆共用执行通道,不阻塞普通终端命令。
|
||||
|
||||
## 登录限流
|
||||
|
||||
计数保存在 PostgreSQL,多实例和重启共享。账号按去空格、小写后的值计数,账号/IP 存为 HMAC 摘要,不保存明文计数键或密码。旧计数按保留策略清理。
|
||||
|
||||
默认不信任转发头;`VDI_TRUSTED_PROXIES` 仅配置实际可信代理的 IP/CIDR。现场 Nginx 覆盖客户端提交的 `X-Forwarded-For`,API 信任现场容器网络。更换网关拓扑时同步调整可信代理配置,否则来源 IP 可能聚合在网关地址。该限流只作用于管理员登录,设备注册、心跳与票据鉴权继续使用原机制。
|
||||
|
||||
## 验收与限制
|
||||
|
||||
- Go 全量测试与 `go vet` 通过,数据库仅使用独立临时 `vdi_configuration_test`。本地 PVE 测试服务覆盖 CPU/内存、绝对磁盘扩容、权限/余量、运行/锁/待应用配置/摘要冲突、幂等、任务互斥、未知结果、重启不重发、只读核对和连接保护。
|
||||
- 前端 lint、类型检查、3 项单元测试和生产构建通过;23 项既有管理页面回归及新增 6 项接入/规格流程通过。深浅主题、窄屏、取消确认及只读角色入口检查通过。
|
||||
- 真实 PVE 连接检查、同地址原凭据加密保存、热加载、错误保存保留原配置、API 重启后继续读取均通过。临时只读账号验证第 11 次登录返回 429 与 `Retry-After`,账号和对应限流摘要已定向清理,审计保留。
|
||||
- 三台现场 VM 的真实规格读取和运行中修改拒绝通过;现场 Token **仍缺少三台 VM 的 CPU/内存/磁盘配置权限**。实际规格写入成功仅在隔离测试服务验证,未在现场执行关机、重启、扩容、克隆或其他 VM 修改。
|
||||
- 重启后原业务快照一致:管理员密码指纹、桌面参数、用户授权及终端身份未改变;三台 VM 运行,`qt-platform/0.3.0` 盒子心跳正常。
|
||||
|
||||
证据:[真实接口验收](evidence/cloud-configuration-20261110/live-verification.json)、[重启与业务快照](evidence/cloud-configuration-20261110/restart-verification.json)、[浏览器验收](evidence/cloud-configuration-20261110/browser-verification.json)、[测试汇总](evidence/cloud-configuration-20261110/test-summary.json)。页面见[真实规格](evidence/cloud-configuration-20261110/live-hardware-dark.png)与[真实接入配置](evidence/cloud-configuration-20261110/live-pve-light.png)。
|
||||
|
||||
## 接口与迁移
|
||||
|
||||
接口契约见 `docs/openapi/platform.v1.json`:`GET/PUT /settings/pve`、`POST /settings/pve/check`、`GET/POST /desktops/{id}/hardware`、`POST /desktops/{id}/hardware/check`、`POST /tasks/{id}/hardware-reconcile`。均以 `/api/v1` 为前缀。
|
||||
|
||||
启动新增 `pve_connection`、`hardware_jobs`、`login_limits` 与规格/电源活动任务唯一索引,不删除既有业务表或列。部署前数据库备份为私有 `.local/live/backups/before-configuration-20261110-151859.dump`,对应 API/控制台旧镜像标签为 `before-configuration-20261110-151859`。本次只重建 API/控制台容器,未重建 PostgreSQL 或删除业务卷。
|
||||
|
||||
回退前需再次备份并核查新增任务、接入配置与凭据变化。旧 API 使用环境中的 PVE 配置且不识别规格任务,存在排队或未知规格任务时不可直接切回旧执行器;恢复数据库快照会丢弃快照后的业务记录,当前未进行恢复演练。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 高级版日志与诊断中心
|
||||
|
||||
日期:2026-11-11。已部署到真实控制台 `http://127.0.0.1:5182`,入口为“高级版 → 日志与诊断”。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 按终端设备或云桌面的唯一 ID 关联执行任务、桌面会话、监控告警、操作审计和已回传的终端日志片段。同名设备不会串记录。
|
||||
- 支持时间范围、记录来源、数据库分页、来源数量及详情展开;最长查询 7 天,默认近 24 小时。修改筛选后清除旧结果,重新查询后才能导出。
|
||||
- 设备、桌面及关联任务详情可直接进入指定对象诊断。设备选择显示 SN,桌面选择显示 VMID。
|
||||
- 导出 ZIP 包含 `diagnostics.json` 和 `README.txt`,使用已查询的对象、时间及来源,包含全部匹配页。超过 2000 条时拒绝并提示缩小范围,不静默截断;导出记录审计。
|
||||
- 在线设备可在确认后提交一次日志采集,显示真实任务回执;已有未完成或结果未知的设备命令时阻止再提交。失败重试复用幂等标识,提交成功与设备执行成功分开显示。
|
||||
- 超级管理员、运维人员可使用;只读角色隐藏入口,直接页面访问被拦截,接口返回 403。界面模式不改变角色权限。
|
||||
- 深浅主题和 390px 窄屏布局;日志以纯文本呈现。导出字段采用白名单,并遮盖常见凭据格式。
|
||||
|
||||
## 数据含义
|
||||
|
||||
对象卡片是查询时的当前快照,不代表所选历史时间点。任务提交条目展示查询时状态,另列最后一次更新事件,不伪造完整的任务状态变更历史。会话按预留、连接、结束分别列事件,因此查询范围之前开始但范围内结束的会话也能查到。
|
||||
|
||||
设备视角按终端 ID 及显卡准备任务的 `device:<ID>` 关联;桌面视角按桌面 ID 关联,并包含所属节点告警。只按名称保存、缺少稳定关联标识的旧审计不推测归属。不根据设备当前绑定推断历史日志属于哪台桌面,所以终端日志仅在设备视角出现。
|
||||
|
||||
日志来自设备已回传的片段,当前 Qt Agent 读取会话错误日志尾部及版本、启动标识、运行时长和 kiosk 状态。没有记录可能是尚未采集、未上报或超出留存;不代表没有故障。本轮没有实现持续实时日志推送、服务端全量日志归档或自动采集 VM 客体日志。常见凭据遮盖不保证消除所有自由文本中的自定义敏感信息。
|
||||
|
||||
## 测试与真实验收
|
||||
|
||||
- 后端 6 项诊断专项集成测试通过,覆盖同名隔离、旧会话结束时间、跨来源分页、权限/参数、ZIP 完整性/限额、白名单与凭据遮盖;使用一次性 PostgreSQL,未对业务库运行集成测试。
|
||||
- Go 全量 `go test ./... -count=1`、`go vet ./...` 通过;全量测试之后仅增加能力标识及调整导出说明,最终 API 生产镜像构建通过。
|
||||
- 前端 TypeScript、ESLint、6 项单元测试及生产 Docker 构建通过。9 项诊断页面测试、12 项两版界面测试、4 项 GPU 核对回归共 25 项通过;修正截图动画后另复验 1 项视觉用例。
|
||||
- 真实 API 对 1 台设备和 3 台桌面核验近 24 小时查询、全部分页、5 类来源筛选与 ZIP 内容一致;临时运维账号可读/导出,临时只读账号 403,未登录 401。临时账号和会话已清理,保留审计。
|
||||
- 真实浏览器核验:通用版 5 个入口、超级管理员高级版 14 个入口、设备/桌面深链、第二页、详情、筛选失效、实际下载、只读角色限制;1440/390 宽度及深浅主题通过,无脚本错误或失败 API 响应。
|
||||
- 进一步通过实际页面确认并提交 **1 次 `collect-logs`**,`qt-platform/0.4.0` 真实盒子成功回执,页面展示日志并下载诊断包。导出中有 1 条对应日志,日志尾部 11981 个字符,内容保留在本机私有目录;公共证据仅保存元数据和收起日志后的截图。
|
||||
- 采集完成后,原管理员密码/角色、桌面连接配置、用户/授权、设备身份/配置版本及告警规则仍与更新前一致。任务数 63→64,唯一新增任务为此次成功日志采集;快照任务数不变。未发送 VM 电源、GPU 切换、会话重置或终端重启。
|
||||
|
||||
证据:[真实接口核验](evidence/cloud-diagnostics-20261111/live-verification.json)、[真实页面核验](evidence/cloud-diagnostics-20261111/browser-verification.json)、[实际采集回执](evidence/cloud-diagnostics-20261111/collection-verification.json)、[采集后的状态核对](evidence/cloud-diagnostics-20261111/collection-state-verification.json)、[页面截图](evidence/cloud-diagnostics-20261111/live-diagnostics-light.png)。
|
||||
|
||||
## 发布与回退
|
||||
|
||||
从上一轮已部署源码 `.local/editions-review/release` 生成隔离发布目录,只叠加本轮 17 个文件;基线 API 重建二进制 SHA256 与更新前实际运行二进制一致。没有包含其他会话正在修改的终端/串流代码。
|
||||
|
||||
更新前备份:`.local/live/backups/before-diagnostics-20261111-174813.dump`,已校验归档目录。旧 API/控制台镜像均保留标签 `before-diagnostics-20261111-174813`。此次只更新 API、控制台,没有数据库结构变更,未重启 PostgreSQL,业务库 `fsync=on`。
|
||||
|
||||
需要回退时使用该标签的两套镜像和本轮保留的 compose 配置,仅重建 API/控制台;不要恢复整个业务库或使用 MFA 之前的 API。镜像身份与文件校验见[发布记录](evidence/cloud-diagnostics-20261111/deployment.json)和[文件清单](evidence/cloud-diagnostics-20261111/manifest.json)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 通用版与高级版:首批实现
|
||||
|
||||
日期:2026-11-11。真实控制台:`http://127.0.0.1:5182`。
|
||||
|
||||
## 使用入口
|
||||
|
||||
登录后,侧栏上方可切换“通用版 / 高级版”。新账号默认进入通用工作台;浏览器按管理员账号记住选择。直接打开旧的专业页面仍可访问,并切到高级导航。两版共用真实后台、数据、任务和审计,切换界面不会获得额外权限。本期实现的是两种操作界面,尚未实施商业许可、收费或组织功能包限制。
|
||||
|
||||
## 通用版
|
||||
|
||||
| 页面 | 已接入的真实功能 |
|
||||
| --- | --- |
|
||||
| 工作台 `/workbench` | 运行中的桌面、在线设备、活动连接;员工分配、设备检查和问题处理捷径;未分配员工、离线/待激活设备、未确认任务摘要 |
|
||||
| 员工与桌面 `/people` | 查找、添加员工;先选桌面、查看方案,再追加或撤销指定授权;桌面名称、使用人、电源和连接状态;单台开机、正常关机、重启的确认提交 |
|
||||
| 终端设备 `/devices` | 查找设备、在线状态、使用人、当前桌面;登记设备并显示一次性激活密钥;进入指定设备诊断 |
|
||||
| 问题处理 `/help` | 只读检查设备启用、在线、员工关联、员工启用、有效桌面授权、电源、初始化及已知连接服务状态;当前触发中告警前 20 条与总数 |
|
||||
| 操作记录 `/records` | 人员/配置审计和设备/桌面任务两类查询,服务端分页与关键词检索,展示实际结果和操作人 |
|
||||
|
||||
员工新增、授权、设备登记仍要求超级管理员;桌面电源允许运维人员;只读人员只能查看。本人密码、双重验证和主题切换在两版均保留,超级管理员也能从通用版顶部进入系统设置。
|
||||
|
||||
授权复用已有批量增减接口,只修改选定员工与桌面的关系,保留其他授权、员工状态和关联设备。查看方案不会写入;保存成功不代表桌面已经开机或完成串流。撤销会作废未消费的旧票据,已有串流不被强制断开。
|
||||
|
||||
电源操作提交后等待实际任务回执;界面不会将提交成功显示为设备已执行成功。存在排队、执行中或结果未知的关联任务时,通用版阻止再次操作。生产环境验收没有操作业务桌面的电源。
|
||||
|
||||
连接服务为“未检测”时不能认定桌面不可用;首页展示真实电源运行数量,详细诊断保留“尚未确认就绪”的说明。设备在线、服务状态通过也不能替代实际网络、画面与输入延迟验收。全局数据或告警读取失败时明确报错,不显示“没有告警”作为正常结论。
|
||||
|
||||
## 高级版
|
||||
|
||||
保留 13 个专业入口:总览、桌面、远程会话、终端、终端策略、计算资源、GPU、监控告警、模板、桌面池、用户授权、任务审计和系统设置。导入、批量电源预检、连接配置、规格、快照、模板/池、指标/规则、管理账号与登录会话等继续使用原有实现及验收范围。
|
||||
|
||||
本轮另补齐“任务与审计 → 执行任务 → 任务详情 → 核对显卡准备结果”,接入已有 `POST /tasks/{id}/gpu-reconcile`。仅超级管理员/运维人员可核对 `gpu-prepare` 类型且结果未知的任务。提交前说明:只读取原 PVE 任务与组内实际电源状态,不重发开关机;核对成功结束未确认流程并释放预留,原任务标记失败,需要重新准备时另行发起。缺少原 UPID、原任务未结束、实际电源未知时显示后台拒绝,保留待查状态。
|
||||
|
||||
## 测试与真实验收
|
||||
|
||||
- 前端 lint、TypeScript、生产 Docker 构建通过;6 项单元测试通过。
|
||||
- 12 项版本/通用页面浏览器测试、4 项 GPU 核对浏览器测试,以及 18 项双重验证、任务历史、审计、在线会话回归通过,共 34 项。首页摘要调整后另复验默认入口、账号隔离及深浅窄屏 3 项。
|
||||
- 4 项 GPU 服务端集成测试通过,包括未知提交不重放、原任务无法读取时拒绝、可核对后释放预留。使用一次性 PostgreSQL 与 PVE 测试服务,没有对真实业务库运行集成测试。
|
||||
- 真实浏览器完成:登录默认通用、两版切换、按账号记忆、临时员工新增、查看方案无写入、两次追加授权、仅撤销其中一个、真实审计查询。临时员工与其授权已清理,保留操作审计。
|
||||
- 真实 1440 / 390 宽度、深浅主题及五个通用页面检查通过;无脚本错误或失败 API 响应。
|
||||
- 原账号、授权、桌面连接配置、设备身份及告警规则快照一致;API 镜像与数据库容器保持原部署,业务库 `fsync=on`。此次只发布经过隔离比对的控制台改动。
|
||||
|
||||
证据:[浏览器验收](evidence/cloud-editions-20261111/browser-verification.json)、[原状态核对](evidence/cloud-editions-20261111/state-verification.json)、[真实通用工作台](evidence/cloud-editions-20261111/live-common-workbench-dark.png)、[高级界面](evidence/cloud-editions-20261111/live-advanced-overview.png)。
|
||||
|
||||
## 后续边界
|
||||
|
||||
按用户要求,本轮不接模型,不展示假的“一句话 AI”。现有向导为明确选择对象的真实授权流程,跨步骤方案持久化、模型解释、受控脚本目录尚未实现。当前尚未把员工关联设备、员工停用、批量业务操作简化到通用版,这些仍在高级版完成。
|
||||
|
||||
高级版页面归类不改变原未完成功能的状态:通用 GPU 绑定与健康、扩展终端策略、外部通知、OTA、目录认证、公共池自动领取/还原、自动初始化、独立交付等继续按原计划逐项完成。GPU 核对以隔离集成与页面测试验收,没有为验收人为制造真实显卡切换超时。专用 VM、Windows 副本、额外存储准备和真实恢复验收仍按用户要求暂缓。
|
||||
@@ -0,0 +1,126 @@
|
||||
# 通用版、高级版与一句话助手设计
|
||||
|
||||
日期:2026-11-11。状态:两版界面及首批真实业务流程已按[实施记录](cloud-editions-20261111.md)落地;本文其余方案对象、模型与脚本内容仍为后续设计。用户已明确本轮不接模型,先完成通用/高级界面的区别。
|
||||
|
||||
## 产品定位
|
||||
|
||||
通用版面向不熟悉虚拟化的日常管理员,以“开通员工、分配桌面、查看设备、处理问题”组织流程。高级版面向 IT 运维,提供节点、模板、存储、规格、GPU、策略、监控和任务等详细管理。
|
||||
|
||||
建议使用同一套 Go 后台、数据库、任务与审计,两套导航和操作流程。现有控制台作为高级界面的基础,新增通用工作台。界面模式与角色权限分别管理:只读账号切换到高级界面仍只有只读权限;通用版管理员发放桌面仍需要后端授权。
|
||||
|
||||
第一期以“通用模式 / 高级模式”落地,设置默认入口并记住个人选择;需要商业版本销售时,再配置组织可用的功能包及服务端许可校验,避免维护两个代码分支。没有高级版授权的安装不能通过切换模式或直接调用接口开启受限功能。
|
||||
|
||||
## 两版功能划分
|
||||
|
||||
| 业务 | 通用版 | 高级版 |
|
||||
| --- | --- | --- |
|
||||
| 首页 | 健康摘要、常用操作、需要处理的事、助手 | 资源水位、节点、任务、详细告警及助手 |
|
||||
| 员工与桌面 | 添加员工、分配/撤销桌面、关联盒子 | 用户批量导入、批量授权、分组筛选及详情 |
|
||||
| 桌面管理 | 业务名称、使用人、是否运行、能否连接;开机/正常关机/重启 | VMID、节点、模板、池、规格、批量预检及维护详情 |
|
||||
| 新增桌面 | 选择人数与用途,优先分配已有桌面;已就绪模板可进入发放向导 | 节点、存储、模板版本、规格及池成员配置 |
|
||||
| 终端管理 | 设备在线状态、名称、使用人、激活向导 | 分组、策略版本、部署回执、系统指标、诊断日志 |
|
||||
| 故障处理 | “桌面打不开 / 设备离线”等引导,说明原因和下一步 | 原始指标、关联事件、任务、诊断及专业核对入口 |
|
||||
| 监控 | 简明健康状态、待处理事件 | 趋势、阈值规则、确认、历史和缺失采样详情 |
|
||||
| GPU/存储/快照 | 常规业务页通过可用性和限制提示体现 | 提供专业页面;具体操作按已实现及已验收能力开放 |
|
||||
| 记录 | 操作结果、谁操作、何时完成 | 全历史检索、CSV、任务关联与审计 |
|
||||
| 账号安全 | 本人改密、双重验证;管理账号按角色开放 | 同样保留安全能力,并展示详细账号及会话管理 |
|
||||
|
||||
通用版减少术语与必填参数,仍明确区分“桌面正在开机”“连接服务未就绪”“设备离线”和“数据已过期”。“断开连接”“重启桌面”“还原数据”分别命名,不使用容易混淆的“一键修复”。
|
||||
|
||||
现有未完成或暂缓的能力继续保留边界:真实克隆与快照还原验收、公共池自动领取/还原、通用 GPU 绑定、OTA、外部通知等不会因为新增助手变成可用。当前已有指定 GPU 的桌面准备与切换能力,也不能推广为任意显卡都可自动分配。
|
||||
|
||||
## 通用版页面交给 UI 的内容
|
||||
|
||||
建议只保留五个一级入口:工作台、员工与桌面、终端设备、问题处理、操作记录。账号菜单保留安全设置,接入配置由有权限的管理员通过设置进入。
|
||||
|
||||
工作台结构:
|
||||
|
||||
```text
|
||||
工作台 [账号] [设置]
|
||||
|
||||
今天的平台情况
|
||||
[可用桌面] [在线设备] [正在使用] [需要处理的问题]
|
||||
|
||||
你想完成什么?
|
||||
[例如:给张三分配一台办公桌面 ] [生成方案]
|
||||
[开通新员工] [分配已有桌面] [批量开机] [桌面打不开]
|
||||
|
||||
常用操作 需要处理的事
|
||||
添加员工 / 激活盒子 设备离线、授权缺失、失败任务
|
||||
|
||||
最近操作
|
||||
对象、操作、当前进度、结果、查看详情
|
||||
```
|
||||
|
||||
生成方案后以卡片显示:目标、推荐做法、影响对象、需要的资源、尚缺条件、执行步骤。主要按钮为“执行方案”“修改需求”,旁边提供“查看详情”。有未满足的条件时显示具体下一步,执行按钮不可用。
|
||||
|
||||
高级版助手可使用同样的卡片,额外展开对象标识、节点/存储、参数、检查结果和底层任务。通用版默认展示用户能理解的业务名称,详情仍可追溯。
|
||||
|
||||
## 一句话助手如何工作
|
||||
|
||||
```text
|
||||
用户描述 → 识别意图和缺失信息 → 读取真实资源与权限
|
||||
→ 生成方案 → 预检与展示 → 用户执行 → 逐步任务与结果
|
||||
```
|
||||
|
||||
脚本或工作流负责检查和执行。理解自由描述、解释结果需要接入语言模型;固定场景也可通过同一套表单向导操作。两者共用执行能力,模型不可用时仍可使用明确的业务向导。
|
||||
|
||||
默认先生成方案。查状态、解释已有告警等读取请求直接返回结果;修改配置、授权或电源的请求先展示具体对象与影响,再由用户点击执行。涉及重启、断开或数据恢复时,明确展示会中断什么、会改变什么。
|
||||
|
||||
### 示例:给张三分配一台办公桌面
|
||||
|
||||
1. 匹配真实用户;同名时提供候选选择。用户不存在时,补充登录名等必要信息,生成“创建用户并分配”的方案。
|
||||
2. 查看当前授权与已有可分配桌面。已有合适桌面时优先复用,避免每次都新建 VM。
|
||||
3. 需要新建时检查已登记模板、存储、克隆权限及当前支持范围;条件不足就展示缺少什么。普通 Windows 业务镜像尚未完成模板验收时,不能直接承诺自动发放。
|
||||
4. 方案卡片说明分配给谁、使用哪台桌面、是否新建、是否关联终端、是否需要开机。
|
||||
5. 执行后分别展示“用户已创建 / 授权已保存 / 终端已关联 / 桌面可连接”等实际结果。
|
||||
|
||||
上述为交互示例,不代表当前已有名为张三的用户或空闲资源。人数、规格和模板名称均须由真实环境或管理员预设产生。
|
||||
|
||||
### 首批适合接入的六种请求
|
||||
|
||||
| 用户说的话 | 方案或返回结果 | 当前接口基础与实施范围 |
|
||||
| --- | --- | --- |
|
||||
| 给张三分配一台办公桌面 | 匹配人员、选桌面、授权;可选关联盒子 | 用户/桌面查询、现有绑定接口;新增跨步骤方案编排 |
|
||||
| 把研发组的桌面都开起来 | 固定目标清单、逐台预检、可执行/跳过原因 | 已有批量电源预检与提交;“研发组桌面”的映射不明确时需选择目标 |
|
||||
| 这个盒子为什么没有桌面 | 注册/启用、绑定用户、授权、心跳等只读检查 | 现有终端/用户/授权信息;新增诊断规则 |
|
||||
| 看看今天有哪些异常 | 汇总真实告警、失败任务、离线设备 | 已有告警与任务历史接口;说明查询时区和时间范围 |
|
||||
| 给这个员工换一台桌面 | 展示旧/新授权及当前使用情况,确认后改授权 | 现有绑定/批量授权;不将撤销授权误写成旧 VM 已删除或串流已断开 |
|
||||
| 我想再增加几个人使用 | 现有资源与模板前提检查、复用/新增建议、实施步骤 | 可做规划;容量结论需明确估算依据,真实克隆仍按现场验收限制开放 |
|
||||
|
||||
预设“标准办公”“开发”等用途时,规格、允许模板、是否允许新建和资源预留由管理员配置。预设未配置时只询问影响方案的关键信息,不凭模型常识替客户决定资源或软件授权。
|
||||
|
||||
## 接口与脚本的实现方式
|
||||
|
||||
增加业务动作目录,由代码定义每种动作的参数、权限、预检和执行器。语言模型只能从目录中选择动作和填写参数,执行器再次验证。
|
||||
|
||||
优先复用当前平台接口:用户和桌面查询、用户绑定、CSV 预检、批量电源预检/任务、模板验证和克隆预检等。脚本适合已有接口未覆盖的受控运维步骤,以版本化模板登记;每个模板定义输入、执行位置、超时、返回结构及允许的任务类型。
|
||||
|
||||
脚本通过平台执行器获得限定的参数和凭据,凭据保留在服务端。模型生成的自由文本不能直接作为 Shell、PowerShell、SSH 命令执行;界面无需让普通用户复制命令。
|
||||
|
||||
建议新增的后台对象及接口(设计,当前尚未存在):
|
||||
|
||||
| 对象/接口 | 内容 |
|
||||
| --- | --- |
|
||||
| 业务动作目录 | 查询范围、参数定义、权限、执行器、版本、预检要求 |
|
||||
| `POST /assistant/plans` | 输入需求,生成方案草稿;缺参数时返回简短问题或候选对象 |
|
||||
| `POST /assistant/plans/{id}/check` | 对已确定的对象和动作预检,保存检查时间与资源版本 |
|
||||
| `POST /assistant/plans/{id}/execute` | 对同一版本的方案进行幂等提交,重新核对权限和资源 |
|
||||
| `GET /assistant/plans/{id}` | 方案步骤、执行状态、关联任务和结果 |
|
||||
| 方案及步骤存储 | 意图、对象标识、参数、预检快照、执行人、每步结果及任务关联 |
|
||||
|
||||
执行权限取当前真实登录身份,同时检查版本可用功能;模型和脚本不能越过服务端 RBAC。只读账号可查和生成允许范围内的方案,执行写操作仍会被后端拒绝。日志、设备名称、用户描述和模型输出都按数据处理,不能改变动作权限。
|
||||
|
||||
方案必须保存确定的对象标识和版本。“所有设备”也先展开成可核对的清单,不在执行时重新解释为另一批对象。资源、授权或方案发生变化时重新预检。脚本版本在方案内固定,执行前变更不能悄悄套用。
|
||||
|
||||
每步拥有稳定操作标识并关联现有 task/command;HTTP 提交成功只代表已提交。对于尚无原生幂等支持的接口,编排层需保存调用状态并提供结果核对。结果未知的步骤先核对,不重发;部分成功时保留成功结果、停止依赖步骤并给出具体处理建议。跨系统工作流不承诺全局事务回滚,补偿动作单独展示和执行。
|
||||
|
||||
## 按项实施与验收
|
||||
|
||||
1. 通用工作台与导航:接真实数据、业务名称、常用操作,保留高级界面;测试权限、空态、失败、深浅主题和窄屏。
|
||||
2. 业务向导与方案对象:先完成“已有桌面授权”“批量开机预检”“终端无桌面诊断”。测试预检无业务副作用、对象唯一性、重复点击和逐步结果。
|
||||
3. 接语言模型:使用真实模型服务,验证输出动作参数,展示可执行方案;测试缺信息、同名对象、服务失败、越权请求及日志中的恶意指令。模型供应方式、外发数据范围、密钥和费用上限由部署配置决定。
|
||||
4. 扩展脚本模板与流程:选择一个现场可验证的流程接入,测试模板版本、超时、离线、部分成功和结果核对,再开放下一项。
|
||||
5. 商业版本授权:功能分包、服务端校验、到期与升级流程,单独验收。账号安全与审计保留为两版基础能力。
|
||||
|
||||
按用户要求,每项完成并通过对应测试后再进入下一项。原先暂缓的专用 VM、Windows 副本、存储准备和真实还原验收继续暂缓,不因本设计自动恢复。
|
||||
@@ -0,0 +1,159 @@
|
||||
# 云管功能核对与本轮实现(2026-11-10)
|
||||
|
||||
依据《VDI云桌面软件开发方案(1)》第 4、5 章、《VDI云桌面完整方案-云管端》第 3、4 章,以及 [控制台功能设计](cloud-console-functional-design-20261109.md)。以下区分代码实现、测试环境验证和真实现场验收。
|
||||
|
||||
## 功能核对
|
||||
|
||||
| 模块 | 当前实现 | 仍缺少的内容 |
|
||||
| --- | --- | --- |
|
||||
| 登录与授权 | 管理员登录、退出、三角色后端校验;账号新增、改名、角色、启停、他人密码重置、本人改密与会话撤销;用户与终端、多桌面绑定;账号/IP 登录限流 | AD/LDAP、自助找回、密码到期、多因素认证;无永久删除账号入口 |
|
||||
| 资源与已有桌面 | PVE 节点/VM 同步、纳管、电源任务;最多 50 台批量开机/正常关机/重启、逐台预检与批次结果;网页接入配置、关机规格变更与磁盘扩容任务;现场三台桌面已接通 | 多集群、备份/迁移与完整生命周期;现场规格写入缺权限,批量电源真实执行未验收 |
|
||||
| 快照与手动还原 | 真实列表、关机创建/删除/还原、预检/确认、异步任务、幂等与只读核对;还原撤销票据并要求重新确认初始化 | 公共池自动退出还原;现场缺快照权限,VM100 不支持快照;真实写入和磁盘内容恢复待专用 VM 验收 |
|
||||
| 镜像模板(本轮) | 发现、登记、版本、系统标记、配置验证、启停;拒绝普通 VM、锁定和直通模板 | 制作模板、镜像发布流水线、连接服务自动探测 |
|
||||
| 克隆发放(本轮) | 同节点完整/链接克隆、存储与权限预检、异步 UPID、幂等提交、失败与未知结果区分、已知 UPID 重新核对 | 跨节点调度、创建向导直接指定规格、自动初始化与配对、批量发放;现场真实克隆未验收 |
|
||||
| 桌面连接(本轮) | 管理员修改端点和分辨率/FPS;新克隆默认禁止连接,确认初始化后放行;活动会话时禁止更改 | VM 内自动初始化、自动探测 Sunshine、自动配对 |
|
||||
| 桌面池(本轮) | 专属/公共类型、容量、默认模板、启停、成员、已授权和活动数量、创建预留 | 自动领取、用户与池的授权规则、退出还原、自动回收、扩容调度、池删除 |
|
||||
| 终端与会话 | 设备登记、注册、心跳、名称/分组修改、停用/重新激活与旧令牌撤销;授权列表、票据、串流状态、重启/会话重置/诊断日志回执;运维策略模板、按设备/分组下发与应用回执;90 天会话历史与名称快照 | Wi-Fi、USB、应用与显示策略;重新激活需管理员更新设备身份配置 |
|
||||
| 桌面使用者 | 新增、姓名编辑、启停、更换/解除关联终端、多桌面授权、批量追加/撤销、CSV 用户与授权导入、未消费票据作废 | 用户目录集成、用户自主登录模式 |
|
||||
| GPU | 已部署真实节点 PCI 显卡清单、IOMMU 分组、当前/待应用配置引用与纳管关系;直通配置由现场手工维护 | 实际驱动/运行占用、独占锁与绑定/解绑、健康指标和 GPU broker;配置引用不表示可分配 |
|
||||
| 监控告警 | 节点历史曲线、CPU/内存/存储与节点异常/终端离线规则、控制台告警确认及自动恢复;终端 CPU/内存/SoC 温度/运行时长、历史曲线和 30 天留存,以及终端 CPU/内存/SoC 温度持续告警;缺失值保持空 | VictoriaMetrics、外部通知、完整串流 FPS/RTT 采集 |
|
||||
| OTA | 尚无发布闭环 | 包签名、适配机型、灰度批次、暂停/恢复、Agent 安装、A/B 回滚 |
|
||||
| 批量与交付 | 本机 Docker/真实 PostgreSQL、用户/终端/授权 CSV 预检及整批导入、操作审计与任务记录、审计全历史分页/多条件查询与 CSV 导出(单次 10000 条) | 独立服务器、MQTT/Rust Agent、七服务独立部署、备份恢复演练、离线交付 |
|
||||
|
||||
画质/延迟、上网代理、软件白名单和品牌兜底画面继续按原有计划单独处理。
|
||||
|
||||
## 使用路径
|
||||
|
||||
控制台地址仍为 `http://127.0.0.1:5182`,使用原管理员账号。新增「镜像模板」「桌面池」,桌面管理新增「从模板创建」及详情中的「配置连接」。
|
||||
|
||||
用户已确认复用现有 Windows 镜像作为母盘。11-10 再次只读核实:VM100 `win10-test` 正在运行,PVE 标记 `template=0`;系统盘为 `local-lvm:base-100-disk-0,size=60G`,配置包含 GTX 965M 的 `hostpci0` 及相关 `args`。磁盘名称带 `base-` 不等于该 VM 已是平台可登记的专用模板。无需重新安装 Windows,后续以该母盘准备独立模板副本,核查副本的系统身份、账号和 Sunshine 配对,移除或重新安排物理显卡绑定;原 VM100 不在本次核查中转换或修改。现有平台 Token 的克隆权限和存储预检限制仍需解决。
|
||||
|
||||
1. 在 PVE 准备专用模板,不要将正在使用的 VM100/101/102 直接转换为模板。
|
||||
2. 在「镜像模板」发现并登记名称、版本和系统。登记检查模板标志、锁、磁盘容量、直通设备和宿主脚本;模板变动后重新验证。
|
||||
3. 在「桌面池」设置容量及可选默认模板,通过「成员」加入已有桌面。成员变更不自动增删用户授权。
|
||||
4. 创建向导填写名称、VMID、完整/链接克隆和存储,可选加入池。先预检,再提交任务。硬件继承模板,目标固定为源节点。
|
||||
5. 到「任务与审计」查看最终结果。成功只表示 VM 克隆并纳管,不表示 Sunshine 或串流可用。
|
||||
6. 完成操作系统身份、网络、Sunshine 和终端配对,再保存连接地址及显示参数,确认初始化;随后单独授权用户。
|
||||
|
||||
公共池目前采用管理员分配/回收,没有自动领取或退出还原。停用池阻止新建,不会中断池内桌面或撤销已有授权。
|
||||
|
||||
## 执行边界
|
||||
|
||||
- 池成员、创建预留、容量编辑使用同一个事务锁,成员加预留不得超额;非空池不能切换类型。
|
||||
- 完整克隆按虚拟磁盘容量(含 EFI/TPM)加 1 GiB 余量检查目标存储,较稀疏占用更保守;链接克隆沿用模板存储并检查余量。PVE 最终校验权限和资源。
|
||||
- 源 VM 需 `VM.Clone`,目标需 `VM.Allocate`,所用存储需 `Datastore.AllocateSpace`;网络还需现场对应桥/SDN 权限。平台不会自行提权。
|
||||
- 网络异常、进程中断或 PVE 任务失败可能留下目标资源,平台不重发克隆、不自动删除 VM;未确认结果继续占预留。
|
||||
- 有 UPID 的未确认任务可点「核对克隆结果」,仅查询原任务并确认目标 VM;没有 UPID、明确失败或残留资源须管理员在 PVE 核对,当前无自动清理/释放预留接口。
|
||||
- VMID 创建记录保留,失败后也不直接复用。相同请求标识重发不创建第二台 VM,参数变化必须换标识。
|
||||
- 只允许一个队列运行者恢复任务。长克隆单独执行,普通管理命令继续处理;当前一次一个克隆,尚无分布式调度。
|
||||
|
||||
## 现场条件与验收
|
||||
|
||||
11-10 只读检查:`starhe` 仅有运行中的 VM100/101/102,未发现模板;平台 Token 具有审计及三台 VM 电源权限,尚无克隆、创建及存储分配权限。10:07 查询 `local`、`local-lvm` 可用空间约 10.5 GiB、7.2 GiB,随现场变化。
|
||||
|
||||
真实接入可验证模板发现、节点存储查询、迁移和页面;不能声称已现场发放新桌面。克隆成功、失败、网络未知及恢复流程由本地 PVE HTTP 测试服务和独立 PostgreSQL 验证。现有业务 VM 未做克隆、关机、重启或模板转换。
|
||||
|
||||
## 迁移与回退
|
||||
|
||||
新增 `image_templates`、`desktop_pools`、`provisioning_jobs`,`desktops` 增加可空 `pool_id` 和默认 false 的 `setup_required`;启动时幂等迁移,原桌面保持连接行为。未删除原字段。
|
||||
|
||||
更新前备份:私有 `.local/live/backups/before-catalog-20261110-095713.dump`;旧 API/控制台镜像标签 `before-catalog-20261110`。不提交含业务数据的备份。
|
||||
|
||||
旧后端严格映射 `SELECT *`,不能直接读取新增列。回退需停服务、另行备份并评估新增业务数据,再恢复配套数据库快照和旧镜像。恢复会覆盖快照后的数据,当前未做恢复演练,不直接删库或回退列。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 专用临时 PostgreSQL 的 Go 全量测试、`go vet` 通过;覆盖 RBAC、克隆最终确认、重复提交、模板变更、权限/空间不足、容量预留、失败/未知结果、已知 UPID 核对及初始化门禁。
|
||||
- 队列修改后另验长克隆期间普通电源任务继续完成;测试仅操作演示数据和本地 PVE HTTP 测试服务。
|
||||
- ESLint、TypeScript、3 项前端单元测试和生产构建通过;9 项浏览器用例通过,包含新表单和既有会话、主题与窄屏回归。浏览器用例拦截接口,不操作现场资源。
|
||||
- API/控制台 Docker 镜像构建并更新到 5182,完成新增表迁移。更新时 Docker Desktop/WSL 引擎发生故障,重启 Docker Desktop 后恢复并继续迁移,没有删卷或恢复数据库快照。
|
||||
- 真实 API 验证登录、模板发现、存储查询、空池创建/扩容/成员保存/停用;只创建的临时空池已定向清理,审计保留。当前有 3 台桌面、0 模板、0 池、0 克隆任务。
|
||||
- 更新前后桌面连接配置、用户授权和终端身份映射一致,三台桌面运行、终端在线。真实 API 的 8 个深浅主题页面及 2 个窄屏页面无脚本错误或失败响应。
|
||||
|
||||
证据:[真实 API 核验](evidence/cloud-catalog-20261110/live-verification.json)、[真实页面核验](evidence/cloud-catalog-20261110/browser-verification.json)、[现场模板页](evidence/cloud-catalog-20261110/live-templates-dark.png)、[现场桌面页](evidence/cloud-catalog-20261110/live-desktops-light.png)、[测试数据池页面](evidence/cloud-catalog-20261110/fixture-pools-light.png)。`fixture-` 图片使用隔离测试数据,不代表现场已有对应模板或桌面池。
|
||||
|
||||
|
||||
## 后续功能:管理员账号维护(11-10)
|
||||
|
||||
已实现真实 API、PostgreSQL 持久化及控制台页面,入口为「系统设置 → 管理员账号」。超级管理员可新增账号、修改显示名称/角色、启停账号和重置其他管理员密码;登录名创建后固定。运维和只读角色不能维护管理员。所有角色通过右上角钥匙按钮修改本人密码,必须验证原密码。
|
||||
|
||||
- 新建默认只读角色;新设密码至少 12 个字符且 UTF-8 不超过 72 字节。既有 root 账号密码保留,启动时不覆盖已有账号密码或启用状态。
|
||||
- 角色或启用状态变更、重置密码、本人改密立即撤销该账号全部会话;仅修改显示名称保留登录。原密码输错提示错误且不退出当前登录。
|
||||
- 禁止停用自己或修改自己的角色;事务锁与最新权限复核防止并发停用导致没有启用的超级管理员。登录插入会话与重置/停用串行,避免旧凭据在撤销后新建会话。
|
||||
- 停用代替永久删除,保留审计;重新启用后可使用当前密码登录。账号修改与审计同事务,响应和审计不包含明文密码或密码哈希。
|
||||
- 新增 `administrators.enabled boolean NOT NULL DEFAULT true`,既有账号默认启用。更新前备份 `.local/live/backups/before-admin-20261110-102640.dump`,旧镜像标签 `before-admin-20261110`。旧 API 的严格 `SELECT *` 映射不能直接读取新增列,回退仍需评估数据并配套数据库快照;本轮未执行回退或删除业务卷。
|
||||
|
||||
Go 全量测试与 `go vet` 通过,使用独立临时 PostgreSQL,覆盖账号生命周期、权限/密码校验、会话撤销及并发超级管理员保护。前端 ESLint、类型检查、3 项单元测试、生产构建和 13 项隔离接口浏览器回归通过。全目录浏览器命令曾包含只适用于 demo 的旧用例,被 production 模式守卫阻止;没有执行其电源操作。随后显式选择管理员、模板池及 UI 三组用例,13/13 通过。
|
||||
|
||||
已更新本机 5182 API/控制台,用临时账号完成真实新增、角色修改、密码重置、停用/恢复、本人改密、旧密码拒绝、全部旧会话失效与权限拒绝验证。临时账号及其会话已定向清理,审计保留;原 root 账号字段和密码哈希指纹、桌面配置及用户绑定与更新前完全一致。查询时 VM100/101/102 均运行,1 台终端在线,没有发送 VM 电源、克隆或 GPU 操作。
|
||||
|
||||
证据:[真实账号接口核验](evidence/cloud-administrators-20261110/live-verification.json)、[隔离测试深色页面](evidence/cloud-administrators-20261110/fixture-accounts-dark.png)、[隔离测试窄屏页面](evidence/cloud-administrators-20261110/fixture-accounts-mobile.png)。`fixture-` 图片中的运维/财务账号为测试数据。
|
||||
|
||||
真实浏览器补充:深浅主题、390px 窄屏及新增/本人改密弹窗均通过;弹窗取消未发送修改请求,无脚本错误或失败响应。见[页面核验](evidence/cloud-administrators-20261110/browser-verification.json)、[真实账号页](evidence/cloud-administrators-20261110/live-accounts-light.png)、[真实窄屏新增弹窗](evidence/cloud-administrators-20261110/live-create-mobile.png)。
|
||||
|
||||
## 后续功能:终端与用户维护(11-10)
|
||||
|
||||
新增终端资料编辑、停用/重新激活,用户姓名编辑/启停和批量追加/撤销桌面授权;原有换绑和授权流程增加确认及旧票据作废。18 项隔离浏览器回归、独立数据库 Go 全量测试及静态检查通过。使用路径、重新激活设备配置步骤、执行边界与回退见[本轮说明](cloud-lifecycle-20261110.md)。
|
||||
|
||||
已更新 5182 并以 2 台临时设备、2 位临时用户完成真实流程验收;临时数据清理后原设备身份、管理员密码、桌面配置、用户与授权均一致,原盒子心跳继续推进、3 台 VM 运行。见[真实核验](evidence/cloud-lifecycle-20261110/live-verification.json)。
|
||||
|
||||
## 后续功能:终端运维策略(11-10)
|
||||
|
||||
「终端策略」已部署 5182:模板版本固定、复制新版本、按设备/分组下发、离线等待、失败原因、重新下发与旧版本恢复。第一批支持心跳、命令轮询、桌面列表刷新三项管理间隔;网络、USB、白名单、显示策略仍待对应客户端能力。旧客户端明确提示更新,不会伪装应用成功。
|
||||
|
||||
真实盒子已更新为 `qt-platform/0.3.0`,在配置版本 2 应用命令 5 秒/列表 20 秒,再在版本 3 恢复默认 30/3/15 秒,均收到设备成功回执并核对设备 0600 策略文件。原身份、root 密码、桌面参数、用户和授权快照保持一致,三台 VM 运行,无活动串流。独立 PostgreSQL Go 全量测试/vet、前端检查/构建、22 项浏览器流程及盒子 5 项 CTest 已通过。见[功能说明](cloud-terminal-policies-20261110.md)与[真实验收](evidence/cloud-policies-20261110/live-verification.json)。
|
||||
|
||||
策略页面深浅主题、390px 窄屏及弹窗已完成真实只读检查,无脚本错误、失败响应或修改请求。见[页面验收](evidence/cloud-policies-20261110/browser-verification.json)。
|
||||
|
||||
## 后续功能:CSV、会话历史与监控告警(11-10)
|
||||
|
||||
用户/终端/授权 CSV 预检与整批幂等提交、90 天会话历史、节点历史采样、告警规则/确认/自动恢复已补齐。具体入口、CSV 表头、留存与验收边界见[本轮说明](cloud-operations-20261110.md)。VictoriaMetrics、外部通知和完整串流指标采集仍未接入;终端系统指标已在后续批次接通。
|
||||
|
||||
已部署本机 5182,并完成真实 CSV 临时身份、会话事件及告警触发/确认/恢复验收;会话使用临时设备合成事件,未开启真实视频串流。临时数据清理后原业务快照一致,原盒子心跳正常、三台 VM 运行。27 项浏览器回归及真实页面深浅/窄屏检查通过,见[真实接口证据](evidence/cloud-operations-20261110/live-verification.json)。
|
||||
|
||||
## 接入配置与规格管理更新
|
||||
|
||||
已部署网页 PVE 接入、桌面 CPU/内存与单磁盘扩容任务、账号/IP 登录限流。真实接入保存、热加载、重启保留、临时账号限流、三台 VM 规格读取与运行拦截已验收。现场 Token 缺规格写权限,未修改任何真实 VM;写入流程仅在隔离测试验证。入口、权限、未知结果和密钥备份边界见[本批说明](cloud-configuration-20261110.md)。
|
||||
|
||||
|
||||
## 后续功能:快照与手动还原(11-10)
|
||||
|
||||
已部署「桌面管理 → 查看详情 → 快照与还原」,接口与页面、权限、磁盘范围、维护互斥和未知结果保护见[说明](cloud-snapshots-20261110.md)。关机无内存快照可创建、删除叶快照和手动还原;本期不支持直通或宿主自定义配置等复杂布局。还原包括快照内的数据盘和配置,不保证只恢复系统盘。后台任务成功需原 PVE UPID 成功及实际状态确认,超时不重发。
|
||||
|
||||
37 项浏览器回归及 Go 全量测试通过;现场仅只读检查,三台 VM 运行、原业务映射一致。当前 Token 缺快照权限,VM100 的 PVE 快照能力检查为不支持,未执行真实 VM 快照写入。公共池自动领取/退出还原仍在计划中。
|
||||
|
||||
|
||||
## 桌面批量电源操作(2026-11-10)
|
||||
|
||||
5182 的「桌面管理」新增跨页选择、批量开机/正常关机/重启和批次历史,每批最多 50 台。逐台真实预检、5 分钟签名凭证、确认后幂等提交;执行前复查,未确认结果不重发并阻止后续修改和新连接。Go 全量与静态检查、前端检查和 45 项隔离接口页面回归通过;真实环境只做预检与取消提交,三台桌面继续运行。新增表、任务状态、备份与现场边界见[功能说明](cloud-batch-operations-20261110.md)。专用测试 VM、Windows 副本和存储准备按用户要求暂缓。
|
||||
|
||||
|
||||
## GPU 资源查看(2026-11-10)
|
||||
|
||||
5182 侧栏新增「GPU 资源」,三角色均可读取节点显卡清单、PCI 地址、IOMMU 分组与当前/待应用的虚拟机配置引用,支持搜索、筛选与详情。现场确认 Intel 核显、GTX 965M 及 Windows VM100 引用;自定义配置导致不完整时明确提示,不将未发现引用视为可分配。只新增 GET 接口,无数据库迁移或 GPU 写入;Go 全量与静态检查、前端检查和 51 项页面回归通过。接口、权限、备份和现场证据见[功能说明](cloud-gpu-inventory-20261110.md)。
|
||||
|
||||
|
||||
## 终端系统指标与历史(2026-11-10)
|
||||
|
||||
5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标仍待接入;系统指标告警已在 11-11 接入,见后续记录。
|
||||
|
||||
|
||||
## 终端系统指标告警(2026-11-11)
|
||||
|
||||
5182「监控告警」新增终端 CPU、内存、SoC 温度规则,按真实新采样计算持续时长;计时不早于规则启用/修改,重复评估不推进,缺失/过期不冒充恢复。支持原有三角色权限、确认、规则启停和 90 天事件历史;摄氏度与百分比分开显示。Go 全量与静态检查、前端检查/构建及 61 项隔离页面回归通过,旧规则/事件/待触发状态的重复升级验证通过。无需更新终端,未操作 VM、磁盘或 GPU。详见[规则与验收边界](cloud-terminal-alerts-20261111.md)。
|
||||
|
||||
11-11 补充:[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)补齐超过最近 1000 条的历史查询,支持时间、操作人、动作、结果和请求标识检索。执行任务历史已在后续批次补齐,见[任务历史查询与 CSV 导出](cloud-task-history-20261111.md)。
|
||||
|
||||
## 管理员在线登录管理(2026-11-11)
|
||||
|
||||
已部署「系统设置 → 管理员账号 → 在线登录」,支持来源、登录/到期时间、分页与指定登录撤销。Go 全量/vet、前端检查/构建、8 项页面回归与真实临时账号双登录撤销验收通过。旧登录来源显示未知,当前登录保护,审计不暴露凭据。见[使用说明与测试证据](cloud-admin-sessions-20261111.md)。
|
||||
|
||||
## 管理员双重验证(2026-11-11)
|
||||
|
||||
已部署右上角「双重验证」:本人可选绑定身份验证器、动态码或恢复码登录、恢复码更新与关闭。Go 全量/vet、前端检查/构建、6 项新增页面和 12 项既有回归通过,并完成真实临时只读账号及实际页面完整验收。root 登录方式保持,见[功能、证据与回退限制](cloud-mfa-20261111.md)。
|
||||
|
||||
11-11 追加:[通用/高级两版界面](cloud-editions-20261111.md)已在 5182 部署,包含通用员工授权向导、设备登记/诊断、简明记录和高级显卡准备结果核对。两版共用真实 API/RBAC;首批 34 项页面测试、6 项单元测试和 GPU 专项集成通过,真实临时员工授权流程验收后已清理。模型暂不接入,剩余高级事项保持原验收边界。
|
||||
|
||||
11-11 追加:[高级版日志与诊断中心](cloud-diagnostics-20261111.md)已在 5182 部署:设备/桌面按唯一 ID 关联五类记录,数据库分页、来源/时间筛选、设备采集回执和完整 ZIP 导出。6 项后端专项、Go 全量/vet、25 项浏览器回归及真实接口/页面验收通过;真实盒子成功完成一次只读日志采集。现有身份、授权、连接配置及告警规则保持,唯一新增任务为该次采集。持续实时日志、全量归档和客体自动采集仍待后续。
|
||||
|
||||
12-11 追加:[通用版固定场景助手向导](cloud-assistant-wizard-20261112.md)已完成代码和隔离页面验收并部署到 5182:输入员工与桌面需求生成授权方案,确认后复用现有绑定接口;未知对象、重复授权、同名桌面和未支持句式不发写请求。15 项通用版浏览器测试、TypeScript、ESLint、单元测试和生产构建通过;真实临时员工授权流程已执行并清理。
|
||||
@@ -0,0 +1,18 @@
|
||||
# GPU 健康、独占资源与终端日志归档
|
||||
|
||||
## 已实现
|
||||
|
||||
- `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标;配置 `VDI_GPU_AGENT_TOKEN` 后,节点采集器通过 `POST /api/v1/gpu-agent/health` 上报近 10 分钟的真实指标,页面才显示 `status=collected`,没有上报时保持 `not_collected`。
|
||||
- `GET /api/v1/resources/gpu-exclusive?node=...` 提供独占组分配前检查:advisory lock、成员完整性、PVE 实际电源、活跃终端会话、未完成任务、冲突原因、`allocationReady` 和 `releaseReady`。接口只读,不会关机、切换显卡或重放任务。
|
||||
- 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`;终端正常运行时可通过设备令牌调用 `POST /api/v1/terminal/logs` 持续上报小批量事件,写入 `terminal_log_entries`。两类记录在 `GET /api/v1/terminal-logs` 统一检索,均按终端和时间建立索引、保留 30 天并执行脱敏。
|
||||
- 高级版 GPU 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。
|
||||
|
||||
## 真实边界
|
||||
|
||||
温度、利用率和显存需要节点采集代理或厂商工具接入,当前 API 明确显示“未采集”。独占资源的释放状态只代表平台检查条件满足,实际 GPU 切换仍走既有终端准备任务和核对流程。
|
||||
|
||||
## 验证
|
||||
|
||||
- `docker run --rm -v "${PWD}:/src" -w /src/platform golang:1.27-alpine sh -c 'go test ./...'` 通过。
|
||||
- `npm run typecheck` 和生产构建通过。
|
||||
- 真实控制台 `http://127.0.0.1:5182` 已重新部署;root 登录后真实读取独占组和 PVE GPU 清单,健康字段显示 `not_collected`,独占组正确报告 VM 101 正在运行导致不可分配。
|
||||
@@ -0,0 +1,49 @@
|
||||
# GPU 清单与配置引用(2026-11-10)
|
||||
|
||||
已部署真实控制台 [GPU 资源](http://127.0.0.1:5182/gpus),侧栏入口为「GPU 资源」。沿用现有深浅主题和图标,三个管理员角色均可查看。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 选择计算节点,读取真实显示控制器类 PCI 设备的厂商、型号、PCI 地址、厂商/设备 ID 和 IOMMU 分组;分组 0 正常显示,无分组报告显示「未报告」。
|
||||
- 搜索显卡、PCI 地址、虚拟机名称/VMID 和纳管桌面名称,按配置引用状态筛选。
|
||||
- 检查该节点所有当前 PVE 凭据可见的 QEMU VM,包括未纳管 VM、关机 VM 和模板;详情区分当前配置、待应用配置与待移除项。
|
||||
- 解析 `hostpciN` 的短地址、完整域地址、整槽所有功能、分号多地址、PCI 映射和介导设备标记;显示引用 VM、配置槽位、平台纳管关系和映射来源。
|
||||
- 展示已检查/可见 VM 数、读取时间与检查失败原因,超过 90 秒提醒刷新。手动刷新重新读取,页脚明确说明按需读取,无后台高频 GPU 采集。
|
||||
|
||||
状态含义:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| 有配置引用 | 查到一台 VM 的当前或待应用配置引用 |
|
||||
| 多 VM 引用 | 查到多台 VM 的配置引用,需进一步核查;不直接判定运行冲突,介导设备也可能有合法共享 |
|
||||
| 未发现配置引用 | 本次 QEMU 配置检查无异常且没有查到引用,不表示设备可分配 |
|
||||
| 待核查 | 没有查到引用,但检查范围不完整,例如部分 VM 不可读、映射不可见或有自定义参数 |
|
||||
|
||||
## 接口和边界
|
||||
|
||||
新增管理员鉴权后的 `GET /api/v1/resources/gpus?node=<节点>`,返回节点、读取时间、检查完整性、可见/已检查 VM 数、显卡及逐条引用、检查问题;没有 GPU 写接口。请求节点必须在实时 PVE 资源清单中可见。
|
||||
|
||||
读取 PVE `/cluster/resources`、`/nodes/{node}/hardware/pci?verbose=1`、`/nodes/{node}/qemu/{vmid}/pending` 和根路径权限;按需读取 `/cluster/mapping/pci`。PCI 清单需要根路径 `Sys.Audit` 等 PVE 允许的审计权限,判断全局 VM 可见范围检查根路径 `VM.Audit`;映射仍受 `Mapping.Audit/Use/Modify` 可见性约束。平台不自动增加权限。
|
||||
|
||||
每节点每次最多检查 200 台 VM,以 4 个并发读取任务、12 秒总预算控制请求;超出范围、读取失败或映射候选不唯一时标明不完整。自定义 `args` / `hookscript` 的额外引用不解析,不将其原文输出至接口。前端请求失败显示明确错误,旧清单清除,避免把旧记录当作本次结果。
|
||||
|
||||
本轮不查询宿主驱动实际绑定、运行中 QEMU 的实际设备、LXC 设备挂载、GPU 温度/利用率/显存,也不确认硬件健康、IOMMU 隔离可用性或硬编能力。多次 PVE 读取不是原子状态快照。配置引用、运行状态和可分配性是不同信息;绑定/解绑、独占锁、GPU broker 和健康采集仍待实现。
|
||||
|
||||
此功能无数据库表或字段迁移,不创建任务、审计或显卡分配记录。API 契约见 [OpenAPI](openapi/platform.v1.json)。
|
||||
|
||||
接口语义参考 Proxmox 官方 [PCI API 源码](https://github.com/proxmox/pve-manager/blob/master/PVE/API2/Hardware/PCI.pm)、[映射 API 源码](https://github.com/proxmox/pve-manager/blob/master/PVE/API2/Cluster/Mapping/PCI.pm) 和 [PCI 直通文档](https://github.com/proxmox/pve-docs/blob/master/qm-pci-passthrough.adoc)。
|
||||
|
||||
## 验证与部署
|
||||
|
||||
- 本地 PVE HTTP 测试服务和独立临时 PostgreSQL 覆盖只读请求、管理员鉴权及只读角色访问、输入校验、PCI 功能/域地址、映射解析、待应用与待移除、关机 VM 引用、未纳管 VM、多引用、权限缺失、配置失败及原始参数不泄露。Go 全量测试和 `go vet` 通过。
|
||||
- ESLint、TypeScript、3 项前端单元测试和生产构建通过;新增 6 项 GPU 浏览器测试,连同已有用例共 51/51 通过。覆盖错误重试、过期、空态、只读角色、搜索、详情与 390px 浅色窄屏;页脚刷新说明修正后,GPU 6 项用例再次通过。
|
||||
- API/控制台镜像已更新到 5182,保留原 PostgreSQL 容器和业务卷。真实浏览器深浅主题与 390px 页面检查通过,无脚本错误或失败响应。
|
||||
- 现场 `starhe` 读取 2 块显卡、检查 3 台 VM:Intel `0000:00:02.0` / IOMMU 0;NVIDIA GTX 965M `0000:01:00.0` / IOMMU 2,当前配置引用为 VM100 `win10-test` / 已纳管 Windows 10。
|
||||
- VM100 有自定义参数,因此页面说明检查未完整,Intel 显示「待核查」,没有标成可分配。没有改动显卡直通或启动任何 VM 电源/快照/磁盘操作。
|
||||
- 三台 VM 仍运行,配置摘要和原管理员密码哈希指纹、桌面配置、授权、终端身份与更新前一致;盒子心跳正常,任务及快照数量未增加。
|
||||
|
||||
更新前数据库备份:私有 `.local/live/backups/before-gpu-inventory-20261110-170206.dump`;已验证备份目录可读。旧 API/控制台镜像标签均为 `before-gpu-inventory-20261110-170206`。本轮无数据库结构变更,可在核实版本配套后回退这两个应用镜像;无须因本功能删除或重建数据库。备份恢复演练尚未执行。
|
||||
|
||||
证据:[51 项页面回归](evidence/cloud-gpu-inventory-20261110/playwright-report.json)、[真实接口与业务基线核查](evidence/cloud-gpu-inventory-20261110/live-verification.json)、[真实页面核查](evidence/cloud-gpu-inventory-20261110/browser-verification.json)、[现场清单](evidence/cloud-gpu-inventory-20261110/live-gpu-inventory-dark.png)、[现场详情](evidence/cloud-gpu-inventory-20261110/live-gpu-detail-light.png)、[现场窄屏](evidence/cloud-gpu-inventory-20261110/live-gpu-detail-mobile.png)。`fixture-` 图片使用测试数据,不代表现场存在测试显卡或映射。
|
||||
|
||||
专用 VM、Windows 副本、存储准备和真实快照内容恢复仍按用户要求暂缓,后续统一见 [计划](roadmap.md)。
|
||||
@@ -0,0 +1,24 @@
|
||||
# GPU 切换偶发超时的自动核对
|
||||
|
||||
## 原因
|
||||
|
||||
2026-11-13 16:02(北京时间),Windows 10(VMID 100)的启动请求已被 PVE 接受,但查询原任务状态时 HTTP 超时。原任务实际以 `exitstatus=OK` 结束;旧逻辑把一次读取失败升级成持续阻塞,需要管理员手动核对。
|
||||
|
||||
## 行为
|
||||
|
||||
- PVE 任务状态和实际电源的 GET 查询,对网络暂时性故障及 HTTP 408/429/500/502/503/504 最多读取 3 次,间隔 250/500 毫秒,并遵守调用方取消和超时。权限错误不重试。
|
||||
- 电源 POST 不自动重发。拿不到原任务 UPID 时保留阻塞,需要核查,避免重复关机或显卡争用。
|
||||
- 后台在没有进行中的 GPU/创建操作时,间隔至少 15 秒选择一条待核对任务;恢复过程中仍持有独占组锁。每次核对最长 60 秒。
|
||||
- 已知原任务结束后,检查独占组配置、全部成员电源、员工/终端授权和 Sunshine 响应。只有这些条件全部满足才自动完成为成功,并更新桌面状态、记录系统审计。
|
||||
- 任务已结束但准备流程未完成,自动结束为失败并解除该任务的阻塞,用户可以重新选择桌面;后台不会补发后续电源操作。
|
||||
- 仍无法确认电源、原任务未结束或发现多台成员同时运行时保持阻塞。最近 24 小时内具有 UPID 的不确定任务继续后台核对,过期任务保留供管理员处理。
|
||||
- API 重启后发现的运行中 GPU 任务只恢复观察,不重放命令。尚未提交电源操作的任务结束为可重试失败。
|
||||
- 前 5 分钟内,设备查询可自动核对任务时返回 `running`,让现有终端继续轮询。同一终端重新准备同一桌面复用原任务,不重复创建。
|
||||
|
||||
## 验证方式
|
||||
|
||||
测试使用独立 Docker PostgreSQL(`vdi-cloud-gpu-recovery-test-db`),不连接业务库;PVE 和 Sunshine 均使用测试 HTTP 服务。覆盖读取失败后恢复、不确定 POST 不重放、实际多 VM 冲突、原任务失败、连接服务未启动、授权撤销、配置变化、API 重启、同一任务复用和有限等待。
|
||||
|
||||
现场部署仅更新 API,无 VM 电源操作、终端重启或 GPU 重新绑定。回退镜像为 `vdi-cloud-live-api:before-gpu-recovery-20261113`。
|
||||
|
||||
验证结果:带专用数据库的 `go test ./... -count=1` 和 `go vet ./...` 均通过;GPU 故障注入及重启恢复用例通过。部署后真实 `/healthz` 返回生产模式 `ok`,独占组冲突为空,VM 100 保持运行,101/102 保持关机。临时测试数据库容器已清理。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 终端生命周期与用户维护(2026-11-10)
|
||||
|
||||
本轮补齐现有控制台的设备与人员日常维护,沿用 `ui/` 的深浅主题和表格、抽屉、确认弹窗。管理员账号和桌面使用者仍分开管理。
|
||||
|
||||
## 使用入口
|
||||
|
||||
| 入口 | 功能 |
|
||||
| --- | --- |
|
||||
| 终端管理 → 编辑资料 | 修改设备名称、分组;SN 保持固定 |
|
||||
| 终端管理 → 详情 → 设备管理 | 停用设备、生成新的激活信息;只允许超级管理员 |
|
||||
| 用户与授权 → 编辑资料 | 修改姓名、启用/停用用户;登录名固定,原终端和桌面授权保留 |
|
||||
| 用户与授权 → 编辑授权 | 更换/解除关联终端、启停、设置完整桌面列表;已有流程增加确认和旧票据作废 |
|
||||
| 用户与授权 → 勾选用户 → 批量授权 | 追加分配或撤销指定桌面,保留其他授权;一次最多 100 位用户、100 台桌面 |
|
||||
|
||||
运维仍可执行原有重启、重置会话和日志命令;只读人员只能查看。停用设备不再提供远程命令入口,后端也拒绝新命令。批量操作对所选用户和桌面先整体校验,再一次提交;目标不存在则整体回滚。
|
||||
|
||||
## 停用与重新激活
|
||||
|
||||
- 停用立即撤销设备令牌和旧激活密钥,清理该设备连接票据;设备的 SN、业务 ID、分组和用户关联保留。
|
||||
- 重新激活会撤销旧身份,切换为待激活,生成只显示一次的新密钥。再次生成会作废上一次尚未使用的密钥;注册成功后密钥不能再次使用。
|
||||
- 活动会话/连接预约(有桌面关联且最近 90 秒有心跳)或排队/执行中的设备命令会阻止停用和重新激活,返回明确原因。先结束/重置会话,等待命令回执或核对超时状态。旧心跳超过在线窗口时按现有离线判定处理,平台身份撤销不等于网络层强制结束既有 Moonlight 串流。
|
||||
- 身份变更重置当前会话、FPS/RTT 和注册元信息;完整历史会话与指标查询尚未实现。永久删除设备/用户未增加,使用停用保留业务关系与审计。
|
||||
|
||||
重新激活不会通过已撤销的通道自动安装新身份。设备管理员需在终端空闲时备份并更新原 `platform.json`:保留原 `url` 和 `caFile`,移除旧 `deviceToken`,写入新 `activationSecret`,然后重启客户端使其重新注册。Qt 客户端优先尝试已有令牌,所以只添加激活密钥而保留旧令牌不能完成恢复。配置按凭据管理,权限保持 0600,不将密钥写入仓库或审计。
|
||||
|
||||
用户停用、授权撤销和更换终端会阻止后续连接;已建立串流仍通过独立的“重置会话”处理。授权修改和用户资料保存作废关联设备未消费票据,避免恢复启用或换绑后继续使用旧票据。桌面批量分配不会自动开启停用用户或自动关联终端。
|
||||
|
||||
## 接口与一致性
|
||||
|
||||
新增 `PUT /terminals/{id}`、`POST /terminals/{id}/disable`、`POST /terminals/{id}/reactivate`、`PUT /users/{id}`、`POST /users/batch-bindings`,均限超级管理员,已同步 OpenAPI 和前端类型。已有 `PUT /users/{id}/bindings` 保持兼容。
|
||||
|
||||
设备认证与该次心跳、命令领取/回执或票据操作共用事务和设备行锁;停用、重新激活、命令提交按同一设备行串行,避免旧请求在撤销后恢复身份或领取新命令。票据创建/消费、授权写入、设备身份重置和桌面连接配置共用授权事务锁,避免校验后权限变更或票据删除与消费的锁顺序冲突。普通设备心跳和命令处理不占用全局授权锁。新管理操作及其审计同事务,凭据不进入公开列表或审计。
|
||||
|
||||
数据库新增 `terminals.enabled boolean NOT NULL DEFAULT true`;原有设备默认启用,既有设备令牌、SN 和授权不重建,启动不重新激活已停用设备。新增数据库字段要求配套后端模型,回退不能直接将旧 API 接到新结构。
|
||||
|
||||
## 验证
|
||||
|
||||
独立临时 PostgreSQL 的 Go 全量测试与 `go vet` 通过;覆盖身份撤销、旧票据失效、重复注册、激活密钥轮换、重启保留停用、会话/任务保护、RBAC、批量幂等与回滚。前端类型检查、ESLint、3 项单元测试、生产构建和 18 项隔离接口浏览器回归通过。测试中的设备、用户和桌面为隔离数据,没有向现场设备或 PVE 发命令。
|
||||
|
||||
界面证据:[终端详情](evidence/cloud-lifecycle-20261110/fixture-terminal-detail-light.png)、[窄屏批量授权](evidence/cloud-lifecycle-20261110/fixture-batch-users-mobile.png)、[测试记录](evidence/cloud-lifecycle-20261110/test-verification.json)。`fixture-` 截图不代表现场实际设备和人员。
|
||||
|
||||
更新前备份 `.local/live/backups/before-lifecycle-20261110-105516.dump`,旧镜像标签 `before-lifecycle-20261110`。备份、原凭据指纹及数据快照只保存在忽略提交的私有目录。回退需评估快照之后的数据,恢复配套数据库与镜像;本轮不执行破坏性回退,不删除正式数据卷。
|
||||
|
||||
终端配置模板/热更新、自动领取与还原、GPU 调度、历史监控告警、OTA 和独立服务器交付仍按[功能核对清单](cloud-feature-progress-20261110.md)继续推进。
|
||||
|
||||
|
||||
## 本机真实验收
|
||||
|
||||
11-10 已构建并更新 5182 API/控制台,完成设备启用字段迁移。使用 2 个临时设备身份、2 位临时用户验证资料保存、用户启停、换绑/占用冲突、批量追加/撤销、票据作废、设备停用、重复生成激活密钥、一次性注册和旧令牌拒绝;没有向业务 VM 或真实盒子发送命令,没有实际创建 VM 连接预约。临时设备、用户、绑定与票据已定向清理,审计保留。
|
||||
|
||||
更新前后管理员及密码指纹、原设备身份凭据/元信息、桌面连接配置、用户和绑定逐项一致。原盒子心跳在更新后继续推进,查询时 1 台终端在线,VM100/101/102 均运行。见[真实接口核验](evidence/cloud-lifecycle-20261110/live-verification.json)。活动会话和待执行命令保护在隔离环境验证,未为测试打断现场业务连接。
|
||||
|
||||
部署后的真实页面已检查深浅主题、390px 窄屏、终端/用户资料弹窗及批量授权弹窗,取消未发送修改请求,无脚本错误或失败响应。证据:[页面核验](evidence/cloud-lifecycle-20261110/browser-verification.json)、[真实终端详情](evidence/cloud-lifecycle-20261110/live-terminal-detail-light.png)、[真实窄屏批量授权](evidence/cloud-lifecycle-20261110/live-batch-users-mobile.png)。
|
||||
@@ -1,6 +1,10 @@
|
||||
# 现场真实接入
|
||||
|
||||
更新:2026-11-09。与原演示环境分开,当前 API 使用 `production` 模式和独立 PostgreSQL,不生成演示节点、桌面或用户。服务运行在开发机 Docker Desktop;这是接入现场设备的联调环境,尚未迁到独立云服务器。
|
||||
更新:2026-11-10。与原演示环境分开,当前 API 使用 `production` 模式和独立 PostgreSQL,不生成演示节点、桌面或用户。服务运行在开发机 Docker Desktop;这是接入现场设备的联调环境,尚未迁到独立云服务器。
|
||||
|
||||
11-10 模板、桌面池与克隆接口及页面扩展见[功能核对与验收边界](cloud-feature-progress-20261110.md)。现场暂无 PVE 模板和创建权限,克隆实际发放仍待接入条件满足后验收。
|
||||
|
||||
Windows 模板来源已由用户确认为现有 Windows 镜像:复用 VM100 的系统作为母盘,不要求重装。VM100 当前仍在运行,PVE `template=0`,60 GiB 系统盘且带物理 GPU 直通;尚未制作独立模板副本。这里只读核查,没有转换现有桌面或修改显卡配置。
|
||||
|
||||
## 日常启动与入口
|
||||
|
||||
@@ -32,6 +36,8 @@ PVE Token 继承专用用户权限:根路径 `PVEAuditor` 负责只读发现
|
||||
|
||||
1. 管理员登记 SN,生成一次性激活密钥。盒子读取 `~/.config/vdi-terminal/platform.json`,通过注册接口取得设备 Token,以 0600 原子保存并删除激活密钥。
|
||||
2. 每 30 秒发送真实心跳;每 15 秒在选择页刷新授权桌面;每 3 秒领取远程命令。平台配置存在时不回退到本地授权或本地票据,列表与连接参数由平台维护。
|
||||
|
||||
11-10 修复冷启动认证失败的页面回退:启动始终显示选择页,平台未就绪时按 2~30 秒退避自动重试,成功后加载授权桌面;不会退回旧账号密码页。此前首次失败的底层原因缺少足够日志,新增 HTTP 状态/网络错误码记录供复查,详见[终端说明](terminal-guide.md#session)。
|
||||
3. 点击连接后申请、消费单次票据。消费只登记“连接中”,Moonlight 实际画面就绪后才上报 connected,计入活动连接。
|
||||
4. 自然退出、连接失败、管理员重置都会按 sessionId 释放预约。结束事件和回执写入待发队列,网络恢复后重试;已执行的命令不自动重放。旧会话事件或迟到的重置回执不能清理新会话。
|
||||
5. “重置会话”等待 Moonlight 进程真正退出后回执。“收集日志”上传受限诊断信息与脱敏日志尾部;管理员/运维可在成功任务详情下载,只读角色不可读取。
|
||||
@@ -67,7 +73,7 @@ PVE SSH 密码通过交互提示或 `VDI_PVE_SSH_PASSWORD` 读取;工具不在
|
||||
|
||||
本轮初始备份路径在盒子 `~/vdi-terminal/backups/platform-live-current-backup` 指向的目录。备份包括接入前 kiosk-shell、client.conf、desktops.json。维护时先结束串流,再原子恢复该 kiosk-shell 并将 platform.json 移至受限备份目录,由原有监控进程重启 kiosk。恢复后是原本的本地 POC 模式,不再从平台授权。无需回退 Moonlight 二进制或重启 VM。
|
||||
|
||||
只撤销 PVE 接入时,可禁用 `vdi-cloud@pve` 用户或移除 `console` Token;不要删除 root 或其他账号。独立部署、用户管理与密码轮换、登录限流、备份恢复演练、设备撤销/重新激活、OTA 和多租户仍属于后续产品化范围。
|
||||
只撤销 PVE 接入时,可禁用 `vdi-cloud@pve` 用户或移除 `console` Token;不要删除 root 或其他账号。独立部署、用户目录集成、登录限流、备份恢复演练、终端策略、OTA 和多租户仍属于后续产品化范围。设备停用/重新激活及用户维护见[11-10 功能说明](cloud-lifecycle-20261110.md)。管理员账号维护与手工密码修改已于 11-10 接通。
|
||||
|
||||
## 本轮现场验收
|
||||
|
||||
@@ -79,6 +85,8 @@ PVE SSH 密码通过交互提示或 `VDI_PVE_SSH_PASSWORD` 读取;工具不在
|
||||
- 现场 Playwright 页面流程通过:正式登录、3 台真实桌面、真实设备在线、下发日志采集、收到成功回执并下载实际 boot ID/uptime/日志内容。截图见 [真实总览](evidence/cloud-live-20261109/overview.png)、[真实桌面](evidence/cloud-live-20261109/desktops.png)。
|
||||
- 现场 kiosk-shell SHA256:`2cca9d2d183c2f34f0f6172db207649decb2799414f9443043d175bbb19d33a1`。Moonlight 二进制未更换。
|
||||
|
||||
以上为 11-09 接入验收。11-10 启动认证重试修复已更新 kiosk-shell 为 SHA256 `9dba9c66092aa9ccf67477b305aaefe09f52eabb8c6b30dbbcbb1ce43dc7a6a6`,CTest 4/4 与部署后真实选择页核验通过;原接入身份、票据、三桌面参数、Moonlight 均保留,未重启云桌面或宿主,未再次断电验收。备份与证据见[终端说明](terminal-guide.md#session)。
|
||||
|
||||
浏览器现场用例独立执行,避免运行演示库用例操作真实 VM:
|
||||
|
||||
```powershell
|
||||
@@ -87,3 +95,43 @@ npx playwright test --config=playwright.live.config.ts
|
||||
```
|
||||
|
||||
该用例读取本机私有初始账号文件,验证正式登录、真实桌面/设备以及日志收集下载,不发 VM 电源操作。完整演示 E2E 仍要求 demo 模式;数据库集成测试仍只指向专用 vdi_test。
|
||||
|
||||
## 管理员维护更新(2026-11-10)
|
||||
|
||||
5182 已部署「系统设置 → 管理员账号」和右上角本人改密入口,真实数据库验证新增/角色/启停/重置/改密及全部旧登录失效。临时账号已清理,原 root 密码、桌面连接参数、用户授权保持一致,3 台 VM 运行且终端在线。新增 `administrators.enabled` 默认 true;启动不重置既有密码或停用状态。更新前保留数据库备份及 `before-admin-20261110` 镜像,回退不能直接将旧 API 接到新增列数据库。完整说明见[功能核对清单](cloud-feature-progress-20261110.md)。
|
||||
|
||||
## 终端与用户维护更新(2026-11-10)
|
||||
|
||||
5182 已部署终端资料编辑、设备停用/重新激活、用户资料/启停及批量桌面授权。真实临时设备与用户验收后定向清理,原 root 密码、设备凭据、桌面配置、用户和授权保持一致,原盒子心跳继续推进。设备重新激活需管理员更新设备身份配置,不会通过旧令牌自动恢复。新增字段、回退边界和证据见[本轮说明](cloud-lifecycle-20261110.md)。
|
||||
|
||||
## 终端策略更新(2026-11-10)
|
||||
|
||||
5182 新增「终端策略」。真实设备客户端为 `qt-platform/0.3.0`,kiosk-shell SHA256 `690a74ee5b077ff96e385f2ba45ca756f1879223f7433e63ec1833eb0f3a628b`。盒子原二进制备份 `~/vdi-terminal/backups/policy-20261110/kiosk-shell`;只替换空闲 kiosk 子进程,由既有看门狗接管,未重启 LightDM、Moonlight、PVE 或 VM。真实配置变更与恢复均已回执,最终为 30/3/15 秒管理间隔。本段记录策略批次的 0.3.0 版本,前文 SHA 为历史接入记录;最新 0.4.0 指标批次见后文。范围、接口与备份见[终端策略说明](cloud-terminal-policies-20261110.md)。
|
||||
|
||||
## 11-10 批量导入、会话历史与告警
|
||||
|
||||
新增 CSV 用户/终端/授权导入、90 天会话历史、PostgreSQL 节点采样及控制台告警,入口、接口、留存、迁移与验收范围见[功能说明](cloud-operations-20261110.md)。VictoriaMetrics 和外部通知仍待接入。
|
||||
|
||||
## 11-10 接入配置、规格与登录限流
|
||||
|
||||
PVE 连接可在「系统设置 → 配置 Proxmox 接入」检查和保存,当前原地址/Token/CA 已入加密存储并通过重启核对,密钥不回显。JWT Secret 与数据库密文必须配套保管。桌面详情新增「规格管理」,三台真实 VM 读取和运行中拒绝修改已验证;现场缺 VM.Config.CPU/Memory/Disk 权限,未进行真实规格写入。管理员登录新增 5 分钟账号 10 次/来源 IP 30 次限制;设备心跳不受登录预算影响。详见[入口、测试与恢复边界](cloud-configuration-20261110.md)。
|
||||
|
||||
|
||||
## 快照与手动还原(2026-11-10)
|
||||
|
||||
新增 snapshot_jobs 表及电源/规格/快照任务的共同活动索引,启动幂等迁移。控制台入口为「桌面管理 → 详情 → 快照与还原」;仅支持关机后的普通虚拟磁盘与无内存还原,含数据盘的快照也会回退其数据。原任务未知不重发,成功还原后须重新确认连接初始化。接口、测试、备份和现场权限/VM100 限制见[本批说明](cloud-snapshots-20261110.md)。本批未进行真实 VM 快照写入,公共池自动领取/还原尚未实施。
|
||||
|
||||
|
||||
## 桌面批量电源操作(2026-11-10)
|
||||
|
||||
5182 的「桌面管理」新增跨页选择、批量开机/正常关机/重启和批次历史,每批最多 50 台。逐台真实预检、5 分钟签名凭证、确认后幂等提交;执行前复查,未确认结果不重发并阻止后续修改和新连接。Go 全量与静态检查、前端检查和 45 项隔离接口页面回归通过;真实环境只做预检与取消提交,三台桌面继续运行。新增表、任务状态、备份与现场边界见[功能说明](cloud-batch-operations-20261110.md)。专用测试 VM、Windows 副本和存储准备按用户要求暂缓。
|
||||
|
||||
|
||||
## GPU 资源查看(2026-11-10)
|
||||
|
||||
5182 侧栏新增「GPU 资源」,三角色均可读取节点显卡清单、PCI 地址、IOMMU 分组与当前/待应用的虚拟机配置引用,支持搜索、筛选与详情。现场确认 Intel 核显、GTX 965M 及 Windows VM100 引用;自定义配置导致不完整时明确提示,不将未发现引用视为可分配。只新增 GET 接口,无数据库迁移或 GPU 写入;Go 全量与静态检查、前端检查和 51 项页面回归通过。接口、权限、备份和现场证据见[功能说明](cloud-gpu-inventory-20261110.md)。
|
||||
|
||||
|
||||
## 11-10 终端系统指标
|
||||
|
||||
当前真实盒子为 `qt-platform/0.4.0`,kiosk SHA256 `6ff0697e9c5efb7428053d54af6d52f360e726eea5962e944f592c802f7463e1`。旧二进制保留在 `~/vdi-terminal/bin/kiosk-shell.before-terminal-metrics-20261110`。CPU、内存、SoC 温度和运行时长已连续上报入库,控制台详情可查历史。只在空闲时替换 kiosk,未重启 LightDM 或宿主;身份、策略文件、桌面参数、账号授权及三台 VM 配置核对一致。数据库备份、API、留存与采集限制见[系统指标说明](cloud-terminal-metrics-20261110.md),现场结果见[验收数据](evidence/cloud-terminal-metrics-20261110/live-verification.json)。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 管理员双重验证(2026-11-11)
|
||||
|
||||
已部署本机 `http://127.0.0.1:5182`。入口为右上角锁形按钮「双重验证」,三个角色均可维护本人设置。既有账号默认不启用;root 密码和原登录方式保持,未为用户账号自动绑定验证器。
|
||||
|
||||
## 使用
|
||||
|
||||
1. 点击「双重验证」,输入当前密码开始绑定。在身份验证器中手动添加账号,填入页面显示的密钥,选择基于时间、6 位、30 秒更新;本期提供手动密钥,不生成二维码。
|
||||
2. 输入动态码并点击「验证并开启」。绑定准备有效 10 分钟,必须在发起绑定的同一登录中确认;确认前不启用。关闭准备窗口不会启用验证,重新开始会生成新密钥。
|
||||
3. 保存只显示一次的 8 条恢复码,可下载文本。每条只能使用一次;关闭窗口后无法再次查看原码。开启后其他登录立即失效,当前登录保留。
|
||||
4. 登录先验证用户名和密码,启用账号随后出现动态码/恢复码输入框。验证成功前不签发或保存登录令牌。动态码成功使用一次后需等待身份验证器刷新,服务端容许前后一个 30 秒时间步的时钟误差。
|
||||
5. 更新恢复码或关闭验证,均须当前密码及有效动态码/恢复码,并二次确认。更新使全部旧恢复码作废,关闭恢复密码登录;两项操作均撤销其他登录,保留当前登录。
|
||||
|
||||
动态码遵循 [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238),使用 SHA-1、6 位和 30 秒时间步。请保持验证器和服务器时间同步。丢失验证器和全部恢复码时需要受控运维恢复,当前网页不提供其他管理员代删 MFA、邮件找回或目录服务恢复;重置密码也不会绕过已有双重验证。
|
||||
|
||||
## 接口与保护
|
||||
|
||||
| 接口 | 用途 |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/auth/mfa` | 本人启用状态、剩余恢复码数量,不回显密钥 |
|
||||
| `POST /api/v1/auth/mfa/setup` | 当前密码验证后生成临时绑定密钥 |
|
||||
| `POST /api/v1/auth/mfa/confirm` | 验证动态码、启用并返回一次性恢复码 |
|
||||
| `POST /api/v1/auth/mfa/recovery` | 当前密码和验证代码校验后更新恢复码 |
|
||||
| `DELETE /api/v1/auth/mfa` | 当前密码和验证代码校验后关闭 |
|
||||
| `POST /api/v1/auth/login` | 新增可选 `code`;启用账号必须通过第二因素 |
|
||||
|
||||
- 临时和启用密钥使用 AES-GCM 加密,独立用途密钥由 `VDI_JWT_SECRET` 派生,附加认证数据绑定管理员标识。备份恢复须保留对应环境密钥,不能只恢复数据库或随意替换 JWT 密钥。
|
||||
- 恢复码使用 128 位随机值,库中仅保存哈希;消费、会话创建、恢复码轮换、设置修改及相关审计使用事务。TOTP 时间步单调递增,同一动态码及同一恢复码的并发请求仅一次成功。
|
||||
- 账号行锁将登录与权限/密码/MFA 修改串行,事务内复核当前登录及最新账号状态。开启、关闭及恢复码更新分别记录 `enable_mfa/disable_mfa/rotate_mfa_recovery`,审计不保存密钥、明文恢复码、密码或验证码。
|
||||
- MFA 设置验证和登录各使用独立持久化尝试预算:每账号每 5 分钟 10 次、每来源地址 30 次。失败尝试和成功尝试均计数;返回 429 与 Retry-After。错误密码不会暴露账号是否已启用 MFA,也不会消费恢复码。
|
||||
- 密钥、恢复码和密码只在当前页面流程中短暂保留,关闭窗口清理,不写入 localStorage/sessionStorage。恢复码仅通过明确下载操作保存到用户本地。
|
||||
|
||||
新增 `admin_mfa/admin_mfa_recovery` 附表,不改原账号及会话表字段。关闭 MFA 删除本人附表记录及恢复码;删除临时管理员时级联清理。验证设置取消或过期后不激活,过期密文保留至下一次绑定覆盖或账号清理。
|
||||
|
||||
## 验收
|
||||
|
||||
- 独立 PostgreSQL 的 Go 全量测试与 vet 通过;专项通过 RFC 六组 SHA-1 测试向量(含 2038 年之后)、时间窗口、动态码重复使用拒绝、恢复码重复使用拒绝、并发消费、密文账号绑定、三角色本人维护、准备过期/跨登录拒绝及尝试限制。另补充超级管理员重启后设置保留及改密仍需第二因素的验证,专项通过。
|
||||
- 前端 lint/typecheck、3 项单元测试和生产构建通过;新增 6 项 MFA 页面测试通过,既有管理员/在线登录/界面 12 项回归通过。最初 3 条错误文案断言未包含请求编号,修正断言后全部通过,应用提示逻辑正常。
|
||||
- 真实临时只读账号完成开启、原登录撤销、密码单独登录拒绝、真实当前时间动态码登录、动态码重复拒绝、恢复码登录及重复拒绝、恢复码更新及旧码失效、关闭后密码登录。设置变化审计不含秘密,临时账号及其 MFA/会话已清理。
|
||||
- 真实浏览器操作完成绑定确认、恢复码下载、登录第二因素、恢复码登录及关闭;1440/390px 深浅主题通过,无脚本异常、非预期 API 错误或越界写请求。真实截图仅展示登录提示及启用状态,不保存有效密钥或恢复码。
|
||||
- 原账号/密码指纹、用户授权、终端身份与配置、告警规则及三台 VM 配置摘要一致,盒子在线。验收期间盒子另行提交成功的 `gpu-prepare` 任务:业务任务 61→62,VM100 开机、VM101 关机,VM102 保持关机。该次设备任务和前后状态已完整保留,不把电源变化当作本轮 MFA 测试操作,也未强制恢复现场状态。
|
||||
|
||||
证据:[真实接口](evidence/cloud-mfa-20261111/live-verification.json)、[真实页面](evidence/cloud-mfa-20261111/browser-verification.json)。集成测试只使用一次性 `vdi-mfa-test-db`,未在业务库清空数据;隔离库为减少清理同步等待临时关闭 fsync,因此这些测试不证明数据库断电恢复能力,业务 PostgreSQL 配置未改动。
|
||||
|
||||
## 部署与回退
|
||||
|
||||
部署前备份 `.local/live/backups/before-mfa-20261111-154828.dump`,原镜像标签 `before-mfa-20261111-154828`。独立构建基线二进制与部署前 SHA-256 一致:`ac04d8f5423b07aa2afa442013a4ddeaee55bbf0343a0876c50a8e829c6a4779`,保留已部署 GPU 功能及 Compose 配置。
|
||||
|
||||
旧 API 不检查新增 MFA 表,直接回退会绕过第二因素。只要存在已启用 MFA 的账号,就必须保留强制验证的兼容 API,不能直接回退到旧镜像;部署脚本的失败恢复也加入了启用账号检查。只有确认没有启用账号且没有后续部署时,才可恢复原镜像并沿用 `.local/mfa-review/compose.release.yml` 和原环境配置;附表可保留,不删除业务卷或恢复整库覆盖其他工作。
|
||||
|
||||
此项为可选的本人 TOTP 与恢复码,尚未实现组织强制策略、目录集成、硬件安全密钥或 WebAuthn。专用 VM、Windows 副本、额外磁盘及真实快照还原仍按用户要求暂缓。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 批量导入、会话历史与监控告警(2026-11-10)
|
||||
|
||||
本轮按原需求补齐三项真实功能,使用 Go API、PostgreSQL 与现有 Vue 控制台。入口保持 http://127.0.0.1:5182,沿用原管理员账号。没有修改盒子串流或画质参数。
|
||||
|
||||
## 使用入口
|
||||
|
||||
| 功能 | 入口 | 权限 |
|
||||
| --- | --- | --- |
|
||||
| 用户 CSV | 用户与授权 → 导入用户 | 超级管理员 |
|
||||
| 终端 CSV | 终端管理 → 批量导入 | 超级管理员 |
|
||||
| 授权 CSV | 用户与授权 → 导入授权 | 超级管理员 |
|
||||
| 会话历史 | 远程会话 → 历史记录 | 所有管理员角色只读 |
|
||||
| 资源曲线、告警 | 侧栏 → 监控告警 | 所有角色查看,超管/运维确认,超管编辑规则 |
|
||||
|
||||
## CSV 格式与提交规则
|
||||
|
||||
UTF-8 CSV,支持 BOM 和标准双引号字段;每批最多 200 行、512 KiB。页面可下载空白模板、上传文件或直接粘贴。先预检,逐行显示错误并可下载错误 CSV;所有行通过后才能整批提交。
|
||||
|
||||
```csv
|
||||
username,displayName
|
||||
worker01,研发人员
|
||||
```
|
||||
|
||||
```csv
|
||||
sn,name,group
|
||||
BOX-NEW-001,研发终端,研发组
|
||||
```
|
||||
|
||||
```csv
|
||||
username,terminalSn,desktopIds
|
||||
worker01,BOX-NEW-001,desktop-id-1;desktop-id-2
|
||||
```
|
||||
|
||||
表头名称固定,列顺序可调整。用户和终端只新增,已存在或文件内重复的账号/SN会阻止整批导入。用户无需在 CSV 填密码,仍是用户与设备绑定模式。
|
||||
|
||||
授权导入仅追加桌面,不撤销原授权;空 terminalSn 保持原终端。不能抢占他人终端、给已有其他终端的用户换绑或绑定停用终端。预检后原授权发生变化时,提交返回冲突,整批不写入,需重新预检。已停用用户不会因导入授权被重新启用;既有串流不因此强制结束。
|
||||
|
||||
提交使用幂等请求标识,同一管理员重发相同内容不重复创建。新终端激活密钥仅在首次成功响应中返回,页面可下载并在关闭时清除;数据库和审计不保存密钥明文或源 CSV。重复请求不再返回密钥,丢失后到终端管理重新激活。
|
||||
|
||||
接口:`POST /api/v1/imports/preview`、`POST /api/v1/imports/commit`,后者要求 `Idempotency-Key` 和预检返回的 `previewToken`。
|
||||
|
||||
## 会话记录
|
||||
|
||||
`GET /api/v1/sessions/history` 支持关键词、状态、起止时间和分页,默认最近 24 小时、最多查询 90 天。记录保留终端、用户及桌面名称快照,后续改名不影响旧记录。
|
||||
|
||||
- 消费票据生成“连接中”预约,不能记作已建立视频串流。
|
||||
- 客户端画面就绪事件写入首次连接时间,重复事件不重置时间;结束事件记录结束原因。
|
||||
- 新连接替代旧预约、身份重置、90 秒无心跳分别记录中断原因,旧设备或旧会话事件不能结束新会话。
|
||||
- 超时只结束历史片段,不自动关闭 VM、不重置终端串流。之后合法事件或已连接状态的心跳恢复时,另记“首次观测”片段。
|
||||
- 升级前的历史不补造。升级中已有连接从首次观测开始,不能将观测时间当成原始连接开始时间。
|
||||
- 时长从画面就绪计算;活动会话只算到最近上报,尚未就绪显示“未就绪”。结束记录保留 90 天,活动记录不删。
|
||||
|
||||
这仍依赖终端对实际串流状态的上报;本轮真实 API 验证使用临时设备发送合成事件,没有开启现场视频串流。
|
||||
|
||||
## 监控与告警
|
||||
|
||||
PVE 节点同步约每 30 秒保存 CPU、内存、存储使用率,使用 PostgreSQL 保留 30 天;控制台每 15 秒刷新,单次可看最近 1 小时、6 小时、24 小时或 7 天,按时间范围分桶平均。存储值来自 PVE 节点 disk/maxdisk,不表示每台客体磁盘使用率。缺失分母或离线节点保存空值;无有效采样显示空态,采样间隔过大时曲线断开,不补零。
|
||||
|
||||
启动幂等创建五条默认规则,已有管理员修改不被覆盖:CPU/内存/存储 90% 持续 120 秒,节点离线或采集异常 120 秒,已激活启用终端离线 180 秒。水位阈值可设 1~100%、持续 0~3600 秒;离线阈值 90~86400 秒。
|
||||
|
||||
评估器每 15 秒运行,多 API 实例通过事务锁避免重复事件。指标持续越线才触发,同一规则/目标只保留一条活动告警;缺少新采样不会假称恢复,采集异常由独立规则检测。节点离线状态持续计时,在线且采集正常后恢复;终端真实心跳恢复后自动解除离线告警。目标移除或停用会结束对应告警。
|
||||
|
||||
确认仅表示已知悉,不将告警改为恢复;保留首次确认人和时间。修改/停用规则会结束旧告警并重新计时。已恢复事件保留 90 天。
|
||||
|
||||
接口包括 `/api/v1/monitor/series`、`/monitor/rules`、`/monitor/alerts` 及 `/monitor/alerts/:id/acknowledge`,完整契约见 [OpenAPI](openapi/platform.v1.json)。
|
||||
|
||||
本轮只实现控制台内告警。VictoriaMetrics、外部邮件/Webhook 通知、终端温度及完整 FPS/RTT 历史采集仍未接入;现有独立 vdi-monitor 的诊断采集继续保留。
|
||||
|
||||
## 迁移与回退
|
||||
|
||||
新增 `import_batches`、`session_history`、`resource_samples`、`alert_rules`、`alerts`、`alert_states` 六张表及索引;默认规则只在不存在时新增,没有向原业务表添加字段。原设备令牌、root 账号、桌面参数和用户授权保留。
|
||||
|
||||
更新前备份 `.local/live/backups/before-operations-20261110-133019.dump`;旧 API/控制台镜像标签 `before-operations-20261110-133019`。备份含业务数据,只放私有目录。旧版本不记录新增会话/采样;如需回退,先另行备份并评估新增数据,不能直接删除业务卷。本轮未做数据库恢复演练。
|
||||
|
||||
验证期间 Docker/本机进程中断,恢复后 PostgreSQL 正常完成启动恢复;原业务快照核对一致,随后继续测试和更新。镜像构建首次遇到 Docker Hub 鉴权网络 EOF,重试成功。
|
||||
|
||||
## 验证证据
|
||||
|
||||
独立临时 PostgreSQL 的 Go 全量测试与 go vet、前端 ESLint/类型检查、3 项单元测试、生产构建及 27 项指定浏览器回归通过。数据库测试没有连接真实业务库;浏览器回归使用接口拦截数据,截图以 `fixture-` 标记。
|
||||
|
||||
真实服务验证及页面检查见 [接口核验](evidence/cloud-operations-20261110/live-verification.json)、[页面核验](evidence/cloud-operations-20261110/browser-verification.json)、[自动检查范围](evidence/cloud-operations-20261110/automated-verification.json)。临时用户/终端/授权/导入批次和测试规则/告警定向清理,操作审计保留。告警验证只调整临时终端的测试心跳时间,并通过真实设备心跳 API 验证恢复;没有修改默认规则或原设备。
|
||||
|
||||
真实验收结果:三类 CSV 均成功且重放未重复写入;会话预约、就绪和结束落库;真实 PVE 采样已返回有效点,历史从本次部署开始积累。临时终端离线告警触发、确认后由心跳自动恢复。清理后原业务快照一致,原盒子心跳正常,VM100/101/102 均运行。真实页面 9 张截图覆盖深浅主题与 390px 弹窗,无脚本错误、失败接口或页面检查产生的修改请求。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
更新:2026-11-09。当前交付是第一阶段本地可运行版本。Vue 控制台通过真实 HTTP 调用 Go API,账号、授权、设备、任务和审计保存在 PostgreSQL;演示 VM 的电源操作由明确标记的演示计算适配执行。本文件主要描述演示开发环境;随后已接通[独立真实环境](cloud-live-integration.md),控制台端口 5182、设备 HTTPS 8443。
|
||||
|
||||
11-10 新增模板登记、PVE 克隆任务、桌面池、初始化连接配置,以及真实管理员账号维护和本人改密。最新实现范围、操作路径、现场前提和剩余功能见[功能核对清单](cloud-feature-progress-20261110.md)。
|
||||
|
||||
## 启动
|
||||
|
||||
### Windows 本机开发
|
||||
@@ -56,7 +58,7 @@ MSW 在浏览器层模拟 HTTP,只有开发构建允许启用,不需要 Go
|
||||
|
||||
当前连接票据仅授权并返回 Moonlight 端点与参数;终端仍需既有 Sunshine 配对。消费票据登记连接预约,不证明串流已建立;现场 Qt 已上报实际画面就绪与进程结束事件,详情见真实接入说明。不要把 `credentialMode=existing-pairing` 当作免配对认证。
|
||||
|
||||
本轮将 API 入口与基础业务模块放在一个可运行的 Go 网关进程中,数据库和 Proxmox client 独立封装。原方案的七个服务职责继续作为后续拆分边界;当前尚未交付七个独立部署的服务。MQTT 总线、Rust Agent、VictoriaMetrics 指标接入、模板发放、桌面池、GPU 绑定、OTA、AD/LDAP 尚未完成;Qt 适配现已实际部署。控制台仅显示已接通的第一阶段功能。
|
||||
本轮将 API 入口与基础业务模块放在一个可运行的 Go 网关进程中,数据库和 Proxmox client 独立封装。原方案的七个服务职责继续作为后续拆分边界;当前尚未交付七个独立部署的服务。MQTT 总线、Rust Agent、VictoriaMetrics 指标接入、GPU 绑定、OTA、AD/LDAP 尚未完成;模板登记、克隆编排与桌面池已在后续批次实现,现场发放仍待模板和权限验收;Qt 适配现已实际部署。控制台仅显示已接通的第一阶段功能。
|
||||
|
||||
## 真实 Proxmox 接入
|
||||
|
||||
@@ -74,11 +76,11 @@ PVE_TOKEN_SECRET=...
|
||||
PVE_CA_FILE=/run/secrets/pve-ca.pem
|
||||
```
|
||||
|
||||
PVE 凭据由后端持有,前端和终端不会接收;证书校验始终开启。PVE_URL 只填 HTTPS origin,不附 `/api2/json`。由运维给 API Token 配置实际需要的节点/VM/存储查询和 VM 电源操作权限。本轮不自动修改 PVE 账号、ACL、VM 或主机配置。
|
||||
PVE API Token 密钥由后端保管,网页只输入不回显,终端不接收;证书校验始终开启。PVE_URL 只填 HTTPS origin,不附 `/api2/json`。由运维给 API Token 配置实际需要的节点/VM/存储查询和 VM 电源操作权限。本轮不自动修改 PVE 账号、ACL、VM 或主机配置。
|
||||
|
||||
启动后到“计算资源”手动同步;再到“桌面管理”选择“纳管已有 VM”。纳管仅登记已存在的 VM,不改 VM 配置。连接地址可在纳管表单填写。首次真实接入应使用专门测试 VM 核验 API 版本、证书、任务结果和关机行为,再对业务 VM 开放操作。
|
||||
|
||||
正式环境的 HTTPS 入口、运维凭据轮换、账号维护、登录限流、独立数据库迁移流程与备份恢复仍属于部署产品化工作;当前 compose 不作生产就绪声明。
|
||||
正式环境的 HTTPS 入口、运维凭据轮换、独立数据库迁移流程与备份恢复仍属于部署产品化工作;账号维护与登录限流已在后续批次接入;当前 compose 不作生产就绪声明。
|
||||
|
||||
## 设备接口联调
|
||||
|
||||
@@ -140,5 +142,54 @@ go test ./...
|
||||
- 在线按最近 90 秒心跳计算;FPS/RTT 缺失为 null,不填 0。PVE 电源状态与 VM 内连接服务状态分开。
|
||||
- 任务成功必须有计算适配结果或设备回执。提交后发生通信异常时保留“结果未确认”,由管理员核对实际状态,不自动重试。
|
||||
- 桌面“重启”只用 PVE reboot;未将硬重启 reset 冒充“还原镜像”。
|
||||
- 当前任务和审计列表展示最近 1000 条,完整记录仍存 PostgreSQL;长期检索和归档后续扩展。
|
||||
- 任务和审计已支持完整历史的数据库分页、组合检索及 CSV 导出;单次导出最多 10000 条。全局轮询只读取近期任务和全部未结束任务;外部归档、防篡改待扩展。详见[任务历史](cloud-task-history-20261111.md)和[审计历史](cloud-audit-history-20261111.md)。
|
||||
- 凭据不写入审计;设备上报的自由文本不直接放入公开任务结果。
|
||||
|
||||
## 管理员维护(2026-11-10)
|
||||
|
||||
真实环境入口为「系统设置 → 管理员账号」,仅超级管理员可新增、编辑角色/显示名称、启停及重置他人密码。所有角色通过右上角钥匙修改本人密码;角色/状态/密码变化撤销该账号所有会话。新增密码至少 12 字符且 UTF-8 不超过 72 字节,原账号不强制改密;无永久删除入口。迁移、测试和验收证据见[功能清单](cloud-feature-progress-20261110.md)。
|
||||
|
||||
## 终端与桌面用户维护(2026-11-10)
|
||||
|
||||
终端列表支持编辑名称/分组,详情增加停用/重新激活;用户列表支持资料编辑、启停和批量追加/撤销指定桌面。设备停用撤销旧身份,重新激活显示一次性密钥并需管理员更新设备配置。活动会话和未完成命令会阻止身份重置;用户权限变更阻止后续连接,既有串流单独重置。完整接口、迁移和验收见[本轮说明](cloud-lifecycle-20261110.md)。
|
||||
|
||||
## 11-10 终端策略
|
||||
|
||||
新增策略模板、下发记录、设备结果和设备应用回执。配置版本沿用终端版本递增;四张独立策略表保存能力、模板与历史,不向既有业务表新增列。仅超级管理员可创建/下发,设备回执沿用鉴权事务与行锁。Qt 客户端在安全持久化后热更新实际定时器,失败保留原运行配置;不改变串流参数。已部署本机真实服务与 RK3528 盒子并验证配置变化/恢复,详见[实现与验收](cloud-terminal-policies-20261110.md)。
|
||||
|
||||
## 11-10 批量导入、会话历史与告警
|
||||
|
||||
新增 CSV 用户/终端/授权导入、90 天会话历史、PostgreSQL 节点采样及控制台告警,入口、接口、留存、迁移与验收范围见[功能说明](cloud-operations-20261110.md)。VictoriaMetrics 和外部通知仍待接入。
|
||||
|
||||
## 接入配置与规格任务(2026-11-10)
|
||||
|
||||
新增网页 PVE 配置及热加载、真实规格读取/预检/变更任务与只读核对、共享数据库的登录限流。完整接口、可信代理、加密密钥保存要求、独立测试和现场权限限制见[本批说明](cloud-configuration-20261110.md)。
|
||||
|
||||
|
||||
## 快照与手动还原(2026-11-10)
|
||||
|
||||
新增 snapshot_jobs 表及电源/规格/快照任务的共同活动索引,启动幂等迁移。控制台入口为「桌面管理 → 详情 → 快照与还原」;仅支持关机后的普通虚拟磁盘与无内存还原,含数据盘的快照也会回退其数据。原任务未知不重发,成功还原后须重新确认连接初始化。接口、测试、备份和现场权限/VM100 限制见[本批说明](cloud-snapshots-20261110.md)。本批未进行真实 VM 快照写入,公共池自动领取/还原尚未实施。
|
||||
|
||||
|
||||
## 桌面批量电源操作(2026-11-10)
|
||||
|
||||
5182 的「桌面管理」新增跨页选择、批量开机/正常关机/重启和批次历史,每批最多 50 台。逐台真实预检、5 分钟签名凭证、确认后幂等提交;执行前复查,未确认结果不重发并阻止后续修改和新连接。Go 全量与静态检查、前端检查和 45 项隔离接口页面回归通过;真实环境只做预检与取消提交,三台桌面继续运行。新增表、任务状态、备份与现场边界见[功能说明](cloud-batch-operations-20261110.md)。专用测试 VM、Windows 副本和存储准备按用户要求暂缓。
|
||||
|
||||
|
||||
## GPU 资源查看(2026-11-10)
|
||||
|
||||
5182 侧栏新增「GPU 资源」,三角色均可读取节点显卡清单、PCI 地址、IOMMU 分组与当前/待应用的虚拟机配置引用,支持搜索、筛选与详情。现场确认 Intel 核显、GTX 965M 及 Windows VM100 引用;自定义配置导致不完整时明确提示,不将未发现引用视为可分配。只新增 GET 接口,无数据库迁移或 GPU 写入;Go 全量与静态检查、前端检查和 51 项页面回归通过。接口、权限、备份和现场证据见[功能说明](cloud-gpu-inventory-20261110.md)。
|
||||
|
||||
|
||||
## 终端系统指标与历史(2026-11-10)
|
||||
|
||||
5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标及系统指标告警仍待接入。
|
||||
|
||||
|
||||
## 终端系统指标告警(2026-11-11)
|
||||
|
||||
5182「监控告警」新增终端 CPU、内存、SoC 温度规则,按真实新采样计算持续时长;计时不早于规则启用/修改,重复评估不推进,缺失/过期不冒充恢复。支持原有三角色权限、确认、规则启停和 90 天事件历史;摄氏度与百分比分开显示。Go 全量与静态检查、前端检查/构建及 61 项隔离页面回归通过,旧规则/事件/待触发状态的重复升级验证通过。无需更新终端,未操作 VM、磁盘或 GPU。详见[规则与验收边界](cloud-terminal-alerts-20261111.md)。
|
||||
|
||||
2026-11-11:[管理员在线登录管理](cloud-admin-sessions-20261111.md)已上线,提供单独撤销与登录来源查看。新增兼容附表,密码与原会话表不重置。
|
||||
|
||||
2026-11-11:[可选管理员 TOTP 双重验证](cloud-mfa-20261111.md)已上线。既有账号默认不启用;启用账号必须验证动态码或恢复码。存在启用账号时禁止直接回退到不校验 MFA 的旧 API。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 关机快照与手动还原(2026-11-10)
|
||||
|
||||
本批接入真实 PVE API,补充公共池自动还原所需的基础能力。入口为「桌面管理 → 查看详情 → 快照与还原」,异步结果在「任务与审计」查看。本批不包含公共池自动领取、退出还原、自动回收、备份或迁移。
|
||||
|
||||
## 功能与范围
|
||||
|
||||
- 全部管理员角色可查看快照名称、描述、时间、父快照及内存标记;仅超级管理员可预检、创建、删除、还原和核对未知结果。
|
||||
- 创建保存关机后的磁盘与配置,不保存内存;名称由字母开头,1~40 位字母、数字、下划线或短横线,不使用 current/pending。
|
||||
- 删除只支持已完成且没有子快照的恢复点,不使用 PVE force 删除。
|
||||
- 还原前输入当前 VMID,明确确认回退快照内磁盘和配置。数据盘若在快照内也会回退,不保证只还原系统盘。拒绝内存快照,完成后保持关机。
|
||||
- 还原提交前撤销旧连接票据并将桌面标记为待初始化。管理员需核对系统身份、IP、Sunshine 服务和终端配对,再在「配置连接」确认初始化。PVE 明确拒绝还原时也保留此标记,核查后重新确认即可。
|
||||
- 页面沿用现有深浅主题,窄屏保留弹窗标题和底部操作,内容区内部滚动。参数变更会使预检失效;取消二次确认不提交写入。
|
||||
|
||||
## 预检与执行保护
|
||||
|
||||
所有快照写操作都要求正常关机、无 PVE 锁、无待应用配置、无活动终端会话,且没有其他电源/规格/快照任务或未确认的维护结果。创建时检查存储快照能力;删除/还原校验选定快照版本;还原另检查磁盘布局、容量和配置目标。
|
||||
|
||||
本期仅支持普通虚拟磁盘(包含 EFI/TPM),跳过 CD-ROM/cloud-init。包含直通设备、宿主 args/hookscript、未使用磁盘、物理盘、共享盘或排除快照的磁盘时不允许平台快照写入;列表仍可查看。自定义 NUMA 等既有配置限制也会拦截。磁盘配置字符串的严格比较可能拒绝等价但格式不同的配置,需要在 PVE 人工核查。
|
||||
|
||||
读取需 VM.Audit;创建/删除需 VM.Snapshot;还原允许 VM.Snapshot 或 VM.Snapshot.Rollback。平台不修改 PVE 权限。PVE 的 start=0 无法保证含内存快照不启动,所以本期拒绝这类还原。依据 [PVE QEMU API 源码](https://raw.githubusercontent.com/proxmox/qemu-server/master/src/PVE/API2/Qemu.pm) 与 [QemuConfig 实现](https://raw.githubusercontent.com/proxmox/qemu-server/master/src/PVE/QemuConfig.pm)。
|
||||
|
||||
任务使用持久化请求、幂等键、桌面行锁和活动任务唯一索引。排队与执行前分别核对配置摘要及快照指纹;进入 submitting 后只发送一次,服务重启仅查询原 UPID。普通任务不等待长快照完成,当前长维护任务仍单个串行执行。
|
||||
|
||||
成功需要原 PVE 任务 stopped/OK 和实际状态一致;还原不能只凭配置相同就断言磁盘数据已经恢复。PVE 最终失败、网络异常或缺少 UPID 时,发送后的结果保持未确认,阻止后续电源/规格变更、新连接及重新开放初始化。任务详情的「核对快照结果」仅查原任务和实际状态,不重发修改;缺 UPID、PVE 任务失败或残留状态需在 PVE 人工核查,当前无一键清除未知保护入口。
|
||||
|
||||
预检无法防止管理员绕过平台直接修改 PVE:这些快照写接口没有配置 digest 条件参数,执行前检查与 PVE 接收之间仍有竞态。请避免从其他入口同时维护同一 VM。
|
||||
|
||||
## API 与数据
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | /desktops/{id}/snapshots | 列表、配置摘要、磁盘范围、限制及操作权限 |
|
||||
| POST | /desktops/{id}/snapshots/check | 只读预检 |
|
||||
| POST | /desktops/{id}/snapshots/actions | 幂等提交 create/delete/rollback,返回 202 任务 |
|
||||
| POST | /tasks/{id}/snapshot-reconcile | 只读核对原任务 |
|
||||
|
||||
提交字段为 action/name/digest,创建可带 description,删除/还原需 revision,还原需 confirmVmid。拒绝未知字段和不适用参数。接口契约见 [OpenAPI](openapi/platform.v1.json)。
|
||||
|
||||
新增 snapshot_jobs 表,保存请求、请求散列、目标配置散列和阶段;不保存完整 PVE 配置。新增 tasks_vm_maintenance_target 唯一索引,与既有电源/规格队列互斥;启动幂等迁移,无业务表删列。
|
||||
|
||||
## 验证与现场限制
|
||||
|
||||
已部署本机控制台 http://127.0.0.1:5182。现场只读结果:三台 VM 均在运行且没有快照;Token 缺 VM.Snapshot 和 VM.Snapshot.Rollback。PVE 报告 VM101/102 支持快照,VM100 不支持;VM100 还含显卡直通和宿主 args,本期写入范围也会拒绝。此次未对真实 VM 执行关机、快照创建/删除/还原、规格修改或克隆。
|
||||
|
||||
Go 全量测试与 vet、ESLint、TypeScript、3 项前端单元测试及生产构建通过。37 项浏览器回归通过,其中快照 8 项;随后修复窄屏弹窗滚动,8 项快照流程重新通过。新增后端覆盖权限二选一、关机/锁/待应用/活动会话限制、磁盘与内存限制、幂等、执行前变更、未知状态不重发、只读核对、过期、维护互斥与还原票据撤销。完整测试摘要见 [记录](evidence/cloud-snapshots-20261110/test-verification.json)。
|
||||
|
||||
真实深浅主题和 390px 页面检查无脚本错误、失败响应或写请求;原 root 密码指纹、桌面连接、用户授权和终端身份与更新前一致,三台桌面运行、盒子心跳正常。
|
||||
|
||||
验证记录在 [证据目录](evidence/cloud-snapshots-20261110/)。写入链路用独立临时 PostgreSQL 和本机 PVE HTTP 测试服务验证;不能将其视为真实磁盘恢复验收。
|
||||
|
||||
部署和现场只读检查结果见 [真实 API 核验](evidence/cloud-snapshots-20261110/live-verification.json) 与 [页面核验](evidence/cloud-snapshots-20261110/browser-verification.json)。快照真实写入与磁盘内容还原需要后续补充 Token 权限,在无直通的专用测试 VM 验收。
|
||||
|
||||
页面证据:[现场深色](evidence/cloud-snapshots-20261110/live-snapshots-dark.png)、[现场窄屏](evidence/cloud-snapshots-20261110/live-snapshots-mobile.png)、[隔离数据创建表单](evidence/cloud-snapshots-20261110/fixture-snapshot-create-dark.png)、[隔离数据还原确认](evidence/cloud-snapshots-20261110/fixture-snapshot-restore-light-mobile.png)。fixture 图片不代表现场已有快照。
|
||||
|
||||
更新前私有备份为 .local/live/backups/before-snapshots-20261110-155834.dump,API/控制台旧镜像标签为 before-snapshots-20261110-155834。数据库备份包含业务数据,不提交仓库。本批不改终端程序。回退前需停写并评估新增任务记录及 schema 兼容性,不能只切旧镜像后丢弃维护保护;备份恢复尚未演练。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 执行任务历史查询与 CSV 导出(2026-11-11)
|
||||
|
||||
已部署本机 `http://127.0.0.1:5182/tasks` →「执行任务」,使用真实 PostgreSQL 数据。
|
||||
|
||||
## 使用与接口
|
||||
|
||||
支持关键词、操作者包含、动作、类型、状态、提交时间范围;每页 20 条,详情可打开旧任务,并保留既有核对、诊断入口。不选起始时间可查全部历史,不再截取最近 1000 条。查询固定截止时间,任务状态随平台刷新,新增任务需重新查询;导出使用已应用条件,修改草稿后不会混用。查询失败清空旧结果并禁用导出。
|
||||
|
||||
- `GET /api/v1/tasks`:SQL 分页,默认每页 20、最多 100 条。可选 `keyword/actor/action/kind/status/targetId/from/to/active`;关键词覆盖任务、目标、操作者、动作和结果。`%/_/反斜杠` 按普通文字匹配,时间使用含时区 RFC3339,边界包含两端。
|
||||
- `active=true` 查询全部 queued/running/timeout。全局刷新只取最近 100 条与分页拉全的未结束任务,旧任务继续阻止冲突操作,避免每 5 秒下载完整历史。
|
||||
- `GET /api/v1/tasks/export`:共享筛选,忽略分页,UTF-8 BOM/CRLF,10 列,时间为 UTC。成功生成记 `export_tasks` 审计;不包含 provider_ref、幂等键或原始任务参数。公式单元格加单引号。最多 10000 条,超过返回 413 并提示缩小范围;不会静默截断。
|
||||
|
||||
三角色均可查询和导出,未登录返回 401。列表总数和记录使用同一数据库快照,稳定排序 `created_at DESC,id DESC`。固定截止时间可避免正常追加造成翻页移动;历史时间回填和状态筛选结果变化仍可能改变后续页。没有新增历史清理或归档。
|
||||
|
||||
## 验收结果
|
||||
|
||||
- 独立 PostgreSQL Go 全量及 vet 通过;专项覆盖 1052 条旧任务分页、旧活动任务、组合筛选、字面通配符、导出 10000/10001 条边界、权限和公式防护。
|
||||
- 前端 lint、类型检查、3 项单元测试、生产构建通过。任务历史新增 4 项浏览器用例通过;原有快照、规格、批量电源和审计 26 项回归通过。
|
||||
- 真实环境 51 条任务按每页 2 条查询,共 26 页;CSV 的 51 个任务标识及顺序与完整分页一致。组合筛选、active、401 和非法条件拒绝、导出审计通过。
|
||||
- 真实深浅主题、1440/390px 页面、任务详情与 CSV 下载通过,无脚本错误、失败 API 或前端业务写请求。账号密码指纹、用户授权、终端身份及配置、告警规则、VM 配置和电源状态均与部署前一致,任务数仍为 51。
|
||||
|
||||
证据见 [真实接口核验](evidence/cloud-task-history-20261111/live-verification.json) 与 [真实浏览器核验](evidence/cloud-task-history-20261111/browser-verification.json)。独立打包以已部署 GPU 功能为基线,保留既有 GPU 配置;基线二进制 SHA-256 与部署前一致:`913056171c513393c24d15abfabcd7efd142a0d0bef3db6ae934f7ffa2cd8a8a`。
|
||||
|
||||
## 回退与范围
|
||||
|
||||
无数据库结构变更。部署前备份 `.local/live/backups/before-task-history-20261111-145521.dump`,原镜像标签 `before-task-history-20261111-145521`。回退前先核对后续部署,再将原 API/console 镜像打回服务名,使用 `.local/task-history-review/compose.release.yml`、原环境文件、`--project-directory deploy` 执行 `up -d --no-build --no-deps api console`,不删除业务卷。
|
||||
|
||||
专用测试 VM、Windows 副本、存储和真实还原仍按用户要求暂缓。此项不执行 VM、磁盘、GPU 或盒子控制命令。外部归档、防篡改仍待后续。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 终端系统指标告警(2026-11-11)
|
||||
|
||||
本轮在已有真实 CPU、内存、SoC 温度采集之上,接通终端系统指标的规则、持续判断、事件、确认和恢复。入口为控制台「监控告警」,无需更新盒子客户端。
|
||||
|
||||
## 使用
|
||||
|
||||
超级管理员在「告警规则 → 新增规则」选择终端 CPU、终端内存或终端 SoC 温度,配置高水位与持续时间。使用率阈值为 1~100%,SoC 温度阈值为 1~150°C,持续时间为 0~3600 秒;值大于等于阈值视为超限。温度选项默认 80°C、持续 120 秒,只是表单初值,需按实际设备调整。
|
||||
|
||||
新增规则作用于已激活且启用的终端。旧客户端、不支持的传感器、空值和过期采样均不按正常值处理。SoC 温度只使用已有 `soc-thermal` 采集,不等同于 CPU 或 GPU 温度。系统升级不会自动创建或启用这三类终端规则。
|
||||
|
||||
运维人员可查看和确认告警,只读人员可查看。确认表示已知悉,不能代替指标恢复。修改或停用规则按现有流程结束该规则的当前事件,原因标为「规则变更」,并重新计时。已恢复事件保留 90 天。
|
||||
|
||||
本轮仅控制台告警,外部通知、终端 GPU 健康指标和串流 FPS/RTT 仍未接入。
|
||||
|
||||
## 数据与持续时间
|
||||
|
||||
- 评估循环沿用 15 秒周期,读取设备最近的真实采样。设备心跳和指标都须在 90 秒以内,设备须仍启用、已注册且支持系统指标。
|
||||
- 持续时间按源采样的时间差计算,起点不早于规则最近启用或修改时间;规则启用前的旧样本不占用持续时长。重复评估同一条采样不会累加。持续时间为 0 时,首次有效超限样本即可触发。
|
||||
- 无效或缺失采样清除待触发计时,保留已发生的告警。采样间隔或监测中断超过 90 秒,新的超限采样重新开始计时。
|
||||
- 为避免漏过评估间隔中的短暂恢复,使用已有历史采样检查上次评估之后是否出现正常值或空值;发现后从当前样本重新计时。检查仅回看最近 90 秒,利用终端/时间索引。
|
||||
- 这是采样级持续判断,不代表两次采样之间每一瞬间都被测量;服务长时间中断后会保守地重新计时。
|
||||
- 已触发事件在数据缺失时保持未恢复,页面明确显示「数据已过期或缺失;显示最近告警值,等待有效采样」。恢复有效采样后,仍超限就继续原事件,正常才自动恢复,不重复创建同一未结束事件。
|
||||
- 终端停用或重新激活导致目标不可用时结束事件,原因标为「目标移除或停用」,不冒充指标自然恢复。
|
||||
|
||||
## 接口和迁移
|
||||
|
||||
沿用 `/api/v1/monitor/rules`、`/monitor/rules/{id}`、`/monitor/alerts` 和 `/monitor/alerts/{id}/acknowledge`。
|
||||
|
||||
规则的 `metric` 增加 `terminal_cpu`、`terminal_memory`、`terminal_temperature`。事件增加可空的 `dataFresh`:终端系统指标返回当前来源是否有效,其他指标返回 `null`;事件值在数据缺失时仍为最近告警值,不能视作当前测量值。设置能力增加 `terminal-metric-alerts`,OpenAPI 与前端类型同步生成。
|
||||
|
||||
启动迁移扩展 `alert_rules_metric_check`,在 `alert_rules` 增加内部更新时间 `updated_at`,在 `alert_states` 增加可空 `last_sample_at`;不清空原规则、事件、待触发状态或业务数据。反复启动可重复执行。节点资源和离线规则继续沿用原有判断,不按终端源采样方式计算。
|
||||
|
||||
## 验证与回退
|
||||
|
||||
使用独立临时 PostgreSQL 验证三指标、持续源时间、重复评估、缺失/过期/旧 Agent、不连续采样、恢复、目标撤销、阈值/RBAC和旧库重复升级。控制台验证单位、温度上限、规则提交、错误保留、过期提示、确认语义、权限及深浅主题/窄屏。完整测试和现场结果见 [证据目录](evidence/cloud-terminal-alerts-20261111/)。
|
||||
|
||||
更新前备份为私有 `.local/live/backups/before-terminal-alerts-20261111-092529.dump`;旧 API 和控制台镜像标签为 `before-terminal-alerts-20261111-092529`。回退旧 API 前应先用新版本停用这三类终端指标规则,防止旧评估器误解未知指标。新列可以保留;不要通过删卷或恢复旧库覆盖新增业务数据。
|
||||
|
||||
本轮不修改设备策略或二进制,不操作 VM 电源、快照、磁盘、GPU 或模板。现场原有 VM 状态以更新前基线为准,而不是沿用前一天的运行状态。
|
||||
|
||||
|
||||
## 最终现场结果
|
||||
|
||||
API 与控制台已更新到本机 5182。真实盒子保持 `qt-platform/0.4.0` 在线,无客户端更新。临时将温度阈值设为 1°C、持续 60 秒验证真实链路,没有给设备加压或制造过热;最终复验覆盖 4 条真实温度样本,事件从开始到触发约 102 秒(包含采样与评估周期),起点不早于规则启用时间。确认后事件仍在告警;随后将该规则设为 **80°C / 120 秒、停用**,事件按“规则变更”结束。验收期间的测试事件保留在历史中,未将这一流程冒充实际过热或自然降温恢复;正常值自动恢复、过期和缺失流程由隔离数据库及页面测试验证。
|
||||
|
||||
Go 全量测试、`go vet`、旧表结构两次升级、前端 lint / 类型 / 3 项单元测试 / 生产构建及 61 项页面回归通过。真实深浅主题、1440px 与 390px 页面检查无脚本异常、失败响应或写请求,截图展示验收结束后的停用规则和已确认历史事件。
|
||||
|
||||
原账号密码哈希指纹、终端身份、授权、桌面参数和既有规则一致;VM102 保持运行,VM100/101 保持关机,三台 VM 的配置 digest 均与本轮更新前一致。期间另有一次资源同步任务新增,已原样保留,本轮没有提交桌面或终端任务。没有操作终端、PVE 电源、快照、磁盘或 GPU。
|
||||
|
||||
证据:[后端与页面测试](evidence/cloud-terminal-alerts-20261111/validation.json)、[真实触发与确认](evidence/cloud-terminal-alerts-20261111/live-verification.json)、[真实页面](evidence/cloud-terminal-alerts-20261111/browser-verification.json)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 终端系统指标与历史(2026-11-10)
|
||||
|
||||
本轮补齐 Qt 终端的真实系统指标采集、设备鉴权上传、PostgreSQL 历史保存,以及控制台详情中的数值和趋势。串流 FPS、RTT 和 GPU 健康数据仍未采集;目标帧率、HTTP 耗时或 ping 不会被当作实际串流指标。
|
||||
|
||||
## 页面与采集
|
||||
|
||||
入口:`http://127.0.0.1:5182/terminals` → 终端详情 → **查看运行指标**。三个管理角色均可只读查看;面板打开后每 30 秒读取,关闭后停止;支持手动刷新和最近 1 小时 / 24 小时 / 7 天。失败时显示错误并允许重试,切换设备或时间范围时旧响应不会覆盖新响应。
|
||||
|
||||
| 指标 | 数据来源与含义 |
|
||||
| --- | --- |
|
||||
| CPU 使用率 | `/proc/stat` 聚合 CPU 相邻心跳差分,idle 与 iowait 计为空闲;不重复累加 guest。第一次采样、读失败、计数倒退或无差分返回空值 |
|
||||
| 内存使用率 | `/proc/meminfo` 的 `100 × (MemTotal − MemAvailable) / MemTotal`。缺少 MemAvailable 不回退为把缓存全部计入已用 |
|
||||
| SoC 温度 | sysfs 中类型为 `soc-thermal` 的 thermal zone,毫摄氏度转摄氏度。不将任意 thermal zone 命名为 CPU/GPU 温度 |
|
||||
| 运行时长 | `/proc/uptime` 第一项。未采到有效值显示“未采集” |
|
||||
|
||||
Agent 版本为 `qt-platform/0.4.0`。采集只读取系统文件,不启动额外后台守护、不改变 Moonlight 参数和性能叠图。采样频率沿用已下发的心跳策略,现场为 30 秒。
|
||||
|
||||
## 服务与数据
|
||||
|
||||
- `POST /api/v1/terminal/heartbeat` 新增可选 `systemMetrics`,包含可空的 `cpuPercent`、`memoryPercent`、`socTemperatureC`、`uptimeSeconds`。
|
||||
- 校验范围依次为 0~100%、0~100%、−40~150°C、0~3155760000 秒;不接受非数值或超限值。
|
||||
- 使用设备令牌对应的终端身份,在已有设备行锁事务内写心跳和指标,客户端不能指定另一个终端 ID。旧 Agent 未带系统指标时兼容接收,并清空当前指标,显示升级提示。
|
||||
- 新表 `terminal_metrics_current` 保存最新状态,`terminal_metric_samples` 保存历史;单终端历史至多每 10 秒一条,重复心跳仍更新当前状态。历史与当前表均外键关联设备;不修改 `terminals` 的原有字段结构。
|
||||
- `GET /api/v1/terminals/{id}/metrics?from=...&to=...` 返回当前值、在线/新鲜度、最近采样时间和聚合历史。未知终端 404,无管理登录 401。单次查询最长 7 天,6 小时以内按 60 秒、24 小时以内按 300 秒、更长按 1800 秒聚合。
|
||||
- CPU/内存/温度历史为桶内均值,运行时长为桶内最大值。显式返回空桶,页面在缺失处断线;粗粒度聚合可能合并同一桶内的短暂中断,不表示整桶内持续在线。
|
||||
- 设备停用、心跳或指标超过 90 秒,当前数值返回空值;历史仍可查。设备重新激活会清除旧身份的当前指标,保留历史。
|
||||
- 现有观测循环定期清理 30 天以前的历史。当前使用 PostgreSQL;VictoriaMetrics 尚未接入;终端系统指标告警已于 11-11 补齐,见[规则与采样判断](cloud-terminal-alerts-20261111.md)。
|
||||
|
||||
API 契约由 `tools/generate-platform-openapi.mjs` 生成,并同步到控制台类型。
|
||||
|
||||
## 验证与部署
|
||||
|
||||
Go 全量测试、`go vet`、前端 lint / 类型检查 / 单元测试 / 生产构建、57 项页面回归和盒子 6 项 CTest 均通过。真实深浅主题、390px 与 7 天聚合检查通过,无脚本异常或失败响应。
|
||||
|
||||
现场验收中连续采样真实入库,CPU 约 2.5%、内存约 10.2%、SoC 温度约 45.5°C(数值会变化)。独立读取 `/proc` 和 thermal 核对内存、温度、时长;CPU 因取样间隔不同只核对合法范围,差分算法由固定计数器测试验证。原业务身份、授权、密码哈希指纹、设备配置文件和三台 VM 的配置 digest 均一致,三台 VM 保持运行。
|
||||
|
||||
测试覆盖 Qt 文件读取、差分/重置/缺失、传感器来源和单位;后端的身份隔离、数值校验、旧 Agent、在线过期、身份重置、采样限速、聚合空桶、查询范围和留存;页面的按需加载、零值、缺失、离线、重试、窄屏和响应竞态。
|
||||
|
||||
最终现场验收及截图见 [证据目录](evidence/cloud-terminal-metrics-20261110/)。更新时备份数据库和旧镜像,盒子单独备份旧二进制,仅在无活动串流时替换 kiosk 并由原看门狗恢复;不重启 LightDM、系统或虚拟机,不更改设备令牌、密码、桌面参数或授权。
|
||||
|
||||
数据库备份:`.local/live/backups/before-terminal-metrics-20261110-173343.dump`;API 与 console 旧镜像标签:`before-terminal-metrics-20261110-173343`。回退可恢复旧镜像和旧 kiosk 二进制,新指标表可保留,旧版本不依赖它们。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 终端运维策略(2026-11-10)
|
||||
|
||||
入口为控制台「终端策略」。这批实现的是管理轮询策略,不调整分辨率、FPS、编解码或现有桌面连接参数。
|
||||
|
||||
## 功能与使用
|
||||
|
||||
- 配置模板:名称、版本;心跳间隔 15~60 秒、命令检查间隔 3~30 秒、桌面列表刷新间隔 5~120 秒。默认分别为 30/3/15 秒。列表仅在桌面选择页刷新。
|
||||
- 保存的模板版本固定。修改时复制为新版本;恢复旧配置时下发旧模板,生成新的设备配置版本。
|
||||
- 超级管理员可创建和下发。运维、只读角色可查看模板、记录和结果。
|
||||
- 支持指定设备或一个非空分组,每次最多 200 台。按提交时的成员创建固定记录,后加入分组的设备不会自动继承。
|
||||
- 下发前整体验证设备已启用、已激活且上报策略协议 1。任一设备不满足条件时整体拒绝。旧客户端显示「需更新客户端」。支持协议的离线设备允许下发,保持等待联网。
|
||||
- 「下发记录」保留每次提交的目标、策略、操作者和设备结果;「设备结果」显示当前期望配置、应用状态、回执时间和失败原因。
|
||||
- 同一请求标识重试不会重复下发;换参数须换标识。重新下发会生成更大的配置版本;尚未应用或失败的旧版本标记为已替代,历史成功结果仍保留。
|
||||
|
||||
## 设备应用语义
|
||||
|
||||
Qt 平台客户端版本为 `qt-platform/0.3.0`。登录后立即获取配置,每 15 秒检查更新;该周期独立于可配置的心跳与命令轮询。管理操作期间不会停止正在运行的串流。
|
||||
|
||||
设备先验证协议、整数范围、配置版本与下发标识,再通过 `QSaveFile` 原子保存到 `~/.config/vdi-terminal/policy.json`,权限 0600,然后更新三个运行时定时器。该文件只包含策略字段,不包含设备令牌、桌面凭据或串流参数。成功后才回传 `applied`。
|
||||
|
||||
校验失败、无法持久化、运行组件未就绪分别回传固定错误代码,不上传自由文本或敏感配置。失败设备后续拉取会尝试恢复;相同失败回执不会持续增加审计。网络中断时保留待确认回执并重试,进程重启后重新从平台读取并应用,再确认结果。
|
||||
|
||||
当前设备身份被撤销后,旧令牌不能继续回执。停用或重新激活会清除协议能力,并将当前策略重新置为待应用;新客户端完成激活、获取配置后重新回执。新版本已下发时,旧版本迟到的回执返回 409,不能覆盖新结果。设备应用成功后迟到的失败回执也不能倒退成功状态。
|
||||
|
||||
“已应用”表示客户端确认配置持久化并完成运行时更新,不表示该设备当前在线。离线或停用状态单独展示。设备未回执时不会根据 HTTP 202、配置拉取或心跳推测成功。设备断网期间不能获得新策略;没有离线授权回退。
|
||||
|
||||
## 接口与存储
|
||||
|
||||
- `GET/POST /api/v1/terminal-policy-templates`
|
||||
- `GET /api/v1/terminal-policy-states`
|
||||
- `GET/POST /api/v1/terminal-policy-deployments`
|
||||
- `GET /api/v1/terminal-policy-deployments/{id}/results`
|
||||
- `GET /api/v1/terminal/config` 返回期望版本、下发标识与完整策略。
|
||||
- `POST /api/v1/terminal/config/ack` 接收设备应用结果。
|
||||
- `POST /api/v1/terminal/heartbeat` 增加可选 `policyProtocol`;旧客户端省略即按不支持处理。
|
||||
|
||||
新增四张表:`terminal_policy_templates`、`terminal_policy_capabilities`、`terminal_policy_deployments`、`terminal_policy_deliveries`。沿用 `terminals.config_version`,每次下发递增;没有向现有业务表新增列。模板、下发与回执均持久化;创建、下发和应用结果写入审计。下发按终端 ID 排序锁定设备行,设备回执沿用设备鉴权事务,身份重置与下发不会交错修改同一终端。
|
||||
|
||||
本批没有新增网络/Wi-Fi 配置、USB/剪贴板开关、应用白名单、自动登录桌面、OTA 或画质策略。这些能力需要对应终端实现后才能提供可用开关。
|
||||
|
||||
## 部署与回退
|
||||
|
||||
更新前数据库备份:私有 `.local/live/backups/before-policy-20261110-114707.dump`;旧 API、控制台镜像标签 `before-policy-20261110`。备份包含业务数据,不提交仓库。盒子二进制更新前另存备份,只替换 kiosk-shell,不改 Moonlight、LightDM 或 VM。
|
||||
|
||||
恢复配置优先重新下发旧策略版本。回退软件前停止新增下发,确认当前策略、身份与会话状态,另行备份新增数据,再评估配套 API、控制台和客户端版本;不要直接删除业务卷。数据库快照恢复会覆盖快照后的业务数据,本批不执行恢复。
|
||||
|
||||
验证与真实部署结果见本文件后续记录和 `docs/evidence/cloud-policies-20261110/`。
|
||||
|
||||
## 验证与现场结果
|
||||
|
||||
- 独立临时 PostgreSQL:`go test ./...`、`go vet ./...` 通过。覆盖旧设备能力拒绝、离线等待、真实回执状态、失败恢复、重复请求、旧回执隔离、历史保留、身份重置、权限、整数范围、分组快照及整批校验。没有对 live 数据库运行后端测试。
|
||||
- ESLint、TypeScript、3 项单元测试、本地与 Docker 生产构建通过。浏览器共验证 22 个流程,其中新增策略 4 项;修正测试对 Element Plus 选择器的定位后新增用例全部通过。所有浏览器功能回归均拦截 API,与真实资源隔离。已有 Element Plus 大包提示仍保留。
|
||||
- 真实 ARM64 盒子编译完成,5 项 CTest 全部通过,包括新增安全持久化/配置范围/失败不更新运行时,以及既有启动、会话、选择页和显示守护测试。
|
||||
- API/控制台已更新到本机 5182;盒子仅替换空闲的 kiosk-shell,由看门狗拉起,未重启 LightDM 或 VM。新版二进制 SHA256 `690a74ee5b077ff96e385f2ba45ca756f1879223f7433e63ec1833eb0f3a628b`,旧版本备份 `~/vdi-terminal/backups/policy-20261110/kiosk-shell`。
|
||||
- 11:55 真实下发「标准运维 1.1」:心跳 30 秒、命令 5 秒、桌面列表 20 秒,配置版本 2 收到设备应用成功;随后下发 1.0 恢复 30/3/15 秒,配置版本 3 再次收到成功,现场保留两个模板版本及完整下发记录。核对实际设备策略文件与权限,重复提交没有产生第二次下发。
|
||||
- 设备身份、root 账号密码、三台桌面连接参数、用户和授权快照保持一致;心跳继续推进,三台 VM 运行,验收时活动会话为 0。没有执行 VM 电源、GPU、克隆或模板转换。未声称已完成断电恢复、长时间离线、活动串流中下发或多型号设备验收。
|
||||
|
||||
证据:[测试](evidence/cloud-policies-20261110/test-verification.json)、[真实下发](evidence/cloud-policies-20261110/live-verification.json)。`fixture-` 截图使用隔离测试数据,`live-` 截图展示真实服务。
|
||||
|
||||
现场页面补验:深浅主题、1440px 与 390px、模板/设备结果/下发详情及新增/下发弹窗均可用,8 张截图,无脚本错误和失败接口;检查期间未发送修改请求。见[页面验收](evidence/cloud-policies-20261110/browser-verification.json)及[真实设备结果](evidence/cloud-policies-20261110/live-states-dark.png)。临时测试数据库与本轮 Vite 开发服务已停止,真实 5182 服务保留。
|
||||
+34
-1
@@ -1,6 +1,8 @@
|
||||
# 当前状态
|
||||
|
||||
截至 2026-11-09,依据仓库中的实施记录整理。本次文档整理没有重新连接现场设备,下面的“已验证”指相应日期的历史验收,并非实时健康报告。
|
||||
截至 2026-11-10,依据仓库中的实施记录整理。“已验证”以对应日期和范围为准,并非持续实时健康报告;11-10 启动认证修复已重新连接盒子核验。
|
||||
|
||||
2026-11-11 更新:Ubuntu、麒麟已接入 GTX 965M 渲染与 NvFBC/NVENC,三桌面按用户授权轮流直通同一块显卡;平台和盒子已部署“退出当前串流、选择下一台时正常关机再启动”的切换流程。下文原 Linux 软件编码资源表保留为历史基线,本轮配置、测量与尚未通过的稳定性条件以 [GPU 优化实施记录](evidence/linux-gpu-20261111/README.md) 为准,不能将其描述为三桌面并发或稳定 2K60。
|
||||
|
||||
## 产品与架构
|
||||
|
||||
@@ -32,6 +34,16 @@
|
||||
|
||||
| 事项 | 记录与验证边界 |
|
||||
| --- | --- |
|
||||
| 终端系统指标告警 | 11-11 新增 CPU/内存/SoC 温度持续规则、采样有效性与恢复判断;61 项页面回归通过,无需更新终端。见[规则与验收](cloud-terminal-alerts-20261111.md) |
|
||||
| 终端系统指标 | 11-10 已部署真实 CPU、内存、SoC 温度、运行时长与历史趋势;盒子 0.4.0 连续入库,57 项页面回归和 6 项 Qt 测试通过。串流 FPS/RTT 仍未采集。见[采集与验收](cloud-terminal-metrics-20261110.md) |
|
||||
| GPU 资源查看 | 11-10 已部署真实显卡清单、PCI/IOMMU、当前/待应用配置引用和纳管关系;现场识别 Intel 核显、GTX 965M 及 VM100 引用,51 项页面回归通过。未修改直通配置,独占分配和健康指标尚未接入。见[功能与验收](cloud-gpu-inventory-20261110.md) |
|
||||
| 桌面批量电源 | 11-10 已部署最多 50 台的开机/正常关机/重启、逐台预检、批次历史、幂等任务和原结果核对;45 项页面回归通过,现场仅做预检及取消,三台 VM 保持运行。专用 VM/Windows 副本和存储准备按用户要求暂缓。见[功能与边界](cloud-batch-operations-20261110.md) |
|
||||
| CSV、会话历史与监控告警 | 11-10 增加用户/终端/授权 CSV 预检与整批提交、90 天会话历史、节点曲线与规则/确认/恢复;完整范围见[功能说明](cloud-operations-20261110.md) |
|
||||
| 终端运维策略 | 11-10 已部署模板版本、设备/分组下发、真实应用回执及失败原因;真实盒子完成修改/恢复,最终管理间隔 30/3/15 秒。见[策略与验收](cloud-terminal-policies-20261110.md) |
|
||||
| 终端与用户维护 | 11-10 设备名称/分组、停用/重新激活、用户资料/启停及批量授权已部署 5182;真实临时身份验收后清理,原盒子心跳和业务映射保留。见[功能说明](cloud-lifecycle-20261110.md) |
|
||||
| 管理员账号维护 | 11-10 真实账号新增、显示名称/角色编辑、启停、他人密码重置、本人改密与全部会话撤销已部署 5182;临时账号验收后清理,原 root 保留。见[功能清单](cloud-feature-progress-20261110.md) |
|
||||
| 云管模板与桌面池 | 11-10 增加真实 PVE 克隆编排、模板/池页面及连接初始化确认;独立数据库与 PVE 测试服务通过,现场缺模板与创建权限,未实际克隆 VM。见[功能清单](cloud-feature-progress-20261110.md) |
|
||||
| 冷启动失败不再退旧登录页 | 11-10 设备认证失败保持选择页,2~30 秒退避重试;5 个启动场景通过,盒子部署后设备认证与三桌面列表正常,尚未再次断电验收 |
|
||||
| 开机直接显示桌面列表 | 11-09 独占 LightDM 会话;保留[冷启动截图](evidence/boot-direct-list-cold-boot.png) |
|
||||
| 退出短暂露出绿色桌面 | 11-09 列表全屏常驻串流窗口下方;三系统两轮共 3121 个 X11 采样未见底层窗口 |
|
||||
| 进入左上角小窗口 | 11-09 Moonlight 0007 隐藏 CLI Qt 窗口,SDL 创建时铺满屏幕;三系统进入 2695 次、返回 1562 次采样均无违规 |
|
||||
@@ -56,3 +68,24 @@
|
||||
- Rust Agent、MQTT、OTA、多租户授权及通用 USB 会话隔离仍待产品化。已有 POC 模块不等于完整功能交付。
|
||||
|
||||
当前构建、策略和排障入口分别见[终端说明](terminal-guide.md)、[云桌面说明](desktop-guide.md)、[运维说明](operations.md);待办统一见[未来计划](roadmap.md)。历史精确配置和回退路径可从[归档索引](archive/README.md)查原文。
|
||||
|
||||
## 11-10 接入配置与规格管理
|
||||
|
||||
本机 5182 已部署 PVE 网页接入维护、关机后 CPU/内存及虚拟磁盘扩容任务、管理员登录限流。原接入配置已加密保存并通过 API 重启验证;真实三台 VM 只读规格及运行中拦截通过,规格写入仍缺现场 Token 权限,未做真实 VM 修改。原 root 密码、桌面连接、授权与终端身份快照一致,盒子心跳正常。细节与证据见[接入、规格和限流](cloud-configuration-20261110.md)。
|
||||
|
||||
|
||||
## 11-10 关机快照与手动还原
|
||||
|
||||
本机 5182 已部署真实快照列表、创建/删除/手动还原任务及只读核对,维护期间和未知结果阻止新连接与桌面修改。还原撤销旧票据并要求重新确认初始化。Go 全量、37 项浏览器回归和窄屏复验通过;现场只读检查保持原业务配置和三台 VM 运行,盒子心跳正常。当前 Token 无快照权限,PVE 报告 VM100 不支持快照,真实写入尚未验收;公共池退出自动还原未实现。见[快照与还原说明](cloud-snapshots-20261110.md)。
|
||||
|
||||
11-11 已补齐[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)及[执行任务历史查询与 CSV 导出](cloud-task-history-20261111.md),均支持数据库分页、多条件查询和全部匹配页导出;外部归档和防篡改仍待后续。
|
||||
|
||||
11-11 已补齐[管理员在线登录管理](cloud-admin-sessions-20261111.md):查看登录来源及到期时间,单独撤销指定登录并记录审计,保留当前登录保护。
|
||||
|
||||
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。
|
||||
|
||||
11-11 已部署[通用版与高级版](cloud-editions-20261111.md):通用版五个日常入口,真实员工授权、设备登记、只读问题诊断和简明操作记录;高级版保留专业管理并补齐显卡准备超时任务核对入口。模型暂不接入,界面模式不改变账号权限或原功能验收范围。
|
||||
|
||||
11-11 已部署[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按唯一设备/桌面关联任务、会话、告警、审计和设备日志,支持 7 天内筛选、分页与完整 ZIP 导出;真实盒子通过页面采集成功并展示/导出。6 项后端专项、Go 全量/vet、25 项页面回归及真实权限/深浅/窄屏检查通过。持续实时日志、全量归档和 VM 客体日志自动采集仍未实现。
|
||||
|
||||
12-11 通用版工作台已加入不依赖模型的固定场景方案向导:匹配员工和已有桌面、展示影响、确认后调用真实授权接口,并在对象不唯一或场景不支持时阻止写操作。前端检查、构建和 15 项通用版浏览器回归通过;已发布到真实 5182,并完成临时员工授权执行与清理。
|
||||
@@ -0,0 +1,144 @@
|
||||
Start testing: Nov 10 09:28 CST
|
||||
----------------------------------------------------------
|
||||
1/4 Testing: display-guard
|
||||
1/4 Test: display-guard
|
||||
Command: "/usr/bin/python3" "/home/armbian/vdi-build/boot-device-auth-20261110/tests/test_display_guard.py"
|
||||
Directory: /home/armbian/vdi-build/boot-device-auth-20261110/build
|
||||
"display-guard" start time: Nov 10 09:28 CST
|
||||
Output:
|
||||
----------------------------------------------------------
|
||||
.........
|
||||
----------------------------------------------------------------------
|
||||
Ran 9 tests in 0.110s
|
||||
|
||||
OK
|
||||
<end of output>
|
||||
Test time = 0.83 sec
|
||||
----------------------------------------------------------
|
||||
Test Passed.
|
||||
"display-guard" end time: Nov 10 09:28 CST
|
||||
"display-guard" time elapsed: 00:00:00
|
||||
----------------------------------------------------------
|
||||
|
||||
2/4 Testing: moonlight-session
|
||||
2/4 Test: moonlight-session
|
||||
Command: "/home/armbian/vdi-build/boot-device-auth-20261110/build/test-moonlight-session"
|
||||
Directory: /home/armbian/vdi-build/boot-device-auth-20261110/build
|
||||
"moonlight-session" start time: Nov 10 09:28 CST
|
||||
Output:
|
||||
----------------------------------------------------------
|
||||
********* Start testing of MoonlightSessionTest *********
|
||||
Config: Using QtTest library 5.15.13, Qt 5.15.13 (arm64-little_endian-lp64 shared (dynamic) release build; by GCC 13.2.0), ubuntu 24.04
|
||||
PASS : MoonlightSessionTest::initTestCase()
|
||||
PASS : MoonlightSessionTest::parsesSplitReadyMarkers()
|
||||
PASS : MoonlightSessionTest::requiresBothReadyMarkers()
|
||||
PASS : MoonlightSessionTest::acceptsSeparateHardwareTestDecoder()
|
||||
PASS : MoonlightSessionTest::rejectsSecondStart()
|
||||
PASS : MoonlightSessionTest::reportsKioskLaunchFailure()
|
||||
PASS : MoonlightSessionTest::buildsConsistentStreamArguments()
|
||||
PASS : MoonlightSessionTest::selectsProfileWithoutLosingStats()
|
||||
PASS : MoonlightSessionTest::rejectsInvalidDecoderOverride()
|
||||
PASS : MoonlightSessionTest::cleanupTestCase()
|
||||
Totals: 10 passed, 0 failed, 0 skipped, 0 blacklisted, 917ms
|
||||
********* Finished testing of MoonlightSessionTest *********
|
||||
<end of output>
|
||||
Test time = 0.94 sec
|
||||
----------------------------------------------------------
|
||||
Test Passed.
|
||||
"moonlight-session" end time: Nov 10 09:28 CST
|
||||
"moonlight-session" time elapsed: 00:00:00
|
||||
----------------------------------------------------------
|
||||
|
||||
3/4 Testing: desktop-list-page
|
||||
3/4 Test: desktop-list-page
|
||||
Command: "/home/armbian/vdi-build/boot-device-auth-20261110/build/test-desktop-list-page"
|
||||
Directory: /home/armbian/vdi-build/boot-device-auth-20261110/build
|
||||
"desktop-list-page" start time: Nov 10 09:28 CST
|
||||
Output:
|
||||
----------------------------------------------------------
|
||||
********* Start testing of DesktopListPageTest *********
|
||||
Config: Using QtTest library 5.15.13, Qt 5.15.13 (arm64-little_endian-lp64 shared (dynamic) release build; by GCC 13.2.0), ubuntu 24.04
|
||||
PASS : DesktopListPageTest::initTestCase()
|
||||
PASS : DesktopListPageTest::firstClickDisablesActionsAndBlocksDuplicate()
|
||||
PASS : DesktopListPageTest::cleanupTestCase()
|
||||
Totals: 3 passed, 0 failed, 0 skipped, 0 blacklisted, 60ms
|
||||
********* Finished testing of DesktopListPageTest *********
|
||||
<end of output>
|
||||
Test time = 0.11 sec
|
||||
----------------------------------------------------------
|
||||
Test Passed.
|
||||
"desktop-list-page" end time: Nov 10 09:28 CST
|
||||
"desktop-list-page" time elapsed: 00:00:00
|
||||
----------------------------------------------------------
|
||||
|
||||
4/4 Testing: main-window-boot
|
||||
4/4 Test: main-window-boot
|
||||
Command: "/home/armbian/vdi-build/boot-device-auth-20261110/build/test-main-window"
|
||||
Directory: /home/armbian/vdi-build/boot-device-auth-20261110/build
|
||||
"main-window-boot" start time: Nov 10 09:28 CST
|
||||
Output:
|
||||
----------------------------------------------------------
|
||||
********* Start testing of vdi::MainWindowTest *********
|
||||
Config: Using QtTest library 5.15.13, Qt 5.15.13 (arm64-little_endian-lp64 shared (dynamic) release build; by GCC 13.2.0), ubuntu 24.04
|
||||
PASS : vdi::MainWindowTest::initTestCase()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() vdi_device_auth_request http_status= 0 network_error= 1
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() vdi_device_auth result=waiting retry_ms= 30
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() vdi_device_auth_request http_status= 0 network_error= 1
|
||||
QWARN : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() vdi_device_auth result=waiting retry_ms= 60
|
||||
QINFO : vdi::MainWindowTest::offlineBootRecoversWithoutRestart() vdi_device_auth result=ready
|
||||
PASS : vdi::MainWindowTest::offlineBootRecoversWithoutRestart()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth_request http_status= 403 network_error= 201
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth result=waiting retry_ms= 30
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth_request http_status= 403 network_error= 201
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth result=waiting retry_ms= 60
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth_request http_status= 403 network_error= 201
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth result=waiting retry_ms= 120
|
||||
QINFO : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() vdi_device_auth result=ready
|
||||
QWARN : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries() This plugin does not support propagateSizeHints()
|
||||
PASS : vdi::MainWindowTest::rejectedIdentityStaysOnSelectionAndRetries()
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support raise()
|
||||
QINFO : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() vdi_device_auth result=ready
|
||||
QWARN : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap() This plugin does not support propagateSizeHints()
|
||||
PASS : vdi::MainWindowTest::slowAuthenticationDoesNotOverlap()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin() vdi_device_auth result=waiting retry_ms= 30
|
||||
PASS : vdi::MainWindowTest::invalidConfigurationNeverShowsLogin()
|
||||
QWARN : vdi::MainWindowTest::localModeStillLoadsDesktops() This plugin does not support propagateSizeHints()
|
||||
QWARN : vdi::MainWindowTest::localModeStillLoadsDesktops() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::localModeStillLoadsDesktops() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::localModeStillLoadsDesktops() This plugin does not support raise()
|
||||
QWARN : vdi::MainWindowTest::localModeStillLoadsDesktops() This plugin does not support raise()
|
||||
QINFO : vdi::MainWindowTest::localModeStillLoadsDesktops() vdi_device_auth result=ready
|
||||
PASS : vdi::MainWindowTest::localModeStillLoadsDesktops()
|
||||
PASS : vdi::MainWindowTest::cleanupTestCase()
|
||||
Totals: 7 passed, 0 failed, 0 skipped, 0 blacklisted, 4104ms
|
||||
********* Finished testing of vdi::MainWindowTest *********
|
||||
<end of output>
|
||||
Test time = 4.19 sec
|
||||
----------------------------------------------------------
|
||||
Test Passed.
|
||||
"main-window-boot" end time: Nov 10 09:28 CST
|
||||
"main-window-boot" time elapsed: 00:00:04
|
||||
----------------------------------------------------------
|
||||
|
||||
End testing: Nov 10 09:28 CST
|
||||
@@ -0,0 +1,4 @@
|
||||
[1/6] Automatic MOC for target kiosk-shell
|
||||
[2/4] Automatic MOC for target test-main-window
|
||||
9dba9c66092aa9ccf67477b305aaefe09f52eabb8c6b30dbbcbb1ce43dc7a6a6 /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||
old_kiosk_pid=1789
|
||||
@@ -0,0 +1,12 @@
|
||||
--- Current kiosk ---
|
||||
PID PPID COMMAND
|
||||
12913 12912 /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||
DESKTOP_SESSION=vdi-kiosk
|
||||
DISPLAY=:0
|
||||
9dba9c66092aa9ccf67477b305aaefe09f52eabb8c6b30dbbcbb1ce43dc7a6a6 /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||
--- Authentication ---
|
||||
vdi_device_auth result=ready
|
||||
--- No host desktop processes ---
|
||||
--- Boot ID unchanged ---
|
||||
7ca3f9d2-9316-45cd-a490-7fc5c3db8ec4
|
||||
{"http_status": 200, "authorized_desktops": [{"name": "Ubuntu", "fps": 45, "width": 2560, "height": 1440}, {"name": "Windows 10", "fps": 45, "width": 2560, "height": 1440}, {"name": "麒麟", "fps": 30, "width": 2560, "height": 1440}], "identity_file_mode": "0o600"}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"date": "2026-11-10",
|
||||
"cause": "A failed one-shot device authentication transitioned DESKTOP_LIST to CONFIG and had no authentication retry; the first underlying network/TLS/HTTP failure was not logged.",
|
||||
"changes": [
|
||||
"Stay on desktop selection for authentication failure",
|
||||
"Retry after completion with 2/4/8/16/30 second backoff",
|
||||
"Do not load local authorization or request tickets before authentication",
|
||||
"Log authentication HTTP status and network error code without credentials"
|
||||
],
|
||||
"ctest_groups_passed": 4,
|
||||
"new_regression_scenarios": [
|
||||
"offline startup and recovery without restart",
|
||||
"HTTP 403 followed by authorization recovery",
|
||||
"slow authentication does not overlap",
|
||||
"synchronous invalid CA configuration stays on selection",
|
||||
"local POC mode still loads desktops"
|
||||
],
|
||||
"deployed_kiosk_sha256": "9dba9c66092aa9ccf67477b305aaefe09f52eabb8c6b30dbbcbb1ce43dc7a6a6",
|
||||
"previous_kiosk_sha256": "2cca9d2d183c2f34f0f6172db207649decb2799414f9443043d175bbb19d33a1",
|
||||
"backup": "/home/armbian/vdi-terminal/backups/boot-device-auth-20261110/",
|
||||
"live_checks": {
|
||||
"device_auth": "ready",
|
||||
"authorized_desktops_http": 200,
|
||||
"authorized_desktops_count": 3,
|
||||
"session": "vdi-kiosk",
|
||||
"boot_id": "7ca3f9d2-9316-45cd-a490-7fc5c3db8ec4",
|
||||
"kernel_rebooted": false,
|
||||
"host_desktop_processes": false,
|
||||
"selection_screenshot": "selection-page.png"
|
||||
},
|
||||
"power_cycle_validation": "Not performed; recovery scenarios used isolated HTTPS test platform on the box."
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"time": "2026-11-11T07:17:20.960Z",
|
||||
"realAPI": true,
|
||||
"themes": [
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"viewportWidths": [
|
||||
1440,
|
||||
390
|
||||
],
|
||||
"scriptErrors": [],
|
||||
"failedResponses": [],
|
||||
"blockedWrites": [],
|
||||
"screenshots": 3
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"time": "2026-11-11T07:09:39.915702+00:00",
|
||||
"backup": ".local/live/backups/before-admin-sessions-20261111-150936.dump",
|
||||
"backupBytes": 256037,
|
||||
"oldImageTag": "before-admin-sessions-20261111-150936",
|
||||
"oldImages": {
|
||||
"api": "sha256:92138b1111fab09270b192ce465ba66fa98393158bace1a359a7553f92dda046",
|
||||
"console": "sha256:828b045abf8c6eca17592a1bfbd2b62f8e50b723f4dbd6439d7f3ebfbccc3fd7"
|
||||
},
|
||||
"deployedAt": "2026-11-11T07:14:11.930124+00:00",
|
||||
"baselineBinarySHA256": "78f454b057fd2de9510fa966ada07654692c609181faa62dc72c96391f0d95ec",
|
||||
"baselineMatchesLiveBinary": true,
|
||||
"existingGPUConfigurationPreserved": true,
|
||||
"activeJobsBeforeDeployment": 0,
|
||||
"newImages": {
|
||||
"api": "sha256:4bc0609343b3c9382c859444579c0246bc12bb6ff787c2d0fb784ec7942db3b7",
|
||||
"console": "sha256:52c38fa17705374202f557edd959271e0fae1c88d9f104a356b266dbd54b5e83"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Loaded 100 of 496 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user