功能:新增云管API与Web控制台并完成现场接入
This commit is contained in:
parent
18a9ce995d
commit
3fb7b4da8c
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/test-results
|
||||||
|
**/playwright-report
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/*.exe
|
||||||
|
terminal
|
||||||
|
docs
|
||||||
|
tools
|
||||||
|
.local
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -7,7 +7,19 @@ CMakeCache.txt
|
|||||||
*.tmp
|
*.tmp
|
||||||
.zcode/
|
.zcode/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
~$*
|
||||||
test-output/
|
test-output/
|
||||||
screenshots/
|
screenshots/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
playwright-report/
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
.local/
|
||||||
|
*.exe
|
||||||
|
|||||||
2
console/.env.example
Normal file
2
console/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# api: connect the Go backend; mock: browser-only demo, no real operations.
|
||||||
|
VITE_API_MODE=api
|
||||||
@ -1 +0,0 @@
|
|||||||
# Keep the monorepo directory structure in Git.
|
|
||||||
13
console/Dockerfile
Normal file
13
console/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY console/package.json console/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY console/ ./
|
||||||
|
ARG VITE_DEMO_HINT=false
|
||||||
|
ENV VITE_API_MODE=api VITE_DEMO_HINT=$VITE_DEMO_HINT
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
21
console/README.md
Normal file
21
console/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# VDI Web 管理控制台
|
||||||
|
|
||||||
|
Vue 3、TypeScript、Vite、Element Plus、Pinia、Vue Router。使用者是管理员,与盒子上的 Qt 桌面选择页分开。
|
||||||
|
|
||||||
|
运行、演示账号和完整能力边界见[开发说明](../docs/cloud-platform-development.md)。
|
||||||
|
|
||||||
|
后台样式已按仓库 `ui/` 设计稿接入,支持深浅主题、桌面卡片与列表、远程会话页和窄屏导航;详见 [UI 接入记录](../docs/cloud-console-ui-20261109.md)。真实现场入口为 `http://127.0.0.1:5182`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/api/ 从 OpenAPI 生成的类型与统一 HTTP 适配
|
||||||
|
src/stores/ 会话与平台数据
|
||||||
|
src/router/ 登录与角色路由守卫
|
||||||
|
src/layouts/ 控制台导航与定时刷新
|
||||||
|
src/views/ 第一阶段功能页面
|
||||||
|
src/components/ 共用状态等组件
|
||||||
|
src/styles/ UI 可替换的设计 token
|
||||||
|
src/mocks/ 开发专用 MSW 模拟接口
|
||||||
|
e2e/ Playwright 主路径验收
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run api:generate` 更新接口契约与类型;API 适配使用 openapi-fetch,页面不直接发网络请求。默认 `npm run dev` 通过 `/api` 代理到本机 `8088` 端口。开发时设 `VITE_API_MODE=mock` 则使用浏览器模拟接口,生产构建不会启用模拟 Worker。
|
||||||
102
console/e2e/console.spec.ts
Normal file
102
console/e2e/console.spec.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
async function login(page: Page, username = 'admin') {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByRole('textbox', { name: '用户名', exact: true }).fill(username)
|
||||||
|
await page.getByRole('textbox', { name: '密码', exact: true }).fill('Demo@2026')
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
|
||||||
|
}
|
||||||
|
test.beforeAll(async ({ request }) => { const r = await request.get('/healthz'); expect(r.ok()).toBe(true); expect((await r.json()).mode).toBe('demo') })
|
||||||
|
test('登录失败、登录恢复与退出', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByRole('textbox', { name: '用户名', exact: true }).fill('admin')
|
||||||
|
await page.getByRole('textbox', { name: '密码', exact: true }).fill('incorrect')
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
await expect(page.getByText('用户名或密码错误')).toBeVisible()
|
||||||
|
await page.getByRole('textbox', { name: '密码', exact: true }).fill('Demo@2026')
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '退出登录' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
|
test('桌面命令与最终任务结果', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '桌面管理' }).click()
|
||||||
|
await page.getByRole('button', { name: '列表视图' }).click()
|
||||||
|
const row = page.getByRole('row').filter({ hasText: '国产办公桌面' })
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
const currentStopped = await row.getByText('已关机', { exact: true }).count()
|
||||||
|
const action = currentStopped ? '开机' : '关机'
|
||||||
|
await row.getByRole('button', { name: action, exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '确认提交', exact: true }).click()
|
||||||
|
await expect(page.getByText('任务已提交,请在任务与审计中查看最终结果')).toBeVisible()
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '任务与审计' }).click()
|
||||||
|
const task = page.getByRole('row').filter({ hasText: '国产办公桌面' }).first()
|
||||||
|
await expect(task.getByText('成功', { exact: true })).toBeVisible()
|
||||||
|
await expect(task).toContainText('演示计算任务已完成')
|
||||||
|
})
|
||||||
|
test('创建用户、编辑多个桌面授权并持久化', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '用户与授权' }).click()
|
||||||
|
await page.getByRole('button', { name: '新建用户', exact: true }).click()
|
||||||
|
const dialog = page.getByRole('dialog')
|
||||||
|
const name = `测试用户 ${Date.now()}`
|
||||||
|
await dialog.getByRole('textbox', { name: /姓名/ }).fill(name)
|
||||||
|
await dialog.getByRole('textbox', { name: /用户账号/ }).fill(`e2e.${Date.now()}`)
|
||||||
|
await dialog.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||||
|
await page.getByRole('textbox', { name: '搜索用户' }).fill(name)
|
||||||
|
await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '编辑授权' }).click()
|
||||||
|
await dialog.locator('label.el-checkbox').filter({ hasText: '设计专属桌面' }).click()
|
||||||
|
await expect(dialog.getByRole('checkbox', { name: /设计专属桌面/ })).toBeChecked()
|
||||||
|
await dialog.locator('label.el-checkbox').filter({ hasText: '研发办公桌面' }).click()
|
||||||
|
await dialog.getByRole('button', { name: '保存授权' }).click()
|
||||||
|
const row = page.getByRole('row').filter({ hasText: name })
|
||||||
|
await expect(row).toContainText('设计专属桌面')
|
||||||
|
await expect(row).toContainText('研发办公桌面')
|
||||||
|
await page.reload()
|
||||||
|
await page.getByRole('textbox', { name: '搜索用户' }).fill(name)
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: name })).toContainText('设计专属桌面')
|
||||||
|
})
|
||||||
|
test('登记终端只显示一次激活密钥', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '终端管理' }).click()
|
||||||
|
await page.getByRole('button', { name: '登记终端' }).click()
|
||||||
|
const dialog = page.getByRole('dialog')
|
||||||
|
const serial = `E2E-${Date.now()}`
|
||||||
|
await dialog.getByRole('textbox', { name: '设备 SN' }).fill(serial)
|
||||||
|
await dialog.getByRole('textbox', { name: '终端名称' }).fill('联调终端')
|
||||||
|
await dialog.getByRole('button', { name: '登记并生成激活信息' }).click()
|
||||||
|
await expect(page.getByTestId('activation-secret')).toContainText('activate_')
|
||||||
|
await page.getByRole('button', { name: '已保存,关闭' }).click()
|
||||||
|
await page.getByRole('textbox', { name: '搜索终端' }).fill(serial)
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: serial })).toContainText('待激活')
|
||||||
|
})
|
||||||
|
for (const username of ['viewer', 'operator']) test(`${username} 权限边界`, async ({ page }) => {
|
||||||
|
await login(page, username)
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '用户与授权' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '新建用户' })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('button', { name: '编辑授权' })).toHaveCount(0)
|
||||||
|
await page.goto('/settings')
|
||||||
|
await expect(page.getByText('无访问权限', { exact: true })).toBeVisible()
|
||||||
|
await page.goto('/desktops')
|
||||||
|
await expect(page.getByRole('heading', { name: '桌面管理' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '列表视图' }).click()
|
||||||
|
if (username === 'viewer') await expect(page.getByRole('button', { name: '关机', exact: true })).toHaveCount(0)
|
||||||
|
else await expect(page.getByRole('button', { name: '关机', exact: true }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
test('空列表、刷新异常及 401 处理', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '桌面管理' }).click()
|
||||||
|
await page.getByRole('textbox', { name: '搜索桌面' }).fill('不存在的桌面')
|
||||||
|
await expect(page.getByText('暂无符合条件的桌面')).toBeVisible()
|
||||||
|
await page.route('**/api/v1/overview', route => route.fulfill({ status: 503, json: { code: 'unavailable', message: '测试服务暂不可用', requestId: 'test-offline' } }))
|
||||||
|
await page.getByRole('navigation').getByRole('link', { name: '云桌面总览', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '刷新数据' }).click()
|
||||||
|
await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible()
|
||||||
|
await page.unroute('**/api/v1/overview')
|
||||||
|
await page.evaluate(() => sessionStorage.setItem('vdi.accessToken', 'invalid-token'))
|
||||||
|
await page.reload()
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
40
console/e2e/live.spec.ts
Normal file
40
console/e2e/live.spec.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {test,expect} from '@playwright/test'
|
||||||
|
import {readFileSync,mkdirSync} from 'node:fs'
|
||||||
|
const env=Object.fromEntries(readFileSync('../.local/live/.env','utf8').trim().split(/\r?\n/).map(line=>{ const i=line.indexOf('='); return [line.slice(0,i),line.slice(i+1)] }))
|
||||||
|
function required(key: string) { const value=env[key]; if (!value) throw new Error(`Missing live configuration: ${key}`); return value }
|
||||||
|
test('现场真实资源、设备在线、日志采集与下载',async({page,request})=>{
|
||||||
|
const health=await request.get('/healthz'); expect((await health.json()).mode).toBe('production')
|
||||||
|
await page.goto('/login')
|
||||||
|
await expect(page.getByText(/演示账号/)).toHaveCount(0)
|
||||||
|
await page.getByRole('textbox',{name:'用户名',exact:true}).fill(required('VDI_ADMIN_USERNAME'))
|
||||||
|
await page.getByRole('textbox',{name:'密码',exact:true}).fill(required('VDI_ADMIN_PASSWORD'))
|
||||||
|
await page.getByRole('button',{name:'登录',exact:true}).click()
|
||||||
|
await expect(page.getByRole('heading',{name:'运行总览'})).toBeVisible()
|
||||||
|
await expect(page.getByText('starhe',{exact:true})).toBeVisible()
|
||||||
|
mkdirSync('../.local/live/screenshots',{recursive:true})
|
||||||
|
await page.screenshot({path:'../.local/live/screenshots/overview.png',fullPage:true,animations:'disabled'})
|
||||||
|
await page.getByRole('navigation').getByRole('link',{name:'桌面管理'}).click()
|
||||||
|
await expect(page.getByRole('heading',{name:'桌面管理',exact:true})).toBeVisible()
|
||||||
|
await page.getByRole('button',{name:'列表视图'}).click()
|
||||||
|
for(const name of ['Windows 10','Ubuntu','麒麟']) await expect(page.getByRole('row').filter({hasText:name})).toBeVisible()
|
||||||
|
await page.screenshot({path:'../.local/live/screenshots/desktops.png',fullPage:true,animations:'disabled'})
|
||||||
|
await page.getByRole('navigation').getByRole('link',{name:'终端管理'}).click()
|
||||||
|
const row=page.getByRole('row').filter({hasText:'ee621cae7199a2e9'})
|
||||||
|
await expect(row.getByText('在线',{exact:true})).toBeVisible()
|
||||||
|
await row.getByRole('button',{name:'详情',exact:true}).click()
|
||||||
|
await expect(page.getByText('qt-platform/0.2.0',{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole('button',{name:'收集日志',exact:true}).click()
|
||||||
|
await page.getByRole('button',{name:'确认提交',exact:true}).click()
|
||||||
|
await page.getByRole('dialog',{name:'终端详情'}).locator('button.el-drawer__close-btn').click()
|
||||||
|
await page.getByRole('navigation').getByRole('link',{name:'任务与审计'}).click()
|
||||||
|
const task=page.getByRole('row').filter({hasText:'收集日志'}).first()
|
||||||
|
await expect(task.getByText('成功',{exact:true})).toBeVisible()
|
||||||
|
await task.getByRole('button').click()
|
||||||
|
const download=page.waitForEvent('download')
|
||||||
|
await page.getByRole('button',{name:'下载诊断日志'}).click()
|
||||||
|
const artifact=await download
|
||||||
|
const path='../.local/live/device-diagnostics.json'; await artifact.saveAs(path)
|
||||||
|
const diagnostic=JSON.parse(readFileSync(path,'utf8'))
|
||||||
|
expect(diagnostic.agentVersion).toBe('qt-platform/0.2.0'); expect(diagnostic.bootId).toBeTruthy(); expect(diagnostic.uptimeSeconds).toBeGreaterThan(0)
|
||||||
|
await page.screenshot({path:'../.local/live/screenshots/diagnostics.png',fullPage:true,animations:'disabled'})
|
||||||
|
})
|
||||||
84
console/e2e/ui.spec.ts
Normal file
84
console/e2e/ui.spec.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// Browser fixtures exercise UI states without sending commands to PVE or a terminal.
|
||||||
|
async function fixtures(page: Page, role = 'super_admin', state = 'connected') {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const desktop = { id: 'ui-desk', name: '界面测试桌面', os: 'Ubuntu', node: 'ui-node', vmid: 9000, pool: '', powerState: 'running', serviceState: 'unknown', provider: 'proxmox', endpoint: 'desktop.example', app: 'Desktop', width: 2560, height: 1440, fps: 45, updatedAt: now }
|
||||||
|
const terminal = { id: 'ui-terminal', sn: 'UI-TEST-DEVICE', name: '界面测试终端', group: '测试组', status: 'online', agentVersion: 'ui-fixture', lastSeen: now, sessionDesktopId: desktop.id, sessionState: state, configVersion: 1, fps: null, rtt: null }
|
||||||
|
const commands: unknown[] = []
|
||||||
|
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'ui-fixture-token'))
|
||||||
|
await page.route('**/api/v1/**', async route => {
|
||||||
|
const request = route.request(), endpoint = new URL(request.url()).pathname.replace('/api/v1', '')
|
||||||
|
if (request.method() === 'POST' && endpoint.endsWith('/commands')) {
|
||||||
|
commands.push(request.postDataJSON())
|
||||||
|
await route.fulfill({ status: 202, json: { id: 'ui-command', kind: 'terminal', action: 'reset-session', targetId: terminal.id, targetName: terminal.name, actor: 'ui-admin', status: 'queued', message: '等待回执', providerRef: '', createdAt: now, updatedAt: now } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === '/auth/me') { await route.fulfill({ json: { id: 'ui-admin', username: 'ui-admin', displayName: '界面测试管理员', role } }); return }
|
||||||
|
if (endpoint === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
|
||||||
|
if (endpoint === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 1, runningDesktops: 1, activeSessions: state === 'connected' ? 1 : 0, failedTasks: 0, mode: 'production' } }); return }
|
||||||
|
const lists: Record<string, unknown[]> = {
|
||||||
|
'/desktops': [desktop], '/terminals': [terminal], '/users': [{ id: 'ui-user', username: 'ui-user', displayName: '测试使用者', enabled: true, terminalId: terminal.id, desktopIds: [desktop.id] }],
|
||||||
|
'/resources': [{ id: 'ui-node', name: 'ui-node', status: 'online', cpu: .2, memory: .4, storage: .3, provider: 'proxmox', updatedAt: now }], '/tasks': [], '/audit-logs': [],
|
||||||
|
}
|
||||||
|
if (endpoint in lists) { const items = lists[endpoint] || []; await route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'ui-fixture' } }); return }
|
||||||
|
await route.fulfill({ status: 404, json: { code: 'fixture_not_found', message: '测试接口未定义', requestId: 'ui-fixture' } })
|
||||||
|
})
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
test('远程会话保留缺失指标,取消断开不发命令,确认后使用现有接口', async ({ page }, testInfo) => {
|
||||||
|
const commands = await fixtures(page)
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||||
|
await page.goto('/sessions')
|
||||||
|
await expect(page.getByRole('heading', { name: '会话信息', exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('桌面正在终端显示')).toBeVisible()
|
||||||
|
await expect(page.getByText('暂无数据', { exact: true })).toHaveCount(2)
|
||||||
|
await expect(page.getByText('尚未采集', { exact: true })).toBeVisible()
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('session-fixture-dark.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
await page.getByRole('button', { name: '切换浅色主题' }).click()
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('session-fixture-light.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
await page.getByRole('button', { name: '断开连接', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '取消', exact: true }).click()
|
||||||
|
expect(commands).toHaveLength(0)
|
||||||
|
await page.getByRole('button', { name: '断开连接', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '确认提交', exact: true }).click()
|
||||||
|
await expect(page.getByText('断开命令已提交,等待终端回执')).toBeVisible()
|
||||||
|
expect(commands).toEqual([{ action: 'reset-session' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('只读角色可查看会话,没有断开按钮与系统设置入口', async ({ page }) => {
|
||||||
|
await fixtures(page, 'viewer')
|
||||||
|
await page.goto('/sessions')
|
||||||
|
await expect(page.getByRole('heading', { name: '会话信息', exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '断开连接', exact: true })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '系统设置' })).toHaveCount(0)
|
||||||
|
await page.goto('/settings')
|
||||||
|
await expect(page.getByText('无访问权限', { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('连接预约不显示为已连接,未上报指标保持为空', async ({ page }) => {
|
||||||
|
await fixtures(page, 'operator', 'connecting')
|
||||||
|
await page.goto('/sessions')
|
||||||
|
await expect(page.getByText('终端正在建立连接')).toBeVisible()
|
||||||
|
await expect(page.getByText('0 个已连接', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('1 个连接中', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('暂无数据', { exact: true })).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('深浅主题跨页面和刷新保留,窄屏导航可用', async ({ page }) => {
|
||||||
|
await fixtures(page)
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await page.goto('/overview')
|
||||||
|
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '切换浅色主题' }).click()
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||||
|
await page.getByRole('button', { name: '打开导航' }).click()
|
||||||
|
await page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '桌面管理' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: '桌面管理', exact: true })).toBeVisible()
|
||||||
|
await expect(page.locator('.sidebar')).not.toHaveClass(/is-open/)
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||||
|
await expect(page.locator('.desktop-card')).toHaveCount(1)
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
|
||||||
|
})
|
||||||
11
console/eslint.config.js
Normal file
11
console/eslint.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import ts from 'typescript-eslint'
|
||||||
|
import vue from 'eslint-plugin-vue'
|
||||||
|
import globals from 'globals'
|
||||||
|
export default ts.config(
|
||||||
|
{ ignores: ['dist/**', 'public/mockServiceWorker.js', 'playwright-report/**', 'test-results/**', 'src/api/schema.d.ts'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...vue.configs['flat/recommended'],
|
||||||
|
{ languageOptions: { globals: { ...globals.browser, ...globals.node }, parserOptions: { parser: ts.parser } }, rules: { 'vue/multi-word-component-names': 'off', 'vue/max-attributes-per-line': 'off', 'vue/html-self-closing': 'off', 'vue/singleline-html-element-content-newline': 'off', 'vue/html-indent': 'off', 'vue/html-closing-bracket-newline': 'off', 'vue/first-attribute-linebreak': 'off', 'vue/attributes-order': 'off' } },
|
||||||
|
)
|
||||||
2
console/index.html
Normal file
2
console/index.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>VDI 云桌面管理平台</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
4690
console/package-lock.json
generated
Normal file
4690
console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
console/package.json
Normal file
52
console/package.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "vdi-console",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"api:generate": "node ../tools/generate-platform-openapi.mjs && openapi-typescript ../docs/openapi/platform.v1.json -o src/api/schema.d.ts",
|
||||||
|
"mock:init": "msw init public --save"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"element-plus": "^2.14.8",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.45",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"eslint": "^9.39.0",
|
||||||
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"msw": "^2.12.7",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.3",
|
||||||
|
"vite": "^7.2.2",
|
||||||
|
"vitest": "^5.1.0",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@typescript-eslint/typescript-estree": {
|
||||||
|
"minimatch": "11.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
console/playwright.config.ts
Normal file
11
console/playwright.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
testIgnore: '**/live.spec.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 30000,
|
||||||
|
expect: { timeout: 12000 },
|
||||||
|
use: { baseURL: process.env.VDI_E2E_URL || 'http://127.0.0.1:5180', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], ...(process.platform === 'win32' ? { channel: 'msedge' } : {}) } }],
|
||||||
|
})
|
||||||
5
console/playwright.live.config.ts
Normal file
5
console/playwright.live.config.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
export default defineConfig({
|
||||||
|
testDir:'./e2e', testMatch:'live.spec.ts', workers:1, timeout:45000,
|
||||||
|
expect:{timeout:15000}, use:{baseURL:'http://127.0.0.1:5182',...(process.platform==='win32'?{channel:'msedge'}:{})},
|
||||||
|
})
|
||||||
361
console/public/mockServiceWorker.js
Normal file
361
console/public/mockServiceWorker.js
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.15.0'
|
||||||
|
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Omit the body of server-sent event stream responses.
|
||||||
|
// Cloning such responses would prevent client-side stream cancelations
|
||||||
|
// from reaching the original stream (a teed stream only cancels its
|
||||||
|
// source once both of its branches cancel) and would buffer the
|
||||||
|
// entire stream into the unconsumed clone indefinitely.
|
||||||
|
const isEventStreamResponse = response.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.toLowerCase()
|
||||||
|
.startsWith('text/event-stream')
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: response.type,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
body: responseClone ? responseClone.body : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone && responseClone.body
|
||||||
|
? [serializedRequest.body, responseClone.body]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
1
console/src/App.vue
Normal file
1
console/src/App.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><router-view /></template>
|
||||||
74
console/src/api/index.ts
Normal file
74
console/src/api/index.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import createClient from 'openapi-fetch'
|
||||||
|
import type { paths, components } from './schema'
|
||||||
|
|
||||||
|
export type Admin = components['schemas']['Administrator']
|
||||||
|
export type Desktop = components['schemas']['Desktop']
|
||||||
|
export type Terminal = components['schemas']['Terminal']
|
||||||
|
export type User = components['schemas']['User']
|
||||||
|
export type Resource = components['schemas']['Resource']
|
||||||
|
export type Task = components['schemas']['Task']
|
||||||
|
export type Audit = components['schemas']['Audit']
|
||||||
|
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 DesktopAction = components['schemas']['DesktopAction']['action']
|
||||||
|
export type TerminalAction = components['schemas']['TerminalAction']['action']
|
||||||
|
let accessToken = ''
|
||||||
|
export function setAccessToken(value: string) { accessToken = value }
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, public status = 0, public code = 'network_error', public requestId = '') { super(message) }
|
||||||
|
}
|
||||||
|
const client = createClient<paths>({ baseUrl: '/api/v1', fetch: async request => {
|
||||||
|
const timeout = AbortSignal.timeout(15000)
|
||||||
|
try { return await fetch(new Request(request, { signal: AbortSignal.any([request.signal, timeout]) })) }
|
||||||
|
catch { throw new ApiError('连接服务失败或请求超时,请检查网络后重试') }
|
||||||
|
} })
|
||||||
|
client.use({
|
||||||
|
onRequest({ request }) { if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`) },
|
||||||
|
onResponse({ request, response }) { if (response.status === 401 && !request.url.endsWith('/auth/login')) window.dispatchEvent(new Event('vdi:unauthorized')) },
|
||||||
|
})
|
||||||
|
type Result<T> = { data?: T; error?: components['schemas']['Error']; response: Response }
|
||||||
|
async function result<T>(promise: Promise<Result<T>>): Promise<T> {
|
||||||
|
const r = await promise
|
||||||
|
if (r.error) throw new ApiError(r.error.message, r.response.status, r.error.code, r.error.requestId)
|
||||||
|
if (!r.response.ok) throw new ApiError(`请求失败(${r.response.status})`, r.response.status)
|
||||||
|
return r.data as 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 } })),
|
||||||
|
me: () => result(client.GET('/auth/me')),
|
||||||
|
logout: () => result(client.POST('/auth/logout')),
|
||||||
|
overview: () => result(client.GET('/overview')),
|
||||||
|
settings: () => result(client.GET('/settings')),
|
||||||
|
resources: () => result(client.GET('/resources', { params: { query } })),
|
||||||
|
desktops: (page = 1) => result(client.GET('/desktops', { params: { query: { ...query, page } } })),
|
||||||
|
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 } } })),
|
||||||
|
taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })),
|
||||||
|
audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })),
|
||||||
|
discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })),
|
||||||
|
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 })),
|
||||||
|
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 })),
|
||||||
|
}
|
||||||
|
export async function allPages<T>(loader: (page: number) => Promise<{ items: T[]; total: number }>): Promise<T[]> {
|
||||||
|
const first = await loader(1)
|
||||||
|
const items = [...first.items]
|
||||||
|
for (let page = 2; items.length < first.total && first.items.length > 0; page++) {
|
||||||
|
const next = await loader(page)
|
||||||
|
if (!next.items.length) break
|
||||||
|
items.push(...next.items)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试'
|
||||||
3732
console/src/api/schema.d.ts
vendored
Normal file
3732
console/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
console/src/assets/brand-mark.svg
Normal file
1
console/src/assets/brand-mark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.2 MiB |
BIN
console/src/assets/kylinsoft.png
Normal file
BIN
console/src/assets/kylinsoft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
1
console/src/assets/ubuntu.svg
Normal file
1
console/src/assets/ubuntu.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ubuntu</title><path d="M17.61.455a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zM12.92.8C8.923.777 5.137 2.941 3.148 6.451a4.5 4.5 0 0 1 .26-.007 4.92 4.92 0 0 1 2.585.737A8.316 8.316 0 0 1 12.688 3.6 4.944 4.944 0 0 1 13.723.834 11.008 11.008 0 0 0 12.92.8zm9.226 4.994a4.915 4.915 0 0 1-1.918 2.246 8.36 8.36 0 0 1-.273 8.303 4.89 4.89 0 0 1 1.632 2.54 11.156 11.156 0 0 0 .559-13.089zM3.41 7.932A3.41 3.41 0 0 0 0 11.342a3.41 3.41 0 0 0 3.41 3.409 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zm2.027 7.866a4.908 4.908 0 0 1-2.915.358 11.1 11.1 0 0 0 7.991 6.698 11.234 11.234 0 0 0 2.422.249 4.879 4.879 0 0 1-.999-2.85 8.484 8.484 0 0 1-.836-.136 8.304 8.304 0 0 1-5.663-4.32zm11.405.928a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41z"/></svg>
|
||||||
|
After Width: | Height: | Size: 948 B |
7
console/src/components/StatusTag.vue
Normal file
7
console/src/components/StatusTag.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
const props = defineProps<{ value: string }>()
|
||||||
|
const labels: Record<string, string> = { running: '运行中', stopped: '已关机', online: '在线', offline: '离线', unregistered: '待激活', unknown: '未检测', ready: '已就绪', queued: '排队中', success: '成功', failed: '失败', timeout: '结果未确认', submitted: '已提交', denied: '无权限', demo: '演示', proxmox: 'Proxmox', enabled: '启用', disabled: '停用' }
|
||||||
|
const type = computed(() => ['online', 'success', 'ready', 'enabled', 'running', 'connected'].includes(props.value) ? 'success' : ['failed', 'denied'].includes(props.value) ? 'danger' : ['queued', 'timeout', 'unregistered', '执行中', 'connecting'].includes(props.value) ? 'warning' : 'info')
|
||||||
|
</script>
|
||||||
|
<template><el-tag :type="type" effect="light" size="small">{{ labels[value] || value }}</el-tag></template>
|
||||||
13
console/src/components/SystemIcon.vue
Normal file
13
console/src/components/SystemIcon.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ubuntu from '../assets/ubuntu.svg'
|
||||||
|
import kylin from '../assets/kylinsoft.png'
|
||||||
|
defineProps<{ os: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<img v-if="os === 'Ubuntu'" :src="ubuntu" alt="Ubuntu" class="system-logo" />
|
||||||
|
<img v-else-if="os === '麒麟'" :src="kylin" alt="麒麟" class="system-logo" />
|
||||||
|
<span v-else class="windows-mark" role="img" aria-label="Windows"><i /><i /><i /><i /></span>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.system-logo{width:28px;height:28px;object-fit:contain}.windows-mark{display:grid;grid-template-columns:repeat(2,11px);gap:3px;transform:perspective(80px) rotateY(-9deg);width:25px;height:25px}.windows-mark i{display:block;background:currentColor}
|
||||||
|
</style>
|
||||||
8
console/src/components/ThemeToggle.vue
Normal file
8
console/src/components/ThemeToggle.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||||
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
const { dark, toggle } = useTheme()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-button class="theme-toggle" :aria-label="dark ? '切换浅色主题' : '切换深色主题'" :title="dark ? '切换浅色主题' : '切换深色主题'" @click="toggle"><el-icon :size="18"><Sunny v-if="dark" /><Moon v-else /></el-icon></el-button>
|
||||||
|
</template>
|
||||||
20
console/src/composables/useAction.ts
Normal file
20
console/src/composables/useAction.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { errorMessage } from '../api'
|
||||||
|
import { usePlatform } from '../stores/platform'
|
||||||
|
|
||||||
|
export function useAction() {
|
||||||
|
const busy = ref(false)
|
||||||
|
async function run(work: () => Promise<unknown>, message = '操作已完成') {
|
||||||
|
if (busy.value) return false
|
||||||
|
busy.value = true
|
||||||
|
try { await work(); ElMessage.success(message); await usePlatform().refresh(); return true }
|
||||||
|
catch (e) { ElMessage.error(errorMessage(e)); return false }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function confirm(title: string, message: string) {
|
||||||
|
try { await ElMessageBox.confirm(message, title, { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }); return true }
|
||||||
|
catch { return false }
|
||||||
|
}
|
||||||
|
return { busy, run, confirm }
|
||||||
|
}
|
||||||
19
console/src/composables/useTheme.ts
Normal file
19
console/src/composables/useTheme.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const key = 'vdi.theme'
|
||||||
|
const saved = (() => { try { return localStorage.getItem(key) } catch { return null } })()
|
||||||
|
const theme = ref<'dark' | 'light'>(saved === 'light' ? 'light' : 'dark')
|
||||||
|
function apply() {
|
||||||
|
document.documentElement.dataset.theme = theme.value
|
||||||
|
document.documentElement.style.colorScheme = theme.value
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
export function useTheme() {
|
||||||
|
const dark = computed(() => theme.value === 'dark')
|
||||||
|
function toggle() {
|
||||||
|
theme.value = dark.value ? 'light' : 'dark'
|
||||||
|
apply()
|
||||||
|
try { localStorage.setItem(key, theme.value) } catch { /* The theme still works without browser storage. */ }
|
||||||
|
}
|
||||||
|
return { theme, dark, toggle }
|
||||||
|
}
|
||||||
53
console/src/layouts/ConsoleLayout.vue
Normal file
53
console/src/layouts/ConsoleLayout.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<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 { useAuth } from '../stores/auth'
|
||||||
|
import { usePlatform } from '../stores/platform'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle.vue'
|
||||||
|
import brandMark from '../assets/brand-mark.svg'
|
||||||
|
const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter()
|
||||||
|
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: '/resources', title: '计算资源', icon: Cpu, group: 'manage' },
|
||||||
|
{ path: '/users', title: '用户与授权', icon: User, group: 'manage' },
|
||||||
|
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
|
||||||
|
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
|
||||||
|
]
|
||||||
|
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)
|
||||||
|
watch(() => route.path, () => { navOpen.value = false })
|
||||||
|
let timer: ReturnType<typeof setInterval>
|
||||||
|
onMounted(() => { void data.refresh(); timer = setInterval(() => { if (!document.hidden) void data.refresh() }, 5000) })
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
async function logout() { try { await auth.logout() } catch { /* Local session is cleared even if the network is unavailable. */ } data.clear(); await router.replace('/login') }
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<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="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="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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.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}}
|
||||||
|
@media(max-width:410px){.breadcrumbs>span:first-child{display:none}.breadcrumbs>.el-icon{display:none}.refresh-note{display:none}.environment{font-size:9px}.page-container{padding:18px 14px}.topbar{padding-left:14px;padding-right:14px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.sidebar{transition:none}}
|
||||||
|
</style>
|
||||||
21
console/src/main.ts
Normal file
21
console/src/main.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './styles/tokens.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { router } from './router'
|
||||||
|
import { useAuth } from './stores/auth'
|
||||||
|
import { usePlatform } from './stores/platform'
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_API_MODE === 'mock') {
|
||||||
|
const { worker } = await import('./mocks/browser')
|
||||||
|
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||||
|
}
|
||||||
|
const app = createApp(App); app.use(createPinia()); app.use(router); app.use(ElementPlus, { locale: zhCn })
|
||||||
|
window.addEventListener('vdi:unauthorized', () => { useAuth().clear(); usePlatform().clear(); if (router.currentRoute.value.path !== '/login') void router.replace('/login') })
|
||||||
|
await router.isReady(); app.mount('#app')
|
||||||
|
}
|
||||||
|
void start()
|
||||||
3
console/src/mocks/browser.ts
Normal file
3
console/src/mocks/browser.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { setupWorker } from 'msw/browser'
|
||||||
|
import { handlers } from './handlers'
|
||||||
|
export const worker = setupWorker(...handlers)
|
||||||
47
console/src/mocks/handlers.ts
Normal file
47
console/src/mocks/handlers.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import type { Admin, Desktop, Terminal, User, Task, Resource, Audit } from '../api'
|
||||||
|
const now = () => new Date().toISOString()
|
||||||
|
const administrators: Record<string, Admin> = {
|
||||||
|
admin: { id: 'mock-admin', username: 'admin', displayName: '系统管理员', role: 'super_admin' },
|
||||||
|
operator: { id: 'mock-operator', username: 'operator', displayName: '运维人员', role: 'operator' },
|
||||||
|
viewer: { id: 'mock-viewer', username: 'viewer', displayName: '只读人员', role: 'viewer' },
|
||||||
|
}
|
||||||
|
const desktops: Desktop[] = [
|
||||||
|
{ id: 'desk_mock_windows', name: '设计专属桌面', os: 'Windows', node: 'demo-node-a', vmid: 201, pool: '设计专属池', powerState: 'running', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
|
||||||
|
{ id: 'desk_mock_ubuntu', name: '研发办公桌面', os: 'Ubuntu', node: 'demo-node-a', vmid: 202, pool: '研发专属池', powerState: 'running', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
|
||||||
|
{ id: 'desk_mock_kylin', name: '国产办公桌面', os: '麒麟', node: 'demo-node-a', vmid: 203, pool: '通用办公池', powerState: 'stopped', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
|
||||||
|
]
|
||||||
|
const terminals: Terminal[] = [{ id: 'term_mock_001', sn: 'DEMO-RK-001', name: '设计工位 01', group: '设计部', status: 'offline', agentVersion: '演示版本', lastSeen: null, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }]
|
||||||
|
const users: User[] = [{ id: 'user_mock_wang', username: 'wang.demo', displayName: '王工', enabled: true, terminalId: 'term_mock_001', desktopIds: ['desk_mock_windows'] }]
|
||||||
|
const tasks: Task[] = [], audits: Audit[] = []
|
||||||
|
const resources: Resource[] = [{ id: 'demo-node-a', name: 'demo-node-a', status: 'online', cpu: 0.24, memory: 0.48, storage: 0.35, provider: 'demo', updatedAt: now() }]
|
||||||
|
const revoked = new Set<string>()
|
||||||
|
const keyFor = (r: Request) => r.headers.get('Authorization')?.replace('Bearer mock-', '') || ''
|
||||||
|
const admin = (r: Request) => !revoked.has(keyFor(r)) ? administrators[keyFor(r)] : undefined
|
||||||
|
const error = (status: number, message: string) => HttpResponse.json({ code: `mock_${status}`, message, requestId: `mock-${crypto.randomUUID()}` }, { status })
|
||||||
|
function guard(r: Request, write = false, adminOnly = false) { const a = admin(r); if (!a) return error(401, '登录已失效,请重新登录'); if ((write && a.role === 'viewer') || (adminOnly && a.role !== 'super_admin')) return error(403, '当前账号没有该操作权限') }
|
||||||
|
function list<T>(request: Request, items: T[]) { const q = new URL(request.url).searchParams; const page = Math.max(1, Number(q.get('page')) || 1), pageSize = Math.min(100, Math.max(1, Number(q.get('pageSize')) || 20)); return HttpResponse.json({ items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total: items.length, requestId: `mock-${crypto.randomUUID()}` }) }
|
||||||
|
function audit(r: Request, action: string, target: string, result = 'success') { audits.unshift({ id: crypto.randomUUID(), actor: admin(r)?.username || '', action, target, result, requestId: crypto.randomUUID(), createdAt: now() }) }
|
||||||
|
function submit(request: Request, kind: string, action: string, targetId: string, targetName: string, finish: () => void) {
|
||||||
|
if (tasks.some(t => t.targetId === targetId && ['queued', 'running'].includes(t.status))) return error(409, '该对象已有任务执行中')
|
||||||
|
const t: Task = { id: `task_mock_${crypto.randomUUID()}`, kind, action, targetId, targetName, actor: admin(request)?.username || '', status: 'queued', message: '模拟任务已提交', providerRef: '', createdAt: now(), updatedAt: now() }
|
||||||
|
tasks.unshift(t); audit(request, action, targetName, 'submitted')
|
||||||
|
setTimeout(() => { t.status = 'running'; t.message = '模拟执行中'; t.updatedAt = now() }, 400)
|
||||||
|
setTimeout(() => { t.status = kind === 'terminal' ? 'failed' : 'success'; t.message = kind === 'terminal' ? '模拟终端离线,未执行' : '模拟任务已完成;没有操作真实资源'; t.updatedAt = now(); if (t.status === 'success') finish(); audit(request, action, targetName, t.status) }, 1500)
|
||||||
|
return HttpResponse.json(t, { status: 202 })
|
||||||
|
}
|
||||||
|
export const handlers = [
|
||||||
|
http.post('/api/v1/auth/login', async ({ request }) => { const b = await request.json() as { username: string; password: string }; const a = administrators[b.username]; if (!a || b.password !== 'Demo@2026') return error(401, '用户名或密码错误'); revoked.delete(b.username); audit(new Request(request.url, { headers: { Authorization: `Bearer mock-${b.username}` } }), 'login', b.username); return HttpResponse.json({ accessToken: `mock-${b.username}`, expiresIn: 3600, user: a }) }),
|
||||||
|
http.get('/api/v1/auth/me', ({ request }) => guard(request) || HttpResponse.json(admin(request))),
|
||||||
|
http.post('/api/v1/auth/logout', ({ request }) => { const e = guard(request); if (e) return e; revoked.add(keyFor(request)); return new HttpResponse(null, { status: 204 }) }),
|
||||||
|
http.get('/api/v1/settings', ({ request }) => guard(request) || HttpResponse.json({ mode: 'demo', computeProvider: 'demo', pveConfigured: false, terminalTransport: 'mock', capabilities: ['auth', 'resources', 'desktops', 'terminals', 'bindings', 'tasks', 'audit'] })),
|
||||||
|
http.get('/api/v1/overview', ({ request }) => guard(request) || HttpResponse.json({ terminalCount: terminals.length, onlineTerminals: 0, desktopCount: desktops.length, runningDesktops: desktops.filter(d => d.powerState === 'running').length, activeSessions: 0, failedTasks: tasks.filter(t => ['failed', 'timeout'].includes(t.status)).length, mode: 'demo' })),
|
||||||
|
...([['desktops', desktops], ['terminals', terminals], ['users', users], ['resources', resources], ['tasks', tasks], ['audit-logs', audits]] as const).map(([path, data]) => http.get(`/api/v1/${path}`, ({ request }) => guard(request) || list(request, data as unknown[]))),
|
||||||
|
http.post('/api/v1/desktops/:id/actions', async ({ request, params }) => { const e = guard(request, true); if (e) return e; const d = desktops.find(d => d.id === params.id); if (!d) return error(404, '桌面不存在'); const b = await request.json() as { action: string }; if (!['start', 'shutdown', 'reboot'].includes(b.action)) return error(400, '不支持的操作'); return submit(request, 'desktop', b.action, d.id, d.name, () => { d.powerState = b.action === 'shutdown' ? 'stopped' : 'running'; d.updatedAt = now() }) }),
|
||||||
|
http.post('/api/v1/terminals', async ({ request }) => { const e = guard(request, true, true); if (e) return e; const b = await request.json() as { sn: string; name: string; group: string }; if (terminals.some(t => t.sn === b.sn)) return error(409, 'SN 已登记'); const id = `term_mock_${crypto.randomUUID()}`; terminals.push({ ...b, id, status: 'unregistered', agentVersion: '', lastSeen: null, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }); audit(request, 'enroll_terminal', id); return HttpResponse.json({ id, activationSecret: `mock-only-${crypto.randomUUID()}` }, { status: 201 }) }),
|
||||||
|
http.post('/api/v1/terminals/:id/commands', async ({ request, params }) => { const e = guard(request, true); if (e) return e; const t = terminals.find(t => t.id === params.id); if (!t) return error(404, '终端不存在'); const b = await request.json() as { action: string }; return submit(request, 'terminal', b.action, t.id, t.name, () => {}) }),
|
||||||
|
http.post('/api/v1/users', async ({ request }) => { const e = guard(request, true, true); if (e) return e; const b = await request.json() as { username: string; displayName: string }; if (users.some(u => u.username === b.username)) return error(409, '账号已存在'); const u: User = { ...b, id: `user_mock_${crypto.randomUUID()}`, enabled: true, terminalId: null, desktopIds: [] }; users.push(u); audit(request, 'create_user', u.id); return HttpResponse.json(u, { status: 201 }) }),
|
||||||
|
http.put('/api/v1/users/:id/bindings', async ({ request, params }) => { const e = guard(request, true, true); if (e) return e; const u = users.find(u => u.id === params.id); if (!u) return error(404, '用户不存在'); const b = await request.json() as { terminalId: string | null; desktopIds: string[]; enabled: boolean }; if (b.terminalId && users.some(x => x.id !== u.id && x.terminalId === b.terminalId)) return error(409, '终端已关联其他用户'); Object.assign(u, b); audit(request, 'update_bindings', u.id); return HttpResponse.json(u) }),
|
||||||
|
http.get('/api/v1/resources/vms', ({ request }) => guard(request) || error(503, '模拟环境未接入 Proxmox')),
|
||||||
|
http.post('/api/v1/resources/sync', ({ request }) => guard(request, true, true) || error(503, '模拟环境未接入 Proxmox')),
|
||||||
|
]
|
||||||
25
console/src/router/index.ts
Normal file
25
console/src/router/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
|
||||||
|
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: 'overview', component: () => import('../views/Overview.vue'), meta: { title: '总览' } },
|
||||||
|
{ path: 'resources', component: () => import('../views/Resources.vue'), meta: { title: '计算资源' } },
|
||||||
|
{ path: 'desktops', component: () => import('../views/Desktops.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: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } },
|
||||||
|
{ 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: '页面不存在' } },
|
||||||
|
] },
|
||||||
|
] })
|
||||||
|
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.meta.adminOnly && !auth.canAdmin) return '/forbidden'
|
||||||
|
})
|
||||||
13
console/src/stores/auth.test.ts
Normal file
13
console/src/stores/auth.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { api, setAccessToken, type Admin } from '../api'
|
||||||
|
import { useAuth } from './auth'
|
||||||
|
vi.mock('../api', () => ({ api: { login: vi.fn(), me: vi.fn(), logout: vi.fn() }, setAccessToken: vi.fn() }))
|
||||||
|
let storage: Map<string, string>
|
||||||
|
const viewer: Admin = { id: 'viewer', username: 'viewer', displayName: '只读', role: 'viewer' }
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); storage = new Map(); vi.stubGlobal('sessionStorage', { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => storage.set(k, v), removeItem: (k: string) => storage.delete(k) }); setActivePinia(createPinia()) })
|
||||||
|
describe('管理员会话', () => {
|
||||||
|
it('只读角色登录后仍没有写权限', async () => { vi.mocked(api.login).mockResolvedValue({ accessToken: 'jwt', expiresIn: 3600, user: viewer }); const auth = useAuth(); await auth.login('viewer', 'password'); expect(auth.canOperate).toBe(false); expect(auth.canAdmin).toBe(false); expect(storage.get('vdi.accessToken')).toBe('jwt') })
|
||||||
|
it('同时触发恢复只请求一次;令牌失效清除会话', async () => { storage.set('vdi.accessToken', 'expired'); vi.mocked(api.me).mockRejectedValue(new Error('expired')); const auth = useAuth(); await Promise.all([auth.restore(), auth.restore()]); expect(api.me).toHaveBeenCalledTimes(1); expect(auth.user).toBeNull(); expect(storage.has('vdi.accessToken')).toBe(false); expect(setAccessToken).toHaveBeenLastCalledWith('') })
|
||||||
|
it('退出接口失败也移除本地登录凭据', async () => { vi.mocked(api.login).mockResolvedValue({ accessToken: 'jwt', expiresIn: 3600, user: viewer }); vi.mocked(api.logout).mockRejectedValue(new Error('offline')); const auth = useAuth(); await auth.login('viewer', 'password'); await expect(auth.logout()).rejects.toThrow('offline'); expect(auth.token).toBe(''); expect(auth.user).toBeNull(); expect(storage.has('vdi.accessToken')).toBe(false) })
|
||||||
|
})
|
||||||
27
console/src/stores/auth.ts
Normal file
27
console/src/stores/auth.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api, setAccessToken, type Admin } from '../api'
|
||||||
|
|
||||||
|
const tokenKey = 'vdi.accessToken'
|
||||||
|
export const useAuth = defineStore('auth', () => {
|
||||||
|
const user = ref<Admin | null>(null)
|
||||||
|
const token = ref(sessionStorage.getItem(tokenKey) || '')
|
||||||
|
const initialized = ref(false)
|
||||||
|
let restoring: Promise<void> | undefined
|
||||||
|
setAccessToken(token.value)
|
||||||
|
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)
|
||||||
|
token.value = data.accessToken; user.value = data.user; initialized.value = true
|
||||||
|
setAccessToken(token.value); sessionStorage.setItem(tokenKey, token.value)
|
||||||
|
}
|
||||||
|
async function restore() {
|
||||||
|
if (initialized.value) return
|
||||||
|
if (!restoring) restoring = (async () => { try { if (token.value) user.value = await api.me() } catch { clear() } finally { initialized.value = true } })()
|
||||||
|
await restoring
|
||||||
|
}
|
||||||
|
async function logout() { try { await api.logout() } finally { clear() } }
|
||||||
|
return { user, token, initialized, canOperate, canAdmin, login, restore, clear, logout }
|
||||||
|
})
|
||||||
29
console/src/stores/platform.ts
Normal file
29
console/src/stores/platform.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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 settings = ref<Settings | null>(null), overview = ref<Overview | null>(null)
|
||||||
|
const loading = ref(false), error = ref(''), updatedAt = ref('')
|
||||||
|
let inFlight: Promise<void> | undefined
|
||||||
|
let generation = 0
|
||||||
|
function refresh() {
|
||||||
|
if (inFlight) return inFlight
|
||||||
|
loading.value = !updatedAt.value
|
||||||
|
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()])
|
||||||
|
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
|
||||||
|
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 }
|
||||||
|
})
|
||||||
74
console/src/styles/tokens.css
Normal file
74
console/src/styles/tokens.css
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
:root {
|
||||||
|
--vdi-bg:#f5f7fa; --vdi-panel:#fff; --vdi-panel-2:#f0f3f7;
|
||||||
|
--vdi-text:#1b2533; --vdi-muted:#64748b; --vdi-border:#e5e9ef;
|
||||||
|
--vdi-brand:#bd713b; --vdi-accent:#965421; --vdi-accent-soft:#fff0e4;
|
||||||
|
--vdi-ok:#16845e; --vdi-warn:#9a6b10; --vdi-danger:#ce4652;
|
||||||
|
--vdi-radius:18px; --vdi-shadow:0 16px 40px rgb(27 37 51 / 6%);
|
||||||
|
--el-color-primary:var(--vdi-brand); --el-color-primary-light-3:#ce966f;
|
||||||
|
--el-color-primary-light-5:#ddb495; --el-color-primary-light-7:#ebd1bd;
|
||||||
|
--el-color-primary-light-8:#f3e2d5; --el-color-primary-light-9:#fcf3ed;
|
||||||
|
--el-color-primary-dark-2:#a9602e;
|
||||||
|
--el-color-success:#16845e; --el-color-success-light-3:#5fa98c;
|
||||||
|
--el-color-success-light-5:#91c2ad; --el-color-success-light-7:#c4ded2;
|
||||||
|
--el-color-success-light-8:#d9ebe3; --el-color-success-light-9:#eaf5ef;
|
||||||
|
--el-color-warning:#9a6b10; --el-color-warning-light-5:#cfb78b;
|
||||||
|
--el-color-warning-light-7:#e6d7bb; --el-color-warning-light-8:#efe5d4;
|
||||||
|
--el-color-warning-light-9:#faf4e8;
|
||||||
|
--el-color-danger:#ce4652; --el-color-danger-light-5:#e59ca3;
|
||||||
|
--el-color-danger-light-7:#f0c4c8; --el-color-danger-light-8:#f5dadd;
|
||||||
|
--el-color-danger-light-9:#fcf0f1;
|
||||||
|
--el-color-info:#64748b; --el-color-info-light-5:#a6b0bd;
|
||||||
|
--el-color-info-light-7:#cdd3db; --el-color-info-light-8:#e0e4e9;
|
||||||
|
--el-color-info-light-9:#f0f3f7;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||||
|
color:var(--vdi-text); background:var(--vdi-bg); font-synthesis:none; text-rendering:optimizeLegibility;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--vdi-bg:#151d27; --vdi-panel:#1d2733; --vdi-panel-2:#253241;
|
||||||
|
--vdi-text:#edf1f6; --vdi-muted:#a5b2c3; --vdi-border:#314050;
|
||||||
|
--vdi-accent:#f1ad62; --vdi-accent-soft:#493323;
|
||||||
|
--vdi-ok:#36ca94; --vdi-warn:#efb744; --vdi-danger:#f07881;
|
||||||
|
--vdi-shadow:0 18px 45px rgb(0 0 0 / 14%);
|
||||||
|
--el-color-primary-light-3:#ce966f; --el-color-primary-light-5:#88603f;
|
||||||
|
--el-color-primary-light-7:#5c422f; --el-color-primary-light-8:#493323;
|
||||||
|
--el-color-primary-light-9:#352c27;
|
||||||
|
--el-color-success:#36ca94; --el-color-success-light-3:#2fa77e;
|
||||||
|
--el-color-success-light-5:#24644f; --el-color-success-light-7:#20453f;
|
||||||
|
--el-color-success-light-8:#203a37; --el-color-success-light-9:#1d3332;
|
||||||
|
--el-color-warning:#efb744; --el-color-warning-light-5:#77643c;
|
||||||
|
--el-color-warning-light-7:#514735; --el-color-warning-light-8:#423c30;
|
||||||
|
--el-color-warning-light-9:#34332e;
|
||||||
|
--el-color-danger:#f07881; --el-color-danger-light-5:#85505a;
|
||||||
|
--el-color-danger-light-7:#5c3d47; --el-color-danger-light-8:#49333d;
|
||||||
|
--el-color-danger-light-9:#372c35;
|
||||||
|
--el-color-info:#a5b2c3; --el-color-info-light-5:#58697d;
|
||||||
|
--el-color-info-light-7:#3c4c60; --el-color-info-light-8:#314050;
|
||||||
|
--el-color-info-light-9:#253241;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--el-bg-color:var(--vdi-panel); --el-bg-color-page:var(--vdi-bg); --el-bg-color-overlay:var(--vdi-panel);
|
||||||
|
--el-text-color-primary:var(--vdi-text); --el-text-color-regular:var(--vdi-text);
|
||||||
|
--el-text-color-secondary:var(--vdi-muted); --el-text-color-placeholder:var(--vdi-muted);
|
||||||
|
--el-text-color-disabled:var(--vdi-muted);
|
||||||
|
--el-border-color:var(--vdi-border); --el-border-color-light:var(--vdi-border);
|
||||||
|
--el-border-color-lighter:var(--vdi-border); --el-border-color-extra-light:var(--vdi-border);
|
||||||
|
--el-border-color-dark:var(--vdi-border); --el-border-color-darker:var(--vdi-muted);
|
||||||
|
--el-fill-color:var(--vdi-panel-2); --el-fill-color-light:var(--vdi-panel-2);
|
||||||
|
--el-fill-color-lighter:var(--vdi-panel-2); --el-fill-color-extra-light:var(--vdi-panel-2);
|
||||||
|
--el-fill-color-blank:var(--vdi-panel); --el-fill-color-dark:var(--vdi-border);
|
||||||
|
--el-fill-color-darker:var(--vdi-border); --el-fill-color-disabled:var(--vdi-panel-2);
|
||||||
|
--el-disabled-bg-color:var(--vdi-panel-2); --el-disabled-text-color:var(--vdi-muted);
|
||||||
|
--el-disabled-border-color:var(--vdi-border);
|
||||||
|
--el-border-radius-base:10px; --el-border-radius-small:7px;
|
||||||
|
--el-mask-color:rgb(0 0 0 / 55%); --el-box-shadow-light:var(--vdi-shadow);
|
||||||
|
--el-box-shadow:var(--vdi-shadow); --el-font-family:inherit;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}body{margin:0;min-width:320px}button,input{font:inherit}button:focus-visible,a:focus-visible{outline:2px solid var(--vdi-accent);outline-offset:4px}a{color:var(--vdi-accent);text-decoration:none}h1,h2,h3,p{margin:0}h1{font-size:29px;line-height:1.35;font-weight:750;letter-spacing:.4px}h2{font-size:17px;font-weight:650}h3{font-size:15px;font-weight:600}
|
||||||
|
.page-heading{display:flex;gap:16px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:25px}.subtext{font-size:13px;color:var(--vdi-muted);margin-top:7px;line-height:1.7}.eyebrow{font-size:11px;color:var(--vdi-muted);letter-spacing:1.8px;margin-bottom:9px}.panel{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:var(--vdi-radius);padding:22px;margin-bottom:20px;min-width:0;box-shadow:var(--vdi-shadow)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}.panel-heading a{font-size:12px}
|
||||||
|
.toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:20px}.toolbar .el-input{width:260px}.toolbar .el-select{width:150px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.actions .el-button+.el-button{margin-left:0}.mono{font-family:ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere}.secondary{font-size:12px;color:var(--vdi-muted);line-height:1.7}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:22px}.stat{display:block;width:100%;border:1px solid var(--vdi-border);border-radius:var(--vdi-radius);background:var(--vdi-panel);padding:22px;text-align:left;color:inherit;cursor:pointer;box-shadow:var(--vdi-shadow)}.stat:hover{border-color:var(--vdi-brand)}.stat strong{display:block;font-size:32px;font-weight:750;margin:12px 0 9px;line-height:1.2}.stat span{color:var(--vdi-muted);font-size:12px}.stat-label{font-size:13px;color:var(--vdi-muted);display:flex;justify-content:space-between;align-items:center}.stat-caption{font-size:12px;color:var(--vdi-muted)}.stat-unit{font-size:14px;font-weight:500;margin-left:4px}.two-columns{display:grid;grid-template-columns:1.35fr .85fr;gap:20px}.row{display:flex;justify-content:space-between;gap:18px;align-items:center;flex-wrap:wrap;margin:16px 0}.tone-ok{color:var(--vdi-ok)}.tone-warn{color:var(--vdi-warn)}.tone-danger{color:var(--vdi-danger)}.health-dot{display:inline-block;width:8px;height:8px;background:currentColor;border-radius:50%;margin-right:9px;flex-shrink:0}
|
||||||
|
.el-button{min-height:39px;padding:10px 16px;font-weight:500;--el-button-hover-text-color:var(--vdi-accent);--el-button-hover-bg-color:var(--vdi-accent-soft);--el-button-hover-border-color:var(--vdi-brand)}.el-button--primary{--el-button-text-color:#fff;--el-button-hover-text-color:#fff;--el-button-hover-bg-color:#ce824a;--el-button-hover-border-color:#ce824a;--el-button-active-bg-color:#a9602e;--el-button-active-border-color:#a9602e;--el-button-disabled-text-color:rgb(255 255 255 / 70%);--el-button-disabled-bg-color:#825a3d;--el-button-disabled-border-color:#825a3d}.el-button.is-link,.el-button.is-text{min-height:30px;padding:5px 8px}.el-button.is-link{color:var(--vdi-accent)}.el-button.is-disabled.is-link{color:var(--vdi-muted);opacity:.6}.el-button-group .el-button{min-height:36px}.theme-toggle.el-button{width:42px;height:42px;padding:0;flex-shrink:0;background:var(--vdi-panel-2);border-radius:11px}
|
||||||
|
.el-table{--el-table-header-bg-color:var(--vdi-panel-2);--el-table-bg-color:var(--vdi-panel);--el-table-tr-bg-color:var(--vdi-panel);--el-table-row-hover-bg-color:var(--vdi-panel-2);--el-table-border-color:var(--vdi-border);--el-table-header-text-color:var(--vdi-muted);--el-table-text-color:var(--vdi-text);border-radius:11px}.el-table th.el-table__cell{font-weight:500;padding:14px 0;font-size:12px}.el-table td.el-table__cell{padding:17px 0}.el-table .cell{padding:0 14px}.el-table__empty-text{color:var(--vdi-muted)}.el-pagination{margin-top:22px;flex-wrap:wrap;gap:8px;justify-content:flex-end;--el-pagination-button-bg-color:var(--vdi-panel-2);--el-pagination-hover-color:var(--vdi-accent)}.el-dialog{max-width:calc(100vw - 32px);border:1px solid var(--vdi-border);border-radius:18px;padding:24px}.el-dialog__title,.el-drawer__title{color:var(--vdi-text);font-weight:650}.el-drawer{max-width:100vw;border-left:1px solid var(--vdi-border)}.el-drawer__header{margin-bottom:24px;color:var(--vdi-text)}.el-drawer__body{padding:24px}.el-descriptions__cell{overflow-wrap:anywhere}.el-descriptions__label.el-descriptions__cell.is-bordered-label{background:var(--vdi-panel-2);color:var(--vdi-muted);font-weight:500}.el-descriptions__content.el-descriptions__cell.is-bordered-content{background:var(--vdi-panel);color:var(--vdi-text)}.el-input__wrapper,.el-select__wrapper{min-height:39px;border-radius:10px;background:var(--vdi-panel-2)}.el-input__wrapper.is-focus,.el-select__wrapper.is-focused{box-shadow:0 0 0 1px var(--vdi-brand) inset}.el-tabs__item.is-active,.el-tabs__item:hover{color:var(--vdi-accent)}.el-tabs__active-bar{background:var(--vdi-brand)}.el-progress-bar__outer{background:var(--vdi-panel-2)}.el-progress__text{color:var(--vdi-muted);font-size:12px!important}.el-tag{border-radius:7px;height:auto;min-height:24px;padding:3px 8px;line-height:1.4}.el-message-box{border-radius:18px;max-width:calc(100vw - 32px)}.el-alert{border-radius:12px}.el-switch{--el-switch-on-color:var(--vdi-brand)}.el-checkbox__input.is-checked+.el-checkbox__label{color:var(--vdi-accent)}
|
||||||
|
.secret{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border:1px dashed var(--vdi-brand);background:var(--vdi-accent-soft);border-radius:10px}.notice{margin-bottom:20px}.empty-note{padding:26px 0;text-align:center;color:var(--vdi-muted)}.empty-panel{display:grid;place-items:center;min-height:230px;text-align:center;padding:32px}.empty-icon{display:grid;place-items:center;width:58px;height:58px;background:var(--vdi-panel-2);color:var(--vdi-accent);border-radius:18px;margin:0 auto 18px}.section-kicker{font-size:10px;letter-spacing:1.6px;color:var(--vdi-muted);margin-bottom:12px}
|
||||||
|
@media(min-width:1600px){.stats{gap:22px}.panel{padding:26px}}
|
||||||
|
@media(max-width:1100px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two-columns{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:600px){h1{font-size:24px}.panel{padding:16px;border-radius:14px}.stats{gap:12px}.stat{padding:18px}.stat strong{font-size:28px}.toolbar .el-input,.toolbar .el-select{width:100%}.el-pagination{justify-content:flex-start}.el-dialog{padding:20px}.el-drawer__body{padding:18px}.page-heading{gap:14px;margin-bottom:20px}.actions{gap:8px}}
|
||||||
47
console/src/views/Desktops.vue
Normal file
47
console/src/views/Desktops.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } 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 { 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'
|
||||||
|
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 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 form = reactive<ImportInput>({ name: '', os: 'Windows', node: '', vmid: 100, endpoint: '' })
|
||||||
|
const discovered = ref<DiscoveredVM[]>([]), vmKey = ref('')
|
||||||
|
const labels = { start: '开机', shutdown: '正常关机', reboot: '重启桌面' }
|
||||||
|
function activeTask(d: Desktop) { return data.tasks.some(t => t.targetId === d.id && ['queued', 'running'].includes(t.status)) }
|
||||||
|
async function action(d: Desktop, a: DesktopAction) {
|
||||||
|
const description = a === 'start' ? '提交开机任务。开机后仍需确认连接服务是否就绪。' : '将中断该桌面的连接,请确认使用者已保存工作。'
|
||||||
|
if (await confirm(`${labels[a]} · ${d.name}`, description)) await run(() => api.desktopAction(d.id, a), '任务已提交,请在任务与审计中查看最终结果')
|
||||||
|
}
|
||||||
|
async function openImport() { if (await run(async () => { discovered.value = (await api.discoverVMs()).items }, '已获取可纳管的 VM')) importVisible.value = true }
|
||||||
|
function chooseVM(key: string) { const v = discovered.value.find(v => `${v.node}/${v.vmid}` === key); if (v) { form.node = v.node; form.vmid = v.vmid; form.name = v.name } }
|
||||||
|
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>
|
||||||
|
<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="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-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-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-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>
|
||||||
|
</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}
|
||||||
|
@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}}
|
||||||
|
</style>
|
||||||
1
console/src/views/Forbidden.vue
Normal file
1
console/src/views/Forbidden.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><el-result icon="warning" title="无访问权限" sub-title="当前角色无权访问该页面"><template #extra><router-link to="/overview"><el-button type="primary">返回总览</el-button></router-link></template></el-result></template>
|
||||||
27
console/src/views/Login.vue
Normal file
27
console/src/views/Login.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } 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 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 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) }
|
||||||
|
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>
|
||||||
|
</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}
|
||||||
|
@media(max-width:950px){.login-main{gap:4%;width:92%}.login-intro h1{font-size:39px}.login-card{padding:30px}.login-art{transform:scale(.85);transform-origin:left top;margin-bottom:-25px}.intro-capabilities{gap:14px}}
|
||||||
|
@media(max-width:700px){.login-header{padding:22px}.login-main{grid-template-columns:1fr;margin:24px auto 45px;gap:28px;width:min(440px,calc(100% - 36px))}.login-intro{padding-top:0}.login-intro h1{font-size:32px}.login-intro h1 br{display:none}.login-intro>p,.intro-capabilities,.login-art{display:none}.eyebrow{margin-bottom:14px;font-size:9px;letter-spacing:1.4px}.login-card{padding:28px 24px;width:100%}.login-card h2{font-size:23px}.login-footer{padding:20px 22px;font-size:10px}.login-footer>span:last-child{display:none}.login-security{font-size:10px}.login-brand{font-size:20px}}
|
||||||
|
</style>
|
||||||
1
console/src/views/NotFound.vue
Normal file
1
console/src/views/NotFound.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><el-result icon="info" title="页面不存在" sub-title="请通过左侧菜单选择功能"><template #extra><router-link to="/overview"><el-button type="primary">返回总览</el-button></router-link></template></el-result></template>
|
||||||
47
console/src/views/Overview.vue
Normal file
47
console/src/views/Overview.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Monitor, Connection, Warning, Refresh, Download, Cpu, ArrowRight, CircleCheck, Clock } from '@element-plus/icons-vue'
|
||||||
|
import { usePlatform } from '../stores/platform'
|
||||||
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
|
const data = usePlatform(), router = useRouter()
|
||||||
|
const active = computed(() => data.terminals.filter(t => t.status === 'online' && t.sessionDesktopId && t.sessionState === 'connected'))
|
||||||
|
const averageRTT = computed(() => {
|
||||||
|
const samples = active.value.flatMap(t => t.rtt == null ? [] : [t.rtt])
|
||||||
|
return samples.length ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length * 10) / 10 : null
|
||||||
|
})
|
||||||
|
const healthyNodes = computed(() => data.resources.filter(n => n.status === 'online' && Date.now() - Date.parse(n.updatedAt) <= 90000).length)
|
||||||
|
const recent = computed(() => data.tasks.slice(0, 5))
|
||||||
|
const taskLabels: Record<string, string> = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源' }
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ title: '运行桌面', value: data.overview?.runningDesktops ?? 0, total: `/ ${data.overview?.desktopCount ?? 0}`, caption: '已纳管桌面的实际电源状态', path: '/desktops', icon: Monitor, warning: false },
|
||||||
|
{ title: '活跃会话', value: data.overview?.activeSessions ?? 0, total: '个', caption: '终端已确认画面就绪', path: '/sessions', icon: Connection, warning: false },
|
||||||
|
{ title: '网络 RTT', value: averageRTT.value ?? '—', total: averageRTT.value === null ? '' : 'ms', caption: averageRTT.value === null ? '当前无活动会话指标' : '活动会话采样均值', path: '/sessions', icon: Clock, warning: false },
|
||||||
|
{ title: '异常任务', value: data.overview?.failedTasks ?? 0, total: '个', caption: '最近 24 小时失败或结果未确认', path: '/tasks', icon: Warning, warning: (data.overview?.failedTasks ?? 0) > 0 },
|
||||||
|
])
|
||||||
|
function exportReport() {
|
||||||
|
const lines = [
|
||||||
|
['VDI Cloud 运行快照', new Date().toLocaleString('zh-CN')],
|
||||||
|
['指标', '数值'], ['运行桌面', String(data.overview?.runningDesktops ?? '')],
|
||||||
|
['桌面总数', String(data.overview?.desktopCount ?? '')], ['在线终端', String(data.overview?.onlineTerminals ?? '')],
|
||||||
|
['终端总数', String(data.overview?.terminalCount ?? '')], ['活动连接', String(data.overview?.activeSessions ?? '')],
|
||||||
|
['网络 RTT(ms)', averageRTT.value === null ? '暂无数据' : String(averageRTT.value)],
|
||||||
|
['最近24小时异常任务', String(data.overview?.failedTasks ?? '')],
|
||||||
|
]
|
||||||
|
const url = URL.createObjectURL(new Blob(['\ufeff' + lines.map(row => row.map(v => `"${v.replace(/"/g, '""')}"`).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8;' }))
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = `vdi-overview-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="page-heading"><div><h1>运行总览</h1><p class="subtext">云桌面、终端连接与资源健康,一处掌握。</p></div><div class="actions"><el-button @click="exportReport"><el-icon><Download /></el-icon><span>导出快照</span></el-button><el-button type="primary" @click="data.refresh"><el-icon><Refresh /></el-icon><span>刷新数据</span></el-button></div></div>
|
||||||
|
<div class="stats"><button v-for="stat in stats" :key="stat.title" class="stat" @click="router.push(stat.path)"><div class="stat-label">{{ stat.title }}<el-icon :size="18"><component :is="stat.icon" /></el-icon></div><strong :class="{ 'tone-warn': stat.warning }">{{ stat.value }}<span class="stat-unit">{{ stat.total }}</span></strong><div class="stat-caption">{{ stat.caption }}</div></button></div>
|
||||||
|
<div class="two-columns overview-grid">
|
||||||
|
<section class="panel nodes-panel"><div class="panel-heading"><div><h2>计算资源水位</h2><p class="subtext">当前节点负载 · 每 30 秒同步 PVE</p></div><router-link to="/resources">资源详情 <el-icon><ArrowRight /></el-icon></router-link></div><div v-if="!data.resources.length" class="empty-panel"><div><div class="empty-icon"><el-icon :size="27"><Cpu /></el-icon></div><h3>尚未同步计算资源</h3><p class="subtext">接入 Proxmox 后查看节点使用情况</p></div></div><div v-for="node in data.resources" :key="node.id" class="node-block"><div class="node-heading"><div class="node-name"><div class="node-icon"><el-icon :size="21"><Cpu /></el-icon></div><div><strong>{{ node.name }}</strong><small>{{ node.provider === 'demo' ? '演示节点' : 'Proxmox 计算节点' }}</small></div></div><el-tag v-if="Date.now() - Date.parse(node.updatedAt) > 90000 && node.provider !== 'demo'" type="warning">数据过期</el-tag><StatusTag v-else :value="node.status" /></div><div class="node-meters"><div v-for="meter in [{ label: 'CPU 使用率', value: node.cpu }, { label: '内存使用率', value: node.memory }, { label: '存储使用率', value: node.storage }]" :key="meter.label"><div class="meter-label"><span>{{ meter.label }}</span><strong>{{ Math.min(100, Math.round(meter.value * 100)) }}<small>%</small></strong></div><el-progress :percentage="Math.min(100, Math.round(meter.value * 100))" :show-text="false" :stroke-width="8" :color="meter.value >= .85 ? 'var(--vdi-warn)' : 'var(--vdi-brand)'" /></div></div><p class="secondary node-sync">最近同步 {{ new Date(node.updatedAt).toLocaleString('zh-CN') }}</p></div></section>
|
||||||
|
<section class="panel health-panel"><div class="panel-heading"><h2>资源健康度</h2><span class="secondary">当前快照</span></div><div class="health-row"><span><i class="health-dot" :class="healthyNodes === data.resources.length && data.resources.length ? 'tone-ok' : 'tone-warn'" />计算节点</span><strong>{{ healthyNodes }} / {{ data.resources.length }}</strong></div><div class="health-row"><span><i class="health-dot" :class="data.overview?.onlineTerminals ? 'tone-ok' : 'tone-warn'" />在线终端</span><router-link to="/terminals">{{ data.overview?.onlineTerminals ?? 0 }} / {{ data.overview?.terminalCount ?? 0 }}</router-link></div><div class="health-row"><span><i class="health-dot" :class="data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length ? 'tone-warn' : 'tone-ok'" />未授权桌面</span><router-link to="/users">{{ data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length }} 台</router-link></div><div class="health-row"><span><i class="health-dot tone-ok" />当前账号</span><span class="tone-ok"><el-icon><CircleCheck /></el-icon> 已认证</span></div><router-link to="/tasks" class="pending-summary"><el-icon :size="19"><Clock /></el-icon><div><strong>{{ data.tasks.filter(t => ['queued', 'running'].includes(t.status)).length }} 个任务等待回执</strong><small>查看任务进度与执行结果</small></div><el-icon><ArrowRight /></el-icon></router-link></section>
|
||||||
|
</div>
|
||||||
|
<section class="panel"><div class="panel-heading"><div><h2>最近任务</h2><p class="subtext">每次操作都有进度和结果记录</p></div><router-link to="/tasks">查看全部 <el-icon><ArrowRight /></el-icon></router-link></div><el-table :data="recent" empty-text="尚无执行任务"><el-table-column prop="targetName" label="操作目标" min-width="150" /><el-table-column label="操作" min-width="120"><template #default="{ row }">{{ taskLabels[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="message" label="执行结果" min-width="250" /></el-table></section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.actions .el-icon{margin-right:7px}.stat-unit{color:var(--vdi-muted);font-size:14px;font-weight:500}.node-block+.node-block{border-top:1px solid var(--vdi-border);padding-top:22px;margin-top:22px}.node-heading{display:flex;justify-content:space-between;align-items:center;margin:24px 0 27px}.node-name{display:flex;align-items:center;gap:12px}.node-name strong{font-size:15px}.node-name small{display:block;color:var(--vdi-muted);font-size:11px;margin-top:4px}.node-icon{display:grid;place-items:center;width:42px;height:42px;background:var(--vdi-panel-2);border-radius:12px;color:var(--vdi-accent)}.node-meters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}.meter-label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:16px}.meter-label>span{font-size:12px;color:var(--vdi-muted)}.meter-label strong{font-size:18px}.meter-label strong small{font-size:11px;font-weight:400;margin-left:2px}.node-sync{margin-top:29px}.health-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 0;border-bottom:1px solid var(--vdi-border);font-size:13px}.health-row>span:first-child{display:flex;align-items:center}.health-row strong{font-size:14px}.health-row .tone-ok{display:flex;align-items:center;gap:5px}.pending-summary{display:flex;align-items:center;gap:12px;background:var(--vdi-panel-2);border-radius:12px;padding:15px;margin-top:21px;color:var(--vdi-text)}.pending-summary>.el-icon:first-child{color:var(--vdi-accent)}.pending-summary>div{flex:1}.pending-summary strong{font-size:12px;font-weight:500}.pending-summary small{display:block;font-size:11px;color:var(--vdi-muted);margin-top:5px}.panel-heading>a{display:flex;gap:4px;align-items:center}
|
||||||
|
@media(max-width:600px){.node-meters{grid-template-columns:1fr;gap:20px}.node-heading{margin-top:18px}.node-sync{margin-top:20px}.meter-label{margin-bottom:10px}.overview-grid{gap:0}}
|
||||||
|
</style>
|
||||||
16
console/src/views/Resources.vue
Normal file
16
console/src/views/Resources.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } 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 syncing = computed(() => data.tasks.some(t => t.kind === 'resource' && ['queued', 'running'].includes(t.status)))
|
||||||
|
const stale = (date: string, provider: string) => provider !== 'demo' && Date.now() - Date.parse(date) > 90000
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="page-heading"><div><h1>计算资源</h1><p class="subtext">计算节点与资源同步状态</p></div><el-button v-if="auth.canAdmin" type="primary" :disabled="!data.settings?.pveConfigured || syncing" :loading="busy" @click="run(() => api.sync(), '同步任务已提交')">{{ syncing ? '同步中' : '同步 Proxmox' }}</el-button></div>
|
||||||
|
<el-alert v-if="!data.settings?.pveConfigured" type="info" class="notice" :closable="false" title="尚未接入 Proxmox" description="由管理员在服务端设置访问地址、API Token 和 CA 证书后重启平台。当前演示节点不代表真实资源。" show-icon />
|
||||||
|
<section class="panel"><el-table :data="data.resources" empty-text="尚无资源,配置接入后同步节点"><el-table-column prop="name" label="节点" min-width="150" /><el-table-column label="状态" width="125"><template #default="{ row }"><el-tag v-if="stale(row.updatedAt, row.provider)" type="warning">数据过期</el-tag><StatusTag v-else :value="row.status" /></template></el-table-column><el-table-column label="CPU" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.cpu * 100))" /></template></el-table-column><el-table-column label="内存" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.memory * 100))" /></template></el-table-column><el-table-column label="存储" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.storage * 100))" /></template></el-table-column><el-table-column label="来源 / 最近同步" min-width="190"><template #default="{ row }"><StatusTag :value="row.provider" /><div class="secondary">{{ new Date(row.updatedAt).toLocaleString('zh-CN') }}</div></template></el-table-column></el-table></section>
|
||||||
|
</template>
|
||||||
30
console/src/views/Sessions.vue
Normal file
30
console/src/views/Sessions.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Connection, Monitor, Refresh, VideoCamera, ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
import { usePlatform } from '../stores/platform'
|
||||||
|
import { useAuth } from '../stores/auth'
|
||||||
|
import { useAction } from '../composables/useAction'
|
||||||
|
import { api, type Terminal } from '../api'
|
||||||
|
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
|
||||||
|
const selectedId = ref('')
|
||||||
|
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))
|
||||||
|
const user = computed(() => data.users.find(u => u.terminalId === selected.value?.id))
|
||||||
|
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running'].includes(t.status))
|
||||||
|
async function disconnect(terminal: Terminal) {
|
||||||
|
if (await confirm('断开远程会话', `将结束 ${terminal.name} 当前的串流连接,云桌面继续运行。`)) await run(() => api.terminalCommand(terminal.id, 'reset-session'), '断开命令已提交,等待终端回执')
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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}
|
||||||
|
@media(max-width:1100px){.session-layout{grid-template-columns:1fr}.session-list{grid-template-columns:1fr}.preview-unavailable{min-height:260px}}
|
||||||
|
@media(max-width:600px){.empty-session{padding:60px 22px}.preview-unavailable{min-height:280px;aspect-ratio:auto}.session-workspace{padding:16px 16px 0}.session-summary{gap:15px}.preview-content h3{font-size:16px}.preview-content p{font-size:12px}}
|
||||||
|
</style>
|
||||||
18
console/src/views/Settings.vue
Normal file
18
console/src/views/Settings.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 灰度与回滚']
|
||||||
|
</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>
|
||||||
|
</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}
|
||||||
|
@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>
|
||||||
33
console/src/views/Tasks.vue
Normal file
33
console/src/views/Tasks.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<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))
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div><el-button @click="data.refresh">刷新</el-button></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" />
|
||||||
|
</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>
|
||||||
29
console/src/views/Terminals.vue
Normal file
29
console/src/views/Terminals.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } 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 StatusTag from '../components/StatusTag.vue'
|
||||||
|
const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction()
|
||||||
|
const keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref(''), createVisible = ref(false), secret = ref(''), activationVisible = ref(false)
|
||||||
|
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))
|
||||||
|
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))
|
||||||
|
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 = '' } }
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
26
console/src/views/Users.vue
Normal file
26
console/src/views/Users.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { api, type User } 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 keyword = ref(''), page = ref(1), createVisible = ref(false), bindingVisible = ref(false), selectedId = ref('')
|
||||||
|
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())))
|
||||||
|
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
|
||||||
|
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 }
|
||||||
|
const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id)
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
12
console/tsconfig.json
Normal file
12
console/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "e2e/**/*.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
8
console/vite.config.ts
Normal file
8
console/vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: { port: 5180, strictPort: true, proxy: { '/api': process.env.VDI_API_PROXY || 'http://127.0.0.1:8088', '/healthz': process.env.VDI_API_PROXY || 'http://127.0.0.1:8088' } },
|
||||||
|
build: { rollupOptions: { output: { manualChunks: { 'element-plus': ['element-plus'], framework: ['vue', 'vue-router', 'pinia'] } } } },
|
||||||
|
})
|
||||||
2
console/vitest.config.ts
Normal file
2
console/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
export default defineConfig({ test: { include: ['src/**/*.test.ts'], environment: 'node' } })
|
||||||
@ -1 +0,0 @@
|
|||||||
# Keep the monorepo directory structure in Git.
|
|
||||||
44
deploy/compose.dev.yml
Normal file
44
deploy/compose.dev.yml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: vdi-cloud-dev
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: vdi
|
||||||
|
POSTGRES_PASSWORD: vdi-local-demo
|
||||||
|
POSTGRES_DB: vdi
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:55438:5432"
|
||||||
|
volumes:
|
||||||
|
- vdi-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U vdi -d vdi"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: platform/Dockerfile
|
||||||
|
environment:
|
||||||
|
VDI_MODE: demo
|
||||||
|
VDI_LISTEN: 0.0.0.0:8080
|
||||||
|
VDI_JWT_SECRET: local-demo-only-change-before-any-production-use-2026
|
||||||
|
DATABASE_URL: postgres://vdi:vdi-local-demo@postgres:5432/vdi?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${VDI_API_PORT:-8088}:8080"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
init: true
|
||||||
|
console:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: console/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_DEMO_HINT: "true"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${VDI_CONSOLE_PORT:-5180}:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
volumes:
|
||||||
|
vdi-postgres:
|
||||||
59
deploy/compose.live.yml
Normal file
59
deploy/compose.live.yml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
name: vdi-cloud-live
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: vdi
|
||||||
|
POSTGRES_PASSWORD: ${VDI_DB_PASSWORD:?required}
|
||||||
|
POSTGRES_DB: vdi_live
|
||||||
|
volumes:
|
||||||
|
- vdi-live-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U vdi -d vdi_live"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
api:
|
||||||
|
image: vdi-cloud-live-api
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: platform/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
extra_hosts:
|
||||||
|
- "${PVE_HOSTNAME:?required}:${PVE_HOST_IP:?required}"
|
||||||
|
environment:
|
||||||
|
VDI_MODE: production
|
||||||
|
VDI_LISTEN: 0.0.0.0:8080
|
||||||
|
VDI_JWT_SECRET: ${VDI_JWT_SECRET:?required}
|
||||||
|
VDI_ADMIN_USERNAME: ${VDI_ADMIN_USERNAME:?required}
|
||||||
|
VDI_ADMIN_PASSWORD: ${VDI_ADMIN_PASSWORD:?required}
|
||||||
|
DATABASE_URL: postgres://vdi:${VDI_DB_PASSWORD}@postgres:5432/vdi_live?sslmode=disable
|
||||||
|
PVE_URL: ${PVE_URL:?required}
|
||||||
|
PVE_TOKEN_ID: ${PVE_TOKEN_ID:?required}
|
||||||
|
PVE_TOKEN_SECRET: ${PVE_TOKEN_SECRET:?required}
|
||||||
|
PVE_CA_FILE: /run/secrets/pve-ca.pem
|
||||||
|
volumes:
|
||||||
|
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
console:
|
||||||
|
image: vdi-cloud-live-console
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: console/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_DEMO_HINT: "false"
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${VDI_LIVE_CONSOLE_PORT:-5182}:80"
|
||||||
|
- "${VDI_LIVE_BIND_IP:?required}:${VDI_LIVE_HTTPS_PORT:-8443}:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.live.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ../.local/live/tls:/run/secrets/tls:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
volumes:
|
||||||
|
vdi-live-postgres:
|
||||||
13
deploy/nginx.conf
Normal file
13
deploy/nginx.conf
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 1m;
|
||||||
|
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 /healthz { proxy_pass http://api:8080; }
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
}
|
||||||
17
deploy/nginx.live.conf
Normal file
17
deploy/nginx.live.conf
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl;
|
||||||
|
ssl_certificate /run/secrets/tls/server.pem;
|
||||||
|
ssl_certificate_key /run/secrets/tls/server-key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 1m;
|
||||||
|
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 /healthz { proxy_pass http://api:8080; }
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
}
|
||||||
4824
docs/openapi/platform.v1.json
Normal file
4824
docs/openapi/platform.v1.json
Normal file
File diff suppressed because it is too large
Load Diff
11
platform/.env.example
Normal file
11
platform/.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
VDI_MODE=production
|
||||||
|
VDI_LISTEN=127.0.0.1:8080
|
||||||
|
DATABASE_URL=postgres://vdi:replace-me@127.0.0.1:5432/vdi?sslmode=require
|
||||||
|
VDI_JWT_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
VDI_ADMIN_USERNAME=admin
|
||||||
|
VDI_ADMIN_PASSWORD=replace-with-strong-initial-password
|
||||||
|
# PVE_URL is the HTTPS origin. CA verification is always enabled.
|
||||||
|
# PVE_URL=https://pve.example.internal:8006
|
||||||
|
# PVE_TOKEN_ID=vdi@pve!console
|
||||||
|
# PVE_TOKEN_SECRET=replace-me
|
||||||
|
# PVE_CA_FILE=/run/secrets/pve-ca.pem
|
||||||
@ -1 +0,0 @@
|
|||||||
# Keep the monorepo directory structure in Git.
|
|
||||||
13
platform/Dockerfile
Normal file
13
platform/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.27-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY platform/go.mod platform/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY platform/ ./
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -o /vdi-api ./cmd/api-gateway
|
||||||
|
|
||||||
|
FROM alpine:3.23
|
||||||
|
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 vdi
|
||||||
|
COPY --from=build /vdi-api /usr/local/bin/vdi-api
|
||||||
|
USER vdi
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["vdi-api"]
|
||||||
14
platform/README.md
Normal file
14
platform/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# VDI 云端服务
|
||||||
|
|
||||||
|
Go API 网关和第一阶段业务模块。运行、接口、真实 PVE 接入与当前限制见[开发说明](../docs/cloud-platform-development.md)。
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/api-gateway/ 服务入口与优雅退出
|
||||||
|
internal/config/ 环境配置校验
|
||||||
|
internal/model/ 对外数据类型
|
||||||
|
internal/store/ PostgreSQL 初始化、账号与业务存储
|
||||||
|
internal/server/ 认证、资源/桌面、终端、授权、任务与审计
|
||||||
|
pkg/proxmox/ 真实 PVE API 适配与异步任务跟踪
|
||||||
|
```
|
||||||
|
|
||||||
|
配置参考 `.env.example`,变量由运行环境注入;程序不会自动读取 `.env`。不要提交访问凭据。
|
||||||
58
platform/cmd/api-gateway/main.go
Normal file
58
platform/cmd/api-gateway/main.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/config"
|
||||||
|
"vdi-cloud/platform/internal/server"
|
||||||
|
"vdi-cloud/platform/internal/store"
|
||||||
|
"vdi-cloud/platform/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
startup, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := store.Open(startup, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("database initialization failed: ", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = store.Bootstrap(startup, db, cfg.Mode, cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||||
|
log.Fatal("bootstrap failed: ", err)
|
||||||
|
}
|
||||||
|
var pve *proxmox.Client
|
||||||
|
if cfg.PVEURL != "" {
|
||||||
|
pve, err = proxmox.New(cfg.PVEURL, cfg.PVETokenID, cfg.PVETokenSecret, cfg.PVECAFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := server.New(cfg, db, pve)
|
||||||
|
go s.RunWorker(ctx)
|
||||||
|
if pve != nil {
|
||||||
|
go s.RunSync(ctx)
|
||||||
|
}
|
||||||
|
httpServer := &http.Server{Addr: cfg.Address, Handler: s.Router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
|
||||||
|
go func() {
|
||||||
|
log.Printf("VDI API listening on %s (mode=%s)", cfg.Address, cfg.Mode)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Print(err)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdown, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
_ = httpServer.Shutdown(shutdown)
|
||||||
|
}
|
||||||
47
platform/go.mod
Normal file
47
platform/go.mod
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
module vdi-cloud/platform
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
|
golang.org/x/net v0.45.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
)
|
||||||
109
platform/go.sum
Normal file
109
platform/go.sum
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
|
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||||
|
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
39
platform/internal/config/config.go
Normal file
39
platform/internal/config/config.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Mode, Address, DatabaseURL, JWTSecret, AdminUsername, AdminPassword string
|
||||||
|
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")}
|
||||||
|
if c.Mode != "demo" && c.Mode != "production" {
|
||||||
|
return c, fmt.Errorf("VDI_MODE must be demo or production")
|
||||||
|
}
|
||||||
|
if c.DatabaseURL == "" {
|
||||||
|
return c, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
if len(c.JWTSecret) < 32 {
|
||||||
|
return c, fmt.Errorf("VDI_JWT_SECRET must contain at least 32 characters")
|
||||||
|
}
|
||||||
|
if c.Mode == "production" && strings.TrimSpace(c.AdminUsername) == "" {
|
||||||
|
return c, fmt.Errorf("production requires VDI_ADMIN_USERNAME")
|
||||||
|
}
|
||||||
|
if c.PVEURL != "" && (c.PVETokenID == "" || c.PVETokenSecret == "") {
|
||||||
|
return c, fmt.Errorf("PVE_TOKEN_ID and PVE_TOKEN_SECRET are required with PVE_URL")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
91
platform/internal/model/model.go
Normal file
91
platform/internal/model/model.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Administrator struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Username string `json:"username" db:"username"`
|
||||||
|
DisplayName string `json:"displayName" db:"display_name"`
|
||||||
|
Role string `json:"role" db:"role"`
|
||||||
|
PasswordHash string `json:"-" db:"password_hash"`
|
||||||
|
}
|
||||||
|
type Desktop struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
OS string `json:"os" db:"os"`
|
||||||
|
Node string `json:"node" db:"node"`
|
||||||
|
VMID int `json:"vmid" db:"vmid"`
|
||||||
|
Pool string `json:"pool" db:"pool"`
|
||||||
|
PowerState string `json:"powerState" db:"power_state"`
|
||||||
|
ServiceState string `json:"serviceState" db:"service_state"`
|
||||||
|
Provider string `json:"provider" db:"provider"`
|
||||||
|
Endpoint string `json:"endpoint" db:"endpoint"`
|
||||||
|
App string `json:"app" db:"app"`
|
||||||
|
Width int `json:"width" db:"width"`
|
||||||
|
Height int `json:"height" db:"height"`
|
||||||
|
FPS int `json:"fps" db:"fps"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
type Terminal struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
SN string `json:"sn" db:"sn"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Group string `json:"group" db:"group_name"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
AgentVersion string `json:"agentVersion" db:"agent_version"`
|
||||||
|
LastSeen *time.Time `json:"lastSeen" db:"last_seen"`
|
||||||
|
SessionDesktopID *string `json:"sessionDesktopId" db:"session_desktop_id"`
|
||||||
|
SessionState string `json:"sessionState" db:"session_state"`
|
||||||
|
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||||
|
FPS *float64 `json:"fps" db:"fps"`
|
||||||
|
RTT *float64 `json:"rtt" db:"rtt"`
|
||||||
|
}
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Username string `json:"username" db:"username"`
|
||||||
|
DisplayName string `json:"displayName" db:"display_name"`
|
||||||
|
Enabled bool `json:"enabled" db:"enabled"`
|
||||||
|
TerminalID *string `json:"terminalId" db:"terminal_id"`
|
||||||
|
DesktopIDs []string `json:"desktopIds"`
|
||||||
|
}
|
||||||
|
type Resource struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
CPU float64 `json:"cpu" db:"cpu"`
|
||||||
|
Memory float64 `json:"memory" db:"memory"`
|
||||||
|
Storage float64 `json:"storage" db:"storage"`
|
||||||
|
Provider string `json:"provider" db:"provider"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
}
|
||||||
|
type Task struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Kind string `json:"kind" db:"kind"`
|
||||||
|
Action string `json:"action" db:"action"`
|
||||||
|
TargetID string `json:"targetId" db:"target_id"`
|
||||||
|
TargetName string `json:"targetName" db:"target_name"`
|
||||||
|
Actor string `json:"actor" db:"actor"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
Message string `json:"message" db:"message"`
|
||||||
|
ProviderRef string `json:"providerRef" db:"provider_ref"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
|
IdempotencyKey string `json:"-" db:"idempotency_key"`
|
||||||
|
}
|
||||||
|
type Audit struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Actor string `json:"actor" db:"actor"`
|
||||||
|
Action string `json:"action" db:"action"`
|
||||||
|
Target string `json:"target" db:"target"`
|
||||||
|
Result string `json:"result" db:"result"`
|
||||||
|
RequestID string `json:"requestId" db:"request_id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceDiagnostics struct {
|
||||||
|
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||||
|
BootID string `json:"bootId" binding:"required,max=80"`
|
||||||
|
UptimeSeconds float64 `json:"uptimeSeconds" binding:"min=0"`
|
||||||
|
KioskState string `json:"kioskState" binding:"required,max=80"`
|
||||||
|
LogTail string `json:"logTail" binding:"max=16384"`
|
||||||
|
}
|
||||||
553
platform/internal/server/admin.go
Normal file
553
platform/internal/server/admin.go
Normal file
@ -0,0 +1,553 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/model"
|
||||||
|
"vdi-cloud/platform/internal/store"
|
||||||
|
"vdi-cloud/platform/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) overview(c *gin.Context) {
|
||||||
|
var counts struct {
|
||||||
|
Terminals int `db:"terminals"`
|
||||||
|
Online int `db:"online"`
|
||||||
|
Desktops int `db:"desktops"`
|
||||||
|
Running int `db:"running"`
|
||||||
|
Sessions int `db:"sessions"`
|
||||||
|
Failed int `db:"failed"`
|
||||||
|
}
|
||||||
|
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
|
||||||
|
}
|
||||||
|
func (s *Server) settings(c *gin.Context) {
|
||||||
|
provider := "unconfigured"
|
||||||
|
if s.Config.Mode == "demo" {
|
||||||
|
provider = "demo"
|
||||||
|
}
|
||||||
|
if s.PVE != nil {
|
||||||
|
provider = "proxmox"
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
|
||||||
|
}
|
||||||
|
func (s *Server) resources(c *gin.Context) {
|
||||||
|
items := []model.Resource{}
|
||||||
|
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) desktops(c *gin.Context) {
|
||||||
|
items := []model.Desktop{}
|
||||||
|
q := "%" + c.Query("keyword") + "%"
|
||||||
|
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) desktop(c *gin.Context) {
|
||||||
|
var d model.Desktop
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, d)
|
||||||
|
}
|
||||||
|
func (s *Server) terminals(c *gin.Context) {
|
||||||
|
items := []model.Terminal{}
|
||||||
|
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) users(c *gin.Context) {
|
||||||
|
items, err := store.Users(c.Request.Context(), s.DB)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) createUser(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Username string `json:"username" binding:"required,min=2,max=80"`
|
||||||
|
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Username = strings.TrimSpace(in.Username)
|
||||||
|
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||||
|
if in.Username == "" || in.DisplayName == "" {
|
||||||
|
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := store.ID("user_")
|
||||||
|
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 409, "conflict", "该账号已存在")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log(c, "create_user", id, "success")
|
||||||
|
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
|
||||||
|
}
|
||||||
|
func conflict(err error) bool {
|
||||||
|
var e *pgconn.PgError
|
||||||
|
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
|
||||||
|
}
|
||||||
|
func (s *Server) bindings(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
TerminalID *string `json:"terminalId"`
|
||||||
|
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.TerminalID != nil && *in.TerminalID == "" {
|
||||||
|
in.TerminalID = nil
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var user model.User
|
||||||
|
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Enabled != nil {
|
||||||
|
user.Enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range in.DesktopIDs {
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
|
||||||
|
if err == nil {
|
||||||
|
err = tx.Commit()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.TerminalID = in.TerminalID
|
||||||
|
user.DesktopIDs = in.DesktopIDs
|
||||||
|
if user.DesktopIDs == nil {
|
||||||
|
user.DesktopIDs = []string{}
|
||||||
|
}
|
||||||
|
c.JSON(200, user)
|
||||||
|
}
|
||||||
|
func (s *Server) tasks(c *gin.Context) {
|
||||||
|
items := []model.Task{}
|
||||||
|
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) task(c *gin.Context) {
|
||||||
|
var t model.Task
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, t)
|
||||||
|
}
|
||||||
|
func (s *Server) audits(c *gin.Context) {
|
||||||
|
items := []model.Audit{}
|
||||||
|
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) discoverVMs(c *gin.Context) {
|
||||||
|
if s.PVE == nil {
|
||||||
|
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := s.PVE.Resources(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vms := []gin.H{}
|
||||||
|
for _, r := range items {
|
||||||
|
if r.Type == "qemu" && r.Template == 0 {
|
||||||
|
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list(c, vms)
|
||||||
|
}
|
||||||
|
func (s *Server) syncResources(c *gin.Context) {
|
||||||
|
if s.PVE == nil {
|
||||||
|
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
|
||||||
|
}
|
||||||
|
func (s *Server) importDesktop(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name" binding:"required,max=80"`
|
||||||
|
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||||
|
Node string `json:"node" binding:"required,max=100"`
|
||||||
|
VMID int `json:"vmid" binding:"min=100"`
|
||||||
|
Endpoint string `json:"endpoint" binding:"max=253"`
|
||||||
|
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
|
||||||
|
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
|
||||||
|
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.PVE == nil {
|
||||||
|
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
|
||||||
|
for _, part := range strings.Split(in.Endpoint, ".") {
|
||||||
|
if part == "" || strings.ContainsAny(part, "/:@ \\") {
|
||||||
|
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := store.ID("desk_")
|
||||||
|
if in.Width == 0 {
|
||||||
|
in.Width = 2560
|
||||||
|
}
|
||||||
|
if in.Height == 0 {
|
||||||
|
in.Height = 1440
|
||||||
|
}
|
||||||
|
if in.FPS == 0 {
|
||||||
|
in.FPS = 30
|
||||||
|
}
|
||||||
|
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
|
||||||
|
if err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 409, "already_managed", "该 VM 已纳管")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log(c, "import_desktop", id, "success")
|
||||||
|
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
|
||||||
|
s.desktop(c)
|
||||||
|
}
|
||||||
|
func (s *Server) desktopAction(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d model.Desktop
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.Provider == "demo" && s.Config.Mode != "demo" {
|
||||||
|
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.Provider == "proxmox" && s.PVE == nil {
|
||||||
|
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
|
||||||
|
}
|
||||||
|
func (s *Server) terminalCommand(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
|
||||||
|
}
|
||||||
|
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
|
||||||
|
key := c.GetHeader("Idempotency-Key")
|
||||||
|
if key == "" || len(key) > 100 {
|
||||||
|
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := s.admin(c).Username
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var existing model.Task
|
||||||
|
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
|
||||||
|
if err == nil {
|
||||||
|
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
|
||||||
|
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(202, existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var task model.Task
|
||||||
|
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
|
||||||
|
if err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
|
||||||
|
if err == nil {
|
||||||
|
err = tx.Commit()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(202, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete persists the final result and its audit entry together.
|
||||||
|
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := result.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Session release is scoped to a session ID by the device event endpoint.
|
||||||
|
// A delayed command acknowledgement must not clear a newer connection.
|
||||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
func ratio(a, b float64) float64 {
|
||||||
|
if b <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return a / b
|
||||||
|
}
|
||||||
|
func (s *Server) sync(ctx context.Context) error {
|
||||||
|
items, err := s.PVE.Resources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range items {
|
||||||
|
if r.Type == "node" {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Type == "qemu" {
|
||||||
|
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RunWorker(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(300 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Unknown outcomes are never automatically retried after a worker restart.
|
||||||
|
var expired []model.Task
|
||||||
|
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
|
||||||
|
for _, t := range expired {
|
||||||
|
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var t model.Task
|
||||||
|
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.execute(ctx, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource reads are independent of the command queue and never change VM power.
|
||||||
|
func (s *Server) RunSync(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
if err := s.sync(readCtx); err != nil {
|
||||||
|
log.Printf("PVE resource sync failed: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) execute(parent context.Context, t model.Task) {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var err error
|
||||||
|
uncertain := false
|
||||||
|
message := "已收到执行结果"
|
||||||
|
if t.Kind == "resource" {
|
||||||
|
err = s.sync(ctx)
|
||||||
|
} else {
|
||||||
|
var d model.Desktop
|
||||||
|
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
|
||||||
|
if err == nil && d.Provider == "demo" {
|
||||||
|
if s.Config.Mode != "demo" {
|
||||||
|
err = errors.New("demo disabled")
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
err = ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
power := "running"
|
||||||
|
if t.Action == "shutdown" {
|
||||||
|
power = "stopped"
|
||||||
|
}
|
||||||
|
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||||||
|
message = "演示计算任务已完成;未检测真实连接服务"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
if s.PVE == nil {
|
||||||
|
err = errors.New("PVE unconfigured")
|
||||||
|
} else {
|
||||||
|
var upid string
|
||||||
|
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
|
||||||
|
uncertain = true
|
||||||
|
var httpError *proxmox.HTTPError
|
||||||
|
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
|
||||||
|
uncertain = false
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var power string
|
||||||
|
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
|
||||||
|
if err == nil {
|
||||||
|
expected := "running"
|
||||||
|
if t.Action == "shutdown" {
|
||||||
|
expected = "stopped"
|
||||||
|
}
|
||||||
|
if power != expected {
|
||||||
|
err = fmt.Errorf("power result not confirmed")
|
||||||
|
} else {
|
||||||
|
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
|
||||||
|
status = "failed"
|
||||||
|
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
|
||||||
|
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
status = "timeout"
|
||||||
|
message = "结果未确认,请核对实际状态;未自动重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer finalCancel()
|
||||||
|
_ = s.complete(finalCtx, t, status, message)
|
||||||
|
}
|
||||||
214
platform/internal/server/server.go
Normal file
214
platform/internal/server/server.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/config"
|
||||||
|
"vdi-cloud/platform/internal/model"
|
||||||
|
"vdi-cloud/platform/internal/store"
|
||||||
|
"vdi-cloud/platform/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Config config.Config
|
||||||
|
DB *sqlx.DB
|
||||||
|
PVE *proxmox.Client
|
||||||
|
Router *gin.Engine
|
||||||
|
}
|
||||||
|
type claims struct {
|
||||||
|
AdminID string `json:"adminId"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
s := &Server{Config: cfg, DB: db, PVE: pve}
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery(), func(c *gin.Context) {
|
||||||
|
c.Set("requestId", store.ID("req_"))
|
||||||
|
c.Header("X-Request-ID", c.GetString("requestId"))
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
s.Router = r
|
||||||
|
r.GET("/healthz", func(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if db.PingContext(ctx) != nil {
|
||||||
|
s.fail(c, 503, "dependency_unavailable", "数据库暂不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "mode": cfg.Mode})
|
||||||
|
})
|
||||||
|
r.POST("/api/v1/auth/login", s.login)
|
||||||
|
r.POST("/api/v1/terminal/register", s.register)
|
||||||
|
t := r.Group("/api/v1/terminal", s.deviceAuth)
|
||||||
|
t.POST("/heartbeat", s.heartbeat)
|
||||||
|
t.GET("/config", s.deviceConfig)
|
||||||
|
t.GET("/desktops", s.deviceDesktops)
|
||||||
|
t.GET("/commands", s.deviceCommands)
|
||||||
|
t.POST("/commands/:id/ack", s.deviceAck)
|
||||||
|
t.POST("/connect-ticket", s.createTicket)
|
||||||
|
t.POST("/connect-ticket/consume", s.consumeTicket)
|
||||||
|
t.POST("/session", s.deviceSession)
|
||||||
|
a := r.Group("/api/v1", s.adminAuth)
|
||||||
|
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
|
||||||
|
a.POST("/auth/logout", s.logout)
|
||||||
|
a.GET("/overview", s.overview)
|
||||||
|
a.GET("/settings", s.settings)
|
||||||
|
a.GET("/resources", s.resources)
|
||||||
|
a.GET("/resources/vms", s.discoverVMs)
|
||||||
|
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
|
||||||
|
a.GET("/desktops", s.desktops)
|
||||||
|
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
|
||||||
|
a.GET("/desktops/:id", s.desktop)
|
||||||
|
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
|
||||||
|
a.GET("/terminals", s.terminals)
|
||||||
|
a.POST("/terminals", s.allow("super_admin"), s.enroll)
|
||||||
|
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
|
||||||
|
a.GET("/users", s.users)
|
||||||
|
a.POST("/users", s.allow("super_admin"), s.createUser)
|
||||||
|
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
|
||||||
|
a.GET("/tasks", s.tasks)
|
||||||
|
a.GET("/tasks/:id", s.task)
|
||||||
|
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
|
||||||
|
a.GET("/audit-logs", s.audits)
|
||||||
|
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Server) fail(c *gin.Context, status int, code, message string) {
|
||||||
|
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message, "requestId": c.GetString("requestId")})
|
||||||
|
}
|
||||||
|
func (s *Server) dbError(c *gin.Context, err error) {
|
||||||
|
log.Printf("database request failed requestId=%s error=%v", c.GetString("requestId"), err)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
s.fail(c, 404, "not_found", "记录不存在")
|
||||||
|
} else {
|
||||||
|
s.fail(c, 500, "database_error", "操作未完成,请根据请求 ID 检查服务日志")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) decode(c *gin.Context, v any) bool {
|
||||||
|
if err := c.ShouldBindJSON(v); err != nil {
|
||||||
|
s.fail(c, 400, "invalid_request", "请检查必填字段和数据格式")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func (s *Server) admin(c *gin.Context) model.Administrator {
|
||||||
|
return c.MustGet("admin").(model.Administrator)
|
||||||
|
}
|
||||||
|
func (s *Server) log(c *gin.Context, action, target, result string) {
|
||||||
|
actor := "device"
|
||||||
|
if a, ok := c.Get("admin"); ok {
|
||||||
|
actor = a.(model.Administrator).Username
|
||||||
|
}
|
||||||
|
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,$5,$6)", store.ID("audit_"), actor, action, target, result, c.GetString("requestId"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) allow(roles ...string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
for _, role := range roles {
|
||||||
|
if role == s.admin(c).Role {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.log(c, c.Request.Method+" "+c.FullPath(), c.Param("id"), "denied")
|
||||||
|
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) adminAuth(c *gin.Context) {
|
||||||
|
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
cl := &claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(raw, cl, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-platform"), jwt.WithAudience("vdi-console"), jwt.WithExpirationRequired())
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var a model.Administrator
|
||||||
|
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("admin", a)
|
||||||
|
c.Set("sessionId", cl.ID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
func (s *Server) login(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Username string `json:"username" binding:"required,max=80"`
|
||||||
|
Password string `json:"password" binding:"required,max=200"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var a model.Administrator
|
||||||
|
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
|
||||||
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
|
||||||
|
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
id := store.ID("sess_")
|
||||||
|
expires := now.Add(time.Hour)
|
||||||
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{AdminID: a.ID, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-platform", Audience: jwt.ClaimStrings{"vdi-console"}, ID: id, Subject: a.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires)}}).SignedString([]byte(s.Config.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("admin", a)
|
||||||
|
s.log(c, "login", a.Username, "success")
|
||||||
|
c.JSON(200, gin.H{"accessToken": token, "expiresIn": 3600, "user": a})
|
||||||
|
}
|
||||||
|
func (s *Server) logout(c *gin.Context) {
|
||||||
|
if _, err := s.DB.ExecContext(c.Request.Context(), "DELETE FROM admin_sessions WHERE id=$1", c.GetString("sessionId")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log(c, "logout", s.admin(c).Username, "success")
|
||||||
|
c.Status(204)
|
||||||
|
}
|
||||||
|
func list[T any](c *gin.Context, items []T) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
if size > 100 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
total := len(items)
|
||||||
|
start := (page - 1) * size
|
||||||
|
if start < 0 || start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + size
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []T{}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"items": items[start:end], "page": page, "pageSize": size, "total": total, "requestId": c.GetString("requestId")})
|
||||||
|
}
|
||||||
375
platform/internal/server/server_test.go
Normal file
375
platform/internal/server/server_test.go
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/config"
|
||||||
|
"vdi-cloud/platform/internal/model"
|
||||||
|
"vdi-cloud/platform/internal/store"
|
||||||
|
"vdi-cloud/platform/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
server *Server
|
||||||
|
db *sqlx.DB
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("VDI_TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := store.Open(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
go s.RunWorker(ctx)
|
||||||
|
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
|
||||||
|
return &fixture{s, db, t}
|
||||||
|
}
|
||||||
|
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
|
||||||
|
f.t.Helper()
|
||||||
|
var encoded []byte
|
||||||
|
if body != nil {
|
||||||
|
encoded, _ = json.Marshal(body)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
r.Header.Set("Idempotency-Key", key)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.server.Router.ServeHTTP(w, r)
|
||||||
|
out := map[string]any{}
|
||||||
|
if w.Body.Len() > 0 {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
func (f *fixture) login(user string) string {
|
||||||
|
f.t.Helper()
|
||||||
|
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
|
||||||
|
if status != 200 {
|
||||||
|
f.t.Fatalf("login failed %d %v", status, out)
|
||||||
|
}
|
||||||
|
return out["accessToken"].(string)
|
||||||
|
}
|
||||||
|
func expect(t *testing.T, want, got int, out any) {
|
||||||
|
t.Helper()
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("want status %d got %d: %v", want, got, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRBACLogoutAndDurableTask(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
viewer := f.login("viewer")
|
||||||
|
operator := f.login("operator")
|
||||||
|
admin := f.login("admin")
|
||||||
|
status, out := f.request("GET", "/desktops", viewer, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
|
||||||
|
expect(t, 403, status, out)
|
||||||
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
|
||||||
|
expect(t, 403, status, out)
|
||||||
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
||||||
|
expect(t, 202, status, out)
|
||||||
|
id := out["id"].(string)
|
||||||
|
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
||||||
|
expect(t, 202, status, again)
|
||||||
|
if again["id"] != id {
|
||||||
|
t.Fatal("duplicate task created")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
|
||||||
|
expect(t, 409, status, out)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["status"] == "success" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("task never completed", out)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
var power, service string
|
||||||
|
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if power != "running" || service != "unknown" {
|
||||||
|
t.Fatal("power and service state confused")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/auth/logout", admin, nil, "")
|
||||||
|
expect(t, 204, status, out)
|
||||||
|
status, out = f.request("GET", "/auth/me", admin, nil, "")
|
||||||
|
expect(t, 401, status, out)
|
||||||
|
var count int
|
||||||
|
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatal("task not persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
admin := f.login("admin")
|
||||||
|
enroll := func(sn string) (string, string) {
|
||||||
|
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
|
||||||
|
expect(t, 201, status, out)
|
||||||
|
secret := out["activationSecret"].(string)
|
||||||
|
id := out["id"].(string)
|
||||||
|
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
token := out["accessToken"].(string)
|
||||||
|
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
||||||
|
expect(t, 401, status, out)
|
||||||
|
return id, token
|
||||||
|
}
|
||||||
|
id, device := enroll("TEST-DEVICE-001")
|
||||||
|
otherID, other := enroll("TEST-DEVICE-002")
|
||||||
|
status, out := f.request("GET", "/users", device, nil, "")
|
||||||
|
expect(t, 401, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||||
|
expect(t, 403, status, out)
|
||||||
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if len(out["items"].([]any)) != 2 {
|
||||||
|
t.Fatal("missing allowed desktops")
|
||||||
|
}
|
||||||
|
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||||
|
expect(t, 201, status, out)
|
||||||
|
ticket := out["ticket"].(string)
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
|
||||||
|
expect(t, 401, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["credentialMode"] != "existing-pairing" {
|
||||||
|
t.Fatal("must declare existing Sunshine pairing")
|
||||||
|
}
|
||||||
|
sessionID := out["sessionId"].(string)
|
||||||
|
status, out = f.request("GET", "/overview", admin, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["activeSessions"] != float64(0) {
|
||||||
|
t.Fatal("reservation counted as active stream")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("GET", "/overview", admin, nil, "")
|
||||||
|
if out["activeSessions"] != float64(1) {
|
||||||
|
t.Fatal("stream-ready event missing")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
||||||
|
expect(t, 409, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
||||||
|
expect(t, 409, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
||||||
|
expect(t, 401, status, out)
|
||||||
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||||
|
expect(t, 403, status, out)
|
||||||
|
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
|
||||||
|
expect(t, 202, status, out)
|
||||||
|
taskID := out["id"].(string)
|
||||||
|
status, out = f.request("GET", "/terminal/commands", other, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if len(out["items"].([]any)) != 0 {
|
||||||
|
t.Fatal("command leaked to other terminal")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
|
||||||
|
expect(t, 404, status, out)
|
||||||
|
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if len(out["items"].([]any)) != 1 {
|
||||||
|
t.Fatal("device did not receive command")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["status"] != "failed" {
|
||||||
|
t.Fatal("must wait for device ack")
|
||||||
|
}
|
||||||
|
if strings.Contains(out["message"].(string), "private") {
|
||||||
|
t.Fatal("raw device detail leaked")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
|
||||||
|
expect(t, 202, status, out)
|
||||||
|
diagnosticID := out["id"].(string)
|
||||||
|
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
|
||||||
|
expect(t, 400, status, out)
|
||||||
|
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
viewer := f.login("viewer")
|
||||||
|
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
|
||||||
|
expect(t, 403, status, out)
|
||||||
|
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["logTail"] != "private diagnostic" {
|
||||||
|
t.Fatal("diagnostic not retained")
|
||||||
|
}
|
||||||
|
_ = otherID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
admin := f.login("admin")
|
||||||
|
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
|
||||||
|
expect(t, 400, status, out)
|
||||||
|
var n int
|
||||||
|
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatal("failed binding update lost original authorization")
|
||||||
|
}
|
||||||
|
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
|
||||||
|
expect(t, 202, status, out)
|
||||||
|
id := out["id"].(string)
|
||||||
|
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for {
|
||||||
|
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
||||||
|
expect(t, 200, status, out)
|
||||||
|
if out["status"] == "timeout" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("offline task was not timed out", out)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
|
||||||
|
expect(t, 503, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
|
||||||
|
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
|
||||||
|
t.Fatal("demo database accepted production bootstrap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
|
||||||
|
t.Fatal("weak initial password accepted")
|
||||||
|
}
|
||||||
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var before, after string
|
||||||
|
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
|
||||||
|
t.Fatal("existing account blocked startup", err)
|
||||||
|
}
|
||||||
|
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if before != after {
|
||||||
|
t.Fatal("restart changed the account password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var task model.Task
|
||||||
|
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var session string
|
||||||
|
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
|
||||||
|
t.Fatalf("new session lost: %q %v", session, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
submission int
|
||||||
|
exit string
|
||||||
|
want string
|
||||||
|
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(tc.submission)
|
||||||
|
fmt.Fprint(w, `{"data":"UPID:test"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
|
||||||
|
}))
|
||||||
|
defer pve.Close()
|
||||||
|
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
|
||||||
|
var task model.Task
|
||||||
|
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.server.execute(context.Background(), task)
|
||||||
|
var status string
|
||||||
|
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
|
||||||
|
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
291
platform/internal/server/terminal.go
Normal file
291
platform/internal/server/terminal.go
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/model"
|
||||||
|
"vdi-cloud/platform/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) enroll(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
SN string `json:"sn" binding:"required,min=3,max=100"`
|
||||||
|
Name string `json:"name" binding:"required,max=80"`
|
||||||
|
Group string `json:"group" binding:"max=80"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.SN = strings.TrimSpace(in.SN)
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.SN == "" || in.Name == "" {
|
||||||
|
s.fail(c, 400, "invalid_request", "SN 和终端名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, secret := store.ID("term_"), store.ID("activate_")
|
||||||
|
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, in.SN, in.Name, in.Group, store.Hash(secret))
|
||||||
|
if err != nil {
|
||||||
|
if conflict(err) {
|
||||||
|
s.fail(c, 409, "sn_exists", "该 SN 已登记")
|
||||||
|
} else {
|
||||||
|
s.dbError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log(c, "enroll_terminal", id, "success")
|
||||||
|
c.JSON(201, gin.H{"id": id, "activationSecret": secret})
|
||||||
|
}
|
||||||
|
func (s *Server) register(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
SN string `json:"sn" binding:"required,max=100"`
|
||||||
|
ActivationSecret string `json:"activationSecret" binding:"required,max=200"`
|
||||||
|
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := store.ID("device_")
|
||||||
|
var id string
|
||||||
|
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
|
||||||
|
if err != nil {
|
||||||
|
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
|
||||||
|
}
|
||||||
|
func (s *Server) deviceAuth(c *gin.Context) {
|
||||||
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
var id string
|
||||||
|
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
|
||||||
|
s.fail(c, 401, "device_unauthorized", "终端身份无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("terminalId", id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
func (s *Server) heartbeat(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||||
|
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
|
||||||
|
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
|
||||||
|
}
|
||||||
|
func (s *Server) deviceConfig(c *gin.Context) {
|
||||||
|
var version int
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
|
||||||
|
}
|
||||||
|
func (s *Server) deviceDesktops(c *gin.Context) {
|
||||||
|
items := []model.Desktop{}
|
||||||
|
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) deviceCommands(c *gin.Context) {
|
||||||
|
items := []model.Task{}
|
||||||
|
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list(c, items)
|
||||||
|
}
|
||||||
|
func (s *Server) deviceAck(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Status string `json:"status" binding:"required,oneof=success failed"`
|
||||||
|
Message string `json:"message" binding:"max=300"`
|
||||||
|
Diagnostics *model.DeviceDiagnostics `json:"diagnostics"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var task model.Task
|
||||||
|
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.Status == in.Status {
|
||||||
|
c.JSON(200, gin.H{"accepted": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.Status != "running" {
|
||||||
|
s.fail(c, 409, "invalid_task_state", "该命令尚未领取或已经结束")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.Action == "collect-logs" && in.Status == "success" {
|
||||||
|
if in.Diagnostics == nil {
|
||||||
|
s.fail(c, 400, "diagnostics_required", "收集日志成功需要上传诊断结果")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, marshalErr := json.Marshal(in.Diagnostics)
|
||||||
|
if marshalErr != nil {
|
||||||
|
s.dbError(c, marshalErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Agent text can contain sensitive device details. Keep the public audit message fixed.
|
||||||
|
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"accepted": true})
|
||||||
|
}
|
||||||
|
func (s *Server) createTicket(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
DesktopID string `json:"desktopId" binding:"required,max=100"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var allowed bool
|
||||||
|
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d model.Desktop
|
||||||
|
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.PowerState != "running" || d.Endpoint == "" {
|
||||||
|
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := store.ID("ticket_")
|
||||||
|
expires := time.Now().Add(45 * time.Second)
|
||||||
|
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
|
||||||
|
}
|
||||||
|
func (s *Server) consumeTicket(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Ticket string `json:"ticket" binding:"required,max=200"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var row struct {
|
||||||
|
TokenHash string `db:"token_hash"`
|
||||||
|
TerminalID string `db:"terminal_id"`
|
||||||
|
DesktopID string `db:"desktop_id"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &row, `SELECT token_hash,terminal_id,desktop_id FROM connection_tickets WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, store.Hash(in.Ticket))
|
||||||
|
if err != nil || subtle.ConstantTimeCompare([]byte(row.TerminalID), []byte(c.GetString("terminalId"))) != 1 {
|
||||||
|
s.fail(c, 401, "invalid_ticket", "票据已失效或不属于当前终端")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d model.Desktop
|
||||||
|
if err = tx.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", row.DesktopID); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var allowed bool
|
||||||
|
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM users u JOIN bindings b ON b.user_id=u.id WHERE u.terminal_id=$1 AND u.enabled AND b.desktop_id=$2)`, row.TerminalID, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.PowerState != "running" || d.Endpoint == "" {
|
||||||
|
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var busy bool
|
||||||
|
if err = tx.GetContext(ctx, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND id!=$2 AND last_seen>now()-interval '90 seconds')`, d.ID, row.TerminalID); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if busy {
|
||||||
|
s.fail(c, 409, "desktop_busy", "桌面已被其他终端占用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := store.ID("session_")
|
||||||
|
_, err = tx.ExecContext(ctx, "UPDATE connection_tickets SET consumed_at=now() WHERE token_hash=$1", row.TokenHash)
|
||||||
|
if err == nil {
|
||||||
|
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = tx.Commit()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deviceSession(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
SessionID string `json:"sessionId" binding:"required,max=100"`
|
||||||
|
State string `json:"state" binding:"required,oneof=connected ended"`
|
||||||
|
}
|
||||||
|
if !s.decode(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := "UPDATE terminals SET session_state='connected',last_seen=now() WHERE id=$1 AND session_id=$2 AND session_desktop_id IS NOT NULL"
|
||||||
|
if in.State == "ended" {
|
||||||
|
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
|
||||||
|
}
|
||||||
|
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := result.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) taskDiagnostics(c *gin.Context) {
|
||||||
|
var data []byte
|
||||||
|
if err := s.DB.GetContext(c.Request.Context(), &data, "SELECT content FROM task_diagnostics WHERE task_id=$1", c.Param("id")); err != nil {
|
||||||
|
s.dbError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log(c, "read_diagnostics", c.Param("id"), "success")
|
||||||
|
c.Data(200, "application/json", data)
|
||||||
|
}
|
||||||
59
platform/internal/store/schema.sql
Normal file
59
platform/internal/store/schema.sql
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS platform_metadata (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), mode text NOT NULL CHECK(mode IN ('demo','production'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS administrators (
|
||||||
|
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
|
||||||
|
role text NOT NULL CHECK (role IN ('super_admin','operator','viewer')), password_hash text NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
id text PRIMARY KEY, admin_id text NOT NULL REFERENCES administrators(id), expires_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS desktops (
|
||||||
|
id text PRIMARY KEY, name text NOT NULL, os text NOT NULL, node text NOT NULL, vmid integer NOT NULL,
|
||||||
|
pool text NOT NULL DEFAULT '', power_state text NOT NULL, service_state text NOT NULL DEFAULT 'unknown',
|
||||||
|
provider text NOT NULL CHECK (provider IN ('demo','proxmox')), endpoint text NOT NULL DEFAULT '', app text NOT NULL DEFAULT 'Desktop',
|
||||||
|
width integer NOT NULL DEFAULT 2560, height integer NOT NULL DEFAULT 1440, fps integer NOT NULL DEFAULT 30,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider,node,vmid)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS terminals (
|
||||||
|
id text PRIMARY KEY, sn text NOT NULL UNIQUE, name text NOT NULL, group_name text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'unregistered', agent_version text NOT NULL DEFAULT '', last_seen timestamptz,
|
||||||
|
session_desktop_id text REFERENCES desktops(id), config_version integer NOT NULL DEFAULT 1,
|
||||||
|
bootstrap_hash text NOT NULL, token_hash text UNIQUE, fps double precision, rtt double precision
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true, terminal_id text UNIQUE REFERENCES terminals(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS bindings (
|
||||||
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
desktop_id text NOT NULL REFERENCES desktops(id), PRIMARY KEY(user_id,desktop_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resources (
|
||||||
|
id text PRIMARY KEY, name text NOT NULL, status text NOT NULL, cpu double precision NOT NULL,
|
||||||
|
memory double precision NOT NULL, storage double precision NOT NULL, provider text NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id text PRIMARY KEY, kind text NOT NULL, action text NOT NULL, target_id text NOT NULL, target_name text NOT NULL,
|
||||||
|
actor text NOT NULL, status text NOT NULL, message text NOT NULL DEFAULT '', provider_ref text NOT NULL DEFAULT '',
|
||||||
|
idempotency_key text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(actor,idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tasks_active_target ON tasks(kind,target_id) WHERE status IN ('queued','running');
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id text PRIMARY KEY, actor text NOT NULL, action text NOT NULL, target text NOT NULL, result text NOT NULL,
|
||||||
|
request_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS connection_tickets (
|
||||||
|
token_hash text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id), desktop_id text NOT NULL REFERENCES desktops(id),
|
||||||
|
expires_at timestamptz NOT NULL, consumed_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_created ON audit_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created_at DESC);
|
||||||
|
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_id text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_state text NOT NULL DEFAULT 'idle';
|
||||||
|
CREATE TABLE IF NOT EXISTS task_diagnostics (
|
||||||
|
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
119
platform/internal/store/store.go
Normal file
119
platform/internal/store/store.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"time"
|
||||||
|
"vdi-cloud/platform/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var schema string
|
||||||
|
|
||||||
|
func Open(ctx context.Context, url string) (*sqlx.DB, error) {
|
||||||
|
db, err := sqlx.ConnectContext(ctx, "pgx", url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(12)
|
||||||
|
db.SetMaxIdleConns(4)
|
||||||
|
db.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
if _, err = db.ExecContext(ctx, schema); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
func ID(prefix string) string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return prefix + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
func Hash(secret string) string { h := sha256.Sum256([]byte(secret)); return hex.EncodeToString(h[:]) }
|
||||||
|
func Bootstrap(ctx context.Context, db *sqlx.DB, mode, username, password string) error {
|
||||||
|
if _, err := db.ExecContext(ctx, "INSERT INTO platform_metadata(singleton,mode) VALUES(true,$1) ON CONFLICT DO NOTHING", mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var existingMode string
|
||||||
|
if err := db.GetContext(ctx, &existingMode, "SELECT mode FROM platform_metadata WHERE singleton=true"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existingMode != mode {
|
||||||
|
return fmt.Errorf("database mode is %s; use a separate database for %s", existingMode, mode)
|
||||||
|
}
|
||||||
|
admins := []struct{ username, name, role, password string }{{username, "平台管理员", "super_admin", password}}
|
||||||
|
if mode == "demo" {
|
||||||
|
admins = []struct{ username, name, role, password string }{{"admin", "系统管理员", "super_admin", "Demo@2026"}, {"operator", "运维人员", "operator", "Demo@2026"}, {"viewer", "只读人员", "viewer", "Demo@2026"}}
|
||||||
|
}
|
||||||
|
for _, a := range admins {
|
||||||
|
var exists bool
|
||||||
|
if err := db.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM administrators WHERE username=$1)", a.username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mode == "production" && len(a.password) < 12 {
|
||||||
|
return fmt.Errorf("initial administrator password must contain at least 12 characters")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(a.password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = db.ExecContext(ctx, "INSERT INTO administrators VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode != "demo" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Seed the demo only once; restarting must not restore revoked bindings.
|
||||||
|
var seeded bool
|
||||||
|
if err := db.GetContext(ctx, &seeded, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='demo')"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if seeded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
statements := []string{
|
||||||
|
`INSERT INTO desktops(id,name,os,node,vmid,pool,power_state,service_state,provider) VALUES ('desk_demo_windows','设计专属桌面','Windows','demo-node-a',201,'设计专属池','running','unknown','demo'),('desk_demo_ubuntu','研发办公桌面','Ubuntu','demo-node-a',202,'研发专属池','running','unknown','demo'),('desk_demo_kylin','国产办公桌面','麒麟','demo-node-a',203,'通用办公池','stopped','unknown','demo') ON CONFLICT DO NOTHING`,
|
||||||
|
`INSERT INTO terminals(id,sn,name,group_name,status,agent_version,last_seen,bootstrap_hash) VALUES ('term_demo_001','DEMO-RK-001','设计工位 01','设计部','offline','演示版本',now()-interval '2 hours','disabled'),('term_demo_002','DEMO-RK-002','研发工位 02','研发部','offline','演示版本',now()-interval '3 hours','disabled') ON CONFLICT DO NOTHING`,
|
||||||
|
`INSERT INTO users(id,username,display_name,terminal_id) VALUES ('user_demo_wang','wang.demo','王工','term_demo_001'),('user_demo_chen','chen.demo','陈工','term_demo_002') ON CONFLICT DO NOTHING`,
|
||||||
|
`INSERT INTO bindings VALUES ('user_demo_wang','desk_demo_windows'),('user_demo_wang','desk_demo_ubuntu'),('user_demo_chen','desk_demo_ubuntu') ON CONFLICT DO NOTHING`,
|
||||||
|
`INSERT INTO resources VALUES ('demo-node-a','demo-node-a','online',0.24,0.48,0.35,'demo',now()) ON CONFLICT DO NOTHING`,
|
||||||
|
}
|
||||||
|
for _, q := range statements {
|
||||||
|
if _, err = tx.ExecContext(ctx, q); err != nil {
|
||||||
|
return fmt.Errorf("demo seed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Users(ctx context.Context, db *sqlx.DB) ([]model.User, error) {
|
||||||
|
users := []model.User{}
|
||||||
|
if err := db.SelectContext(ctx, &users, "SELECT * FROM users ORDER BY username"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range users {
|
||||||
|
users[i].DesktopIDs = []string{}
|
||||||
|
if err := db.SelectContext(ctx, &users[i].DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", users[i].ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
146
platform/pkg/proxmox/client.go
Normal file
146
platform/pkg/proxmox/client.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL, TokenID, TokenSecret string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
type Resource struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
CPU float64 `json:"cpu"`
|
||||||
|
Memory float64 `json:"mem"`
|
||||||
|
MaxMemory float64 `json:"maxmem"`
|
||||||
|
Disk float64 `json:"disk"`
|
||||||
|
MaxDisk float64 `json:"maxdisk"`
|
||||||
|
Template int `json:"template"`
|
||||||
|
}
|
||||||
|
type TaskStatus struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitStatus string `json:"exitstatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrTaskFailed = errors.New("PVE task failed; inspect task logs in PVE")
|
||||||
|
|
||||||
|
type HTTPError struct{ Status int }
|
||||||
|
|
||||||
|
func (e *HTTPError) Error() string { return fmt.Sprintf("PVE returned HTTP %d", e.Status) }
|
||||||
|
|
||||||
|
func New(base, id, secret, caFile string) (*Client, error) {
|
||||||
|
u, err := url.Parse(base)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return nil, errors.New("PVE_URL must be an HTTPS origin without credentials or query")
|
||||||
|
}
|
||||||
|
if u.Path != "" && u.Path != "/" {
|
||||||
|
return nil, errors.New("PVE_URL must not include a path")
|
||||||
|
}
|
||||||
|
pool, err := x509.SystemCertPool()
|
||||||
|
if err != nil {
|
||||||
|
pool = x509.NewCertPool()
|
||||||
|
}
|
||||||
|
if caFile != "" {
|
||||||
|
data, err := os.ReadFile(caFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !pool.AppendCertsFromPEM(data) {
|
||||||
|
return nil, errors.New("invalid PVE CA file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Client{BaseURL: strings.TrimRight(base, "/"), TokenID: id, TokenSecret: secret, HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
|
||||||
|
}
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, form url.Values, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if form != nil {
|
||||||
|
body = strings.NewReader(form.Encode())
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api2/json"+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "PVEAPIToken="+c.TokenID+"="+c.TokenSecret)
|
||||||
|
if form != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
res, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("PVE request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return &HTTPError{Status: res.StatusCode}
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&envelope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(envelope.Data, out)
|
||||||
|
}
|
||||||
|
func (c *Client) Resources(ctx context.Context) ([]Resource, error) {
|
||||||
|
var list []Resource
|
||||||
|
err := c.request(ctx, "GET", "/cluster/resources", nil, &list)
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
func (c *Client) Action(ctx context.Context, node string, vmid int, action string) (string, error) {
|
||||||
|
if action != "start" && action != "shutdown" && action != "reboot" {
|
||||||
|
return "", errors.New("unsupported VM action")
|
||||||
|
}
|
||||||
|
var upid string
|
||||||
|
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", url.PathEscape(node), vmid, action), url.Values{}, &upid)
|
||||||
|
if err == nil && upid == "" {
|
||||||
|
err = errors.New("PVE did not return a task ID")
|
||||||
|
}
|
||||||
|
return upid, err
|
||||||
|
}
|
||||||
|
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
|
||||||
|
var status TaskStatus
|
||||||
|
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status)
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
|
||||||
|
var status struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status)
|
||||||
|
return status.Status, err
|
||||||
|
}
|
||||||
|
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
s, err := c.Task(ctx, node, upid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Status == "stopped" {
|
||||||
|
if s.ExitStatus != "OK" {
|
||||||
|
return ErrTaskFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
platform/pkg/proxmox/client_test.go
Normal file
77
platform/pkg/proxmox/client_test.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTLSAndTaskLifecycle(t *testing.T) {
|
||||||
|
polls := 0
|
||||||
|
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "PVEAPIToken=vdi@pve!console=secret" {
|
||||||
|
t.Error("missing PVE token")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/status/start"):
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Error("action should POST")
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"data":"UPID:node:123"}`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/status/current"):
|
||||||
|
w.Write([]byte(`{"data":{"status":"running"}}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/tasks/"):
|
||||||
|
polls++
|
||||||
|
if polls < 2 {
|
||||||
|
w.Write([]byte(`{"data":{"status":"running"}}`))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
c, err := New(ts.URL, "vdi@pve!console", "secret", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = c.Action(context.Background(), "node", 201, "start"); err == nil {
|
||||||
|
t.Fatal("untrusted PVE certificate must not be accepted")
|
||||||
|
}
|
||||||
|
c.HTTP = ts.Client()
|
||||||
|
upid, err := c.Action(context.Background(), "node", 201, "start")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = c.Wait(context.Background(), "node", upid, time.Millisecond); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if polls != 2 {
|
||||||
|
t.Fatalf("expected task polling; got %d", polls)
|
||||||
|
}
|
||||||
|
if _, err = c.Action(context.Background(), "node", 201, "restore"); err == nil {
|
||||||
|
t.Fatal("restore must never map to hard reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestFailedPVEJobIsNotSuccess(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
|
||||||
|
if err := c.Wait(context.Background(), "node", "task", time.Millisecond); err == nil {
|
||||||
|
t.Fatal("failed PVE job reported as success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRejectInsecureOrigin(t *testing.T) {
|
||||||
|
for _, base := range []string{"http://pve:8006", "https://user:password@pve:8006", "https://pve:8006/other"} {
|
||||||
|
if _, err := New(base, "id", "secret", ""); err == nil {
|
||||||
|
t.Fatalf("accepted %s", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tools/generate-platform-openapi.mjs
Normal file
98
tools/generate-platform-openapi.mjs
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
const string = { type: 'string' }, integer = { type: 'integer' }, number = { type: 'number' }, boolean = { type: 'boolean' }
|
||||||
|
const date = { type: 'string', format: 'date-time' }
|
||||||
|
const ref = name => ({ $ref: `#/components/schemas/${name}` })
|
||||||
|
const array = items => ({ type: 'array', items })
|
||||||
|
const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false })
|
||||||
|
const schemas = {
|
||||||
|
Error: object({ code: string, message: string, requestId: string }),
|
||||||
|
Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }),
|
||||||
|
LoginInput: object({ username: string, password: string }),
|
||||||
|
LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }),
|
||||||
|
Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }),
|
||||||
|
Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }),
|
||||||
|
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
|
||||||
|
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
|
||||||
|
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
|
||||||
|
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
|
||||||
|
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
|
||||||
|
Settings: object({ mode: { type: 'string', enum: ['demo', 'production'] }, computeProvider: string, pveConfigured: boolean, terminalTransport: string, capabilities: array(string) }),
|
||||||
|
ImportInput: object({ name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, endpoint: string, width: integer, height: integer, fps: integer }, ['name', 'os', 'node', 'vmid']),
|
||||||
|
DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }),
|
||||||
|
EnrollmentInput: object({ sn: string, name: string, group: string }),
|
||||||
|
Enrollment: object({ id: string, activationSecret: string }),
|
||||||
|
CreateUserInput: object({ username: string, displayName: string }),
|
||||||
|
BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }),
|
||||||
|
DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }),
|
||||||
|
TerminalAction: object({ action: { type: 'string', enum: ['reboot', 'reset-session', 'collect-logs'] } }),
|
||||||
|
RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }),
|
||||||
|
RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }),
|
||||||
|
HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']),
|
||||||
|
Accepted: object({ accepted: boolean }),
|
||||||
|
HeartbeatResult: object({ accepted: boolean, serverTime: date }),
|
||||||
|
DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }),
|
||||||
|
TicketInput: object({ desktopId: string }),
|
||||||
|
Ticket: object({ ticket: string, desktopId: string, expiresAt: date, credentialMode: string }),
|
||||||
|
ConsumeInput: object({ ticket: string }),
|
||||||
|
Connection: object({ desktopId: string, sessionId: string, protocol: string, endpoint: string, app: string, width: integer, height: integer, fps: integer, credentialMode: string }),
|
||||||
|
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
|
||||||
|
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
|
||||||
|
AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']),
|
||||||
|
}
|
||||||
|
// Older frontends can omit this display field; the API always supplies it.
|
||||||
|
schemas.Terminal.properties.sessionState = string
|
||||||
|
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) {
|
||||||
|
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
|
||||||
|
}
|
||||||
|
const json = schema => ({ 'application/json': { schema } })
|
||||||
|
const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({
|
||||||
|
operationId: id,
|
||||||
|
security: security ? [{ [security]: [] }] : [],
|
||||||
|
...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}),
|
||||||
|
parameters: [
|
||||||
|
...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []),
|
||||||
|
...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []),
|
||||||
|
...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
[status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) },
|
||||||
|
...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const paths = {
|
||||||
|
'/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) },
|
||||||
|
'/auth/me': { get: operation('me', 'Administrator') },
|
||||||
|
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
|
||||||
|
'/overview': { get: operation('overview', 'Overview') },
|
||||||
|
'/settings': { get: operation('settings', 'Settings') },
|
||||||
|
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
|
||||||
|
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
|
||||||
|
'/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) },
|
||||||
|
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
|
||||||
|
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
|
||||||
|
'/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) },
|
||||||
|
'/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) },
|
||||||
|
'/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) },
|
||||||
|
'/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) },
|
||||||
|
'/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) },
|
||||||
|
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
|
||||||
|
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
|
||||||
|
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
|
||||||
|
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
|
||||||
|
'/audit-logs': { get: operation('audits', 'AuditList', { list: true }) },
|
||||||
|
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
|
||||||
|
'/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) },
|
||||||
|
'/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) },
|
||||||
|
'/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) },
|
||||||
|
'/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) },
|
||||||
|
'/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) },
|
||||||
|
'/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) },
|
||||||
|
'/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) },
|
||||||
|
'/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) },
|
||||||
|
}
|
||||||
|
const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } }
|
||||||
|
const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
|
||||||
|
mkdirSync(directory, { recursive: true })
|
||||||
|
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)
|
||||||
|
console.log('Updated docs/openapi/platform.v1.json')
|
||||||
53
tools/platform-terminal-simulator.py
Normal file
53
tools/platform-terminal-simulator.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""Exercise device registration/heartbeat/command receipts without controlling real hardware."""
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--server', default='http://127.0.0.1:8088')
|
||||||
|
parser.add_argument('--sn', required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
server = args.server.rstrip('/')
|
||||||
|
token = ''
|
||||||
|
|
||||||
|
def request(path, body=None):
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = 'Bearer ' + token
|
||||||
|
req = Request(server + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=15) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except HTTPError as error:
|
||||||
|
detail = json.load(error)
|
||||||
|
raise RuntimeError(detail.get('message', 'API request failed')) from None
|
||||||
|
|
||||||
|
if request('/healthz').get('mode') != 'demo':
|
||||||
|
raise SystemExit('This simulator only runs against VDI_MODE=demo.')
|
||||||
|
secret = os.environ.get('VDI_DEVICE_ACTIVATION_SECRET') or getpass.getpass('One-time activation secret: ')
|
||||||
|
result = request('/api/v1/terminal/register', {'sn': args.sn, 'activationSecret': secret, 'agentVersion': 'simulator-0.1'})
|
||||||
|
token = result['accessToken']
|
||||||
|
print('Registered terminal:', result['terminalId'])
|
||||||
|
print('Simulator sends heartbeat only; all requested device operations return failed.')
|
||||||
|
print('Press Ctrl+C to stop. No reboot, session change, or log collection is performed.')
|
||||||
|
while True:
|
||||||
|
request('/api/v1/terminal/heartbeat', {'agentVersion': 'simulator-0.1', 'fps': None, 'rtt': None})
|
||||||
|
desktops = request('/api/v1/terminal/desktops')['items']
|
||||||
|
print('Authorized desktops:', ', '.join(d['name'] for d in desktops) or '(none)')
|
||||||
|
for command in request('/api/v1/terminal/commands')['items']:
|
||||||
|
request('/api/v1/terminal/commands/' + command['id'] + '/ack', {'status': 'failed', 'message': 'Device simulator does not execute operations'})
|
||||||
|
print('Returned failed receipt for:', command['action'], command['id'])
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\nSimulator stopped.')
|
||||||
43
tools/prepare-live-config.py
Normal file
43
tools/prepare-live-config.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Generate private local configuration and a CA for the device HTTPS channel."""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import secrets
|
||||||
|
import os
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument('--ip', required=True)
|
||||||
|
p.add_argument('--pve-hostname', required=True, help='DNS name present in the PVE certificate')
|
||||||
|
p.add_argument('--pve-ip', required=True)
|
||||||
|
args = p.parse_args()
|
||||||
|
ip = ipaddress.ip_address(args.ip)
|
||||||
|
root = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
target = root / '.local/live'
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
if (target / '.env').exists():
|
||||||
|
raise RuntimeError('Existing configuration retained; edit it deliberately instead of regenerating secrets')
|
||||||
|
access = json.loads((target / 'pve-access.json').read_text())
|
||||||
|
access['PVE_URL']=f'https://{args.pve_hostname}:8006'
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'VDI local device CA')])
|
||||||
|
ca = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=3650)).add_extension(x509.BasicConstraints(ca=True,path_length=0),critical=True).add_extension(x509.KeyUsage(True,False,False,False,False,True,True,False,False),critical=True).sign(key,hashes.SHA256())
|
||||||
|
server_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||||
|
server = x509.CertificateBuilder().subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,str(ip))])).issuer_name(name).public_key(server_key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=365)).add_extension(x509.BasicConstraints(ca=False,path_length=None),critical=True).add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip),x509.DNSName('localhost')]),critical=False).add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),critical=False).sign(key,hashes.SHA256())
|
||||||
|
tls = target / 'tls'; tls.mkdir(exist_ok=True)
|
||||||
|
def save_private(path, data):
|
||||||
|
path.write_bytes(data); os.chmod(path, 0o600)
|
||||||
|
for name, obj in [('ca',key),('server',server_key)]:
|
||||||
|
save_private(tls/f'{name}-key.pem', obj.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||||
|
(tls/'ca.pem').write_bytes(ca.public_bytes(serialization.Encoding.PEM))
|
||||||
|
(tls/'server.pem').write_bytes(server.public_bytes(serialization.Encoding.PEM)+ca.public_bytes(serialization.Encoding.PEM))
|
||||||
|
env = dict(VDI_DB_PASSWORD=secrets.token_hex(24),VDI_JWT_SECRET=secrets.token_hex(48),VDI_ADMIN_USERNAME='admin',VDI_ADMIN_PASSWORD=secrets.token_urlsafe(24),VDI_LIVE_BIND_IP=str(ip),PVE_HOSTNAME=args.pve_hostname,PVE_HOST_IP=str(ipaddress.ip_address(args.pve_ip)),**access)
|
||||||
|
save_private(target/'.env', ''.join(f'{k}={v}\n' for k,v in env.items()).encode())
|
||||||
|
save_private(target/'admin-login.txt', f'Console: http://127.0.0.1:5182\nUsername: {env["VDI_ADMIN_USERNAME"]}\nPassword: {env["VDI_ADMIN_PASSWORD"]}\nDevice HTTPS: https://{ip}:8443\n'.encode())
|
||||||
|
print('Private environment, admin login, CA and HTTPS certificate generated in .local/live')
|
||||||
62
tools/provision-pve-access.py
Normal file
62
tools/provision-pve-access.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Create scoped VDI API access through a known-host-verified SSH connection.
|
||||||
|
|
||||||
|
Requires paramiko. Password is prompted or read from VDI_PVE_SSH_PASSWORD.
|
||||||
|
The one-time API secret is written only to the requested private output file.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shlex
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument('--host', required=True)
|
||||||
|
p.add_argument('--vmids', nargs='+', type=int, required=True)
|
||||||
|
p.add_argument('--output', required=True)
|
||||||
|
args = p.parse_args()
|
||||||
|
target = pathlib.Path(args.output)
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError('Output already exists; reuse the existing token rather than replace it')
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.load_system_host_keys(str(pathlib.Path.home() / '.ssh/known_hosts'))
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||||
|
password = os.environ.get('VDI_PVE_SSH_PASSWORD') or getpass.getpass('PVE SSH password: ')
|
||||||
|
ssh.connect(args.host, username='root', password=password, look_for_keys=False, allow_agent=False, timeout=10)
|
||||||
|
def run(*parts):
|
||||||
|
_, out, err = ssh.exec_command(shlex.join(parts), timeout=30)
|
||||||
|
body = out.read().decode()
|
||||||
|
if out.channel.recv_exit_status() != 0:
|
||||||
|
raise RuntimeError(err.read().decode())
|
||||||
|
return body
|
||||||
|
try:
|
||||||
|
user, role, token = 'vdi-cloud@pve', 'VDICloudPower', 'console'
|
||||||
|
users = json.loads(run('pveum', 'user', 'list', '--output-format', 'json'))
|
||||||
|
if any(u['userid'] == user for u in users):
|
||||||
|
raise RuntimeError('VDI account already exists. Inspect it and recover the existing token first')
|
||||||
|
roles = json.loads(run('pveum', 'role', 'list', '--output-format', 'json'))
|
||||||
|
if not any(r['roleid'] == role for r in roles):
|
||||||
|
run('pveum', 'role', 'add', role, '--privs', 'VM.Audit VM.PowerMgmt')
|
||||||
|
elif set(next(r for r in roles if r['roleid'] == role)['privs'].split(',')) != {'VM.Audit','VM.PowerMgmt'}:
|
||||||
|
raise RuntimeError('Existing VDICloudPower role has unexpected privileges; inspect it first')
|
||||||
|
run('pveum', 'user', 'add', user, '--comment', 'VDI cloud API; scoped power management')
|
||||||
|
run('pveum', 'acl', 'modify', '/', '--users', user, '--roles', 'PVEAuditor')
|
||||||
|
for vmid in args.vmids:
|
||||||
|
run('pveum', 'acl', 'modify', f'/vms/{vmid}', '--users', user, '--roles', role)
|
||||||
|
result = json.loads(run('pveum', 'user', 'token', 'add', user, token, '--privsep', '0', '--output-format', 'json'))
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with target.open('x', encoding='utf-8') as f:
|
||||||
|
json.dump({'PVE_URL': f'https://{args.host}:8006', 'PVE_TOKEN_ID': result['full-tokenid'], 'PVE_TOKEN_SECRET': result['value']}, f)
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
with ssh.open_sftp() as sftp:
|
||||||
|
sftp.get('/etc/pve/pve-root-ca.pem', str(target.parent / 'pve-ca.pem'))
|
||||||
|
print(f'Created dedicated account and VM power scopes {args.vmids}; credentials saved privately')
|
||||||
|
finally:
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
42
tools/start-cloud-dev.ps1
Normal file
42
tools/start-cloud-dev.ps1
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
param([switch]$SkipInstall, [int]$ApiPort=8088, [int]$ConsolePort=5180)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$vdiWorkspace = Split-Path -Parent $PSScriptRoot
|
||||||
|
$vdiLocalDir = Join-Path $vdiWorkspace '.local'
|
||||||
|
New-Item -ItemType Directory -Force -Path $vdiLocalDir | Out-Null
|
||||||
|
$vdiGoCommand = Get-Command go -ErrorAction SilentlyContinue
|
||||||
|
$vdiGoPath = if ($vdiGoCommand) { $vdiGoCommand.Source } else { Join-Path $env:LOCALAPPDATA 'vdi-cloud-dev\go\bin\go.exe' }
|
||||||
|
if (!(Test-Path -LiteralPath $vdiGoPath)) { throw 'Go 1.25+ is required. Install Go and add it to PATH.' }
|
||||||
|
foreach ($vdiPort in @($ApiPort,$ConsolePort)) {
|
||||||
|
if (Get-NetTCPConnection -State Listen -LocalPort $vdiPort -ErrorAction SilentlyContinue) { throw "Port $vdiPort is already in use. Stop the existing development instance before starting another." }
|
||||||
|
}
|
||||||
|
Push-Location $vdiWorkspace
|
||||||
|
try {
|
||||||
|
docker compose -f deploy/compose.dev.yml up -d postgres
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'PostgreSQL startup failed' }
|
||||||
|
$vdiDatabaseReady = $false
|
||||||
|
for ($vdiAttempt = 0; $vdiAttempt -lt 40; $vdiAttempt++) {
|
||||||
|
docker exec vdi-cloud-dev-postgres-1 pg_isready -U vdi -d vdi 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) { $vdiDatabaseReady = $true; break }
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
if (!$vdiDatabaseReady) { throw 'PostgreSQL did not become ready' }
|
||||||
|
Push-Location platform
|
||||||
|
try { & $vdiGoPath build -o (Join-Path $vdiLocalDir 'vdi-api.exe') ./cmd/api-gateway; if ($LASTEXITCODE -ne 0) { throw 'API build failed' } } finally { Pop-Location }
|
||||||
|
if (!$SkipInstall) { Push-Location console; try { npm.cmd ci; if ($LASTEXITCODE -ne 0) { throw 'Console install failed' } } finally { Pop-Location } }
|
||||||
|
$vdiEnvironment = @{
|
||||||
|
VDI_MODE = 'demo'; VDI_LISTEN = "127.0.0.1:$ApiPort";
|
||||||
|
DATABASE_URL = 'postgres://vdi:vdi-local-demo@127.0.0.1:55438/vdi?sslmode=disable';
|
||||||
|
VDI_JWT_SECRET = 'local-demo-only-change-before-any-production-use-2026';
|
||||||
|
PVE_URL = ''; PVE_TOKEN_ID = ''; PVE_TOKEN_SECRET = ''; PVE_CA_FILE = ''; VITE_API_MODE = 'api'; VDI_API_PROXY = "http://127.0.0.1:$ApiPort"
|
||||||
|
}
|
||||||
|
$vdiOriginalEnvironment = @{}
|
||||||
|
try {
|
||||||
|
foreach ($vdiKey in $vdiEnvironment.Keys) { $vdiOriginalEnvironment[$vdiKey] = [Environment]::GetEnvironmentVariable($vdiKey,'Process'); [Environment]::SetEnvironmentVariable($vdiKey,$vdiEnvironment[$vdiKey],'Process') }
|
||||||
|
$vdiAPIProcess = Start-Process -FilePath (Join-Path $vdiLocalDir 'vdi-api.exe') -WorkingDirectory $vdiWorkspace -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'api.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'api.stderr.log') -PassThru
|
||||||
|
$vdiConsoleProcess = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d','/c',"npm.cmd run dev -- --port $ConsolePort" -WorkingDirectory (Join-Path $vdiWorkspace 'console') -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'console.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'console.stderr.log') -PassThru
|
||||||
|
@{ apiPid = $vdiAPIProcess.Id; consolePid = $vdiConsoleProcess.Id; startedAt = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content (Join-Path $vdiLocalDir 'cloud-dev.json')
|
||||||
|
} finally { foreach ($vdiKey in $vdiOriginalEnvironment.Keys) { [Environment]::SetEnvironmentVariable($vdiKey,$vdiOriginalEnvironment[$vdiKey],'Process') } }
|
||||||
|
Write-Host "Console: http://127.0.0.1:$ConsolePort"
|
||||||
|
Write-Host 'Demo users: admin / operator / viewer; password: Demo@2026'
|
||||||
|
Write-Host 'Development process IDs and logs: .local/'
|
||||||
|
} finally { Pop-Location }
|
||||||
13
tools/start-cloud-live.ps1
Normal file
13
tools/start-cloud-live.ps1
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
param([switch]$Build)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$vdiRoot=Split-Path -Parent $PSScriptRoot
|
||||||
|
Push-Location $vdiRoot
|
||||||
|
try {
|
||||||
|
if (!(Test-Path -LiteralPath '.local/live/.env')) { throw 'Configure PVE access and run tools/prepare-live-config.py first. See docs/cloud-live-integration.md.' }
|
||||||
|
$vdiArgs=@('compose','--env-file','.local/live/.env','-f','deploy/compose.live.yml','up','-d')
|
||||||
|
if ($Build) { $vdiArgs+='--build' }
|
||||||
|
& docker @vdiArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Live platform startup failed' }
|
||||||
|
Write-Host 'Real platform: http://127.0.0.1:5182'
|
||||||
|
Write-Host 'Private initial administrator credentials: .local/live/admin-login.txt'
|
||||||
|
} finally { Pop-Location }
|
||||||
Loading…
Reference in New Issue
Block a user