Compare commits
3 Commits
8276da0d45
...
d87fa47e24
| Author | SHA1 | Date | |
|---|---|---|---|
| d87fa47e24 | |||
| 3fb7b4da8c | |||
| 18a9ce995d |
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
@ -7,7 +7,19 @@ CMakeCache.txt
|
||||
*.tmp
|
||||
.zcode/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
~$*
|
||||
test-output/
|
||||
screenshots/
|
||||
.DS_Store
|
||||
.playwright-cli/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
playwright-report/
|
||||
coverage/
|
||||
test-results/
|
||||
.local/
|
||||
*.exe
|
||||
|
||||
276
MEMORY.md
@ -1,273 +1,11 @@
|
||||
# 继续开发前必读
|
||||
|
||||
## 2026-11-05 Ubuntu 深度响应调优(最新)
|
||||
- Ubuntu VM102 已在正常会话中启用 KMS 捕获 + 原生独立光标:virtio_gpu atomic 唯一 primary plane,2K geometry,cursor hotspot plane 不暴露;盒子客户端 SHA 未变。KMS 守护按 `CRTC_W/H=2560x1440` 校验,不能依赖 virtio atomic 客户端的 legacy `crtc_id/fb_id=0`。
|
||||
- KMS 45FPS 与 X11 45FPS 长按框选回显 P50/P95=195.9/246.6ms vs 250.1/311.5ms;更新约16.0/s vs10.6/s。28次原生光标点击、7种形状/热点/隐显通过,像素差0;正常退出、空闲重连、guard强杀恢复、再次重连通过。证据 `docs/evidence/ubuntu-deep-latency-20261105/`,报告 `docs/ubuntu-deep-latency-20261105.md`。
|
||||
- 真实GTK按钮/ masked entry 定量仅为当前应用内回显,不能代表真实后端认证;KMS按钮约304/378ms、字符约165/182ms。硬编和 `min_threads=4` 均失败/无收益并已撤回;Ubuntu仍软件x264,未达到Windows约90ms或零延迟。
|
||||
- 盒子 Ubuntu 卡片暂保持45FPS请求用于当前联调,2K/30Mbps;若收尾要回30FPS,先正常退出并改仅 Ubuntu 对象。当前现场应保持 `vdi-sunshine.service capture=kms` 与 guard active;检查时不要用 `fb_id/crtc_id` 作为活动判据。
|
||||
更新:2026-11-09。状态与计划统一维护在主题文档,本文件只保留导航,避免多处历史“最新”描述相互矛盾。
|
||||
|
||||
完整对话总结在:`D:\DAIMA\vdi-cloud\PROJECT_CONTEXT.md`
|
||||
1. 阅读[项目上下文](PROJECT_CONTEXT.md)与[当前状态](docs/current-status.md),再查看 `git status`,保留已有改动。
|
||||
2. 终端进出窗口、显示守护和当前客户端 SHA 见[终端说明](docs/terminal-guide.md);Linux 光标升级/回退必须匹配二进制与 profile SHA。
|
||||
3. Linux 硬编仍未打通;Intel 直通与显存 64MB 试验已回退。合成回显不等于物理端到端延迟,详见[云桌面说明](docs/desktop-guide.md)。
|
||||
4. 日常监控在独立 `D:\DAIMA\vdi-monitor`;开发证据、失败记录与回退仍保留在本仓库,见[运维说明](docs/operations.md)。
|
||||
5. VPN/Clash、软件白名单与品牌兜底画面只列[未来计划](docs/roadmap.md),目前不实施。
|
||||
|
||||
## 2026-11-05 Ubuntu框选/渲染后续(优先于旧VM102硬件描述)
|
||||
- 后续用户要求清理锁屏Sunshine卡片:已确认是Stream Started/Paused通知,非多份安装。Ubuntu sunshine.conf新增system_tray=disabled(同时移除托盘图标),正常退出/入口重连后新进程日志确认生效;仅当前4条通知均为Sunshine时,通过GNOME通知面板清除并确认0剩余。原配置备份~/vdi-terminal/backups/sunshine-notifications-20261105/sunshine.conf;不改全局通知或锁屏策略,串流/guard active,退回普通桌面。下方“Sunshine配置与原配置一致”仅指渲染调优结束时,现多此一项。
|
||||
- 用户要求先只处理Ubuntu,已明确允许正常关闭启动、A/B及必要回退。本轮VM102正常关闭启动3次,最终4核/6144MB、cpu=host、vga=virtio-gl;宿主Intel i915共享渲染,Windows/麒麟/独占NVIDIA配置未动。宿主仅补GL/EGL/Mesa依赖,0升级10新增0移除。
|
||||
- Xorg确认virgl Intel HD4600/glamor生效,x264确认AVX2/FMA3/BMI2。仍X11捕获/libx264软件编码,renderD128不代表硬件编码。最终2K30/30Mbps/limited、默认线程;45FPS及min_threads=4试验无明显延迟收益,已撤回。盒子desktops JSON结构与本轮备份完全相同,客户端SHA仍5d4330…。
|
||||
- 受控60Hz长按框选:基线回显P50/P95=398.3/634.6ms,最终224.4/308.3ms;独立编号更新2.0→12.4/s,不等于总视频FPS。有效轮次最终编号300及释放均见;中位改善约44%,短样本未随机重复,非USB/光子延迟,尚未达到Windows/原生目标。
|
||||
- 回归16编号点击+2快速点击,源18按/18放,最大坐标误差1.375px;2K及12灰阶核对通过。临时测试服务已停,最后留Ubuntu普通桌面,Sunshine/guard active。不要自动进一步改麒麟。
|
||||
- 备份PVE /root/vdi-ubuntu-render-20261105/102.conf.before,Ubuntu/盒子各自~/vdi-terminal/backups/ubuntu-render-20261105/。回退只还原cpu/删除vga字段,需正常关闭启动;别整份覆盖VM配置。
|
||||
- 报告docs/ubuntu-drag-render-20261105.md,原始证据docs/evidence/ubuntu-drag-20261105/。剩余瓶颈为捕获/GPU读回/软件编码;未部署硬编方案或更换协议。
|
||||
|
||||
## 2026-11-05 Linux原生独立光标(优先于下方旧样式/原型状态)
|
||||
- 用户要求保留原生样式并直接实施低延迟方案;Ubuntu恢复Adwaita/24、default加速,关闭动画保留。两Linux真实2560×1440/30FPS请求/30Mbps/limited/软件编码保留。
|
||||
- 已部署0005 + terminal/native-cursor:受限独立SSH密钥/固定主机公钥同步XFixes真实形状、热点、显隐,本地X11指针移动不等待视频。单座席X11/2K/绝对鼠标,最多128×128;状态2秒过期或源几何改变则结束串流恢复原服务,不做运行中帧交接。
|
||||
- 私有原版Sunshine精确SHA + 进程级adapter只清除捕获副本像素。Ubuntu原/usr/bin/sunshine的capabilities保留,租约内用同SHA无cap副本;麒麟仅host.py/私有sunshine/.so认证KYSEC。guard按需启动,ExecStopPost恢复;原服务永久文件不改。
|
||||
- 盒子最终客户端SHA 5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03,.71/.72 profile固定该值;helper在远端租约前核验,旧备份实测拒绝且原服务PID不变。修改二进制须停用profile并重新验证。备份~/vdi-terminal/backups/native-cursor-20261105/;回退先退出等恢复,再停用两JSON。
|
||||
- 最终版两系统七种光标形状/热点一致、静态像素最大差0、视频无混入指针;各28编号点击与源事件完全匹配,Ubuntu误差最大1.055px、麒麟0.829px。较早各16次40ms短按和双击补测通过;最终版暂停Moonlight指针仍移动,较早服务器主动warp(400,600)两台读回正确。
|
||||
- 最终页面视频回显Ubuntu P50/P95 188.38/522.42ms,麒麟134.37/165.56ms;不是本地指针或物理USB延迟,未定量证明Windows等效。Ubuntu两次强制暂停恢复后首击未回显,根因未定;完整SDL失焦未验收,不可称全部场景无丢输入。
|
||||
- agent/guard SIGKILL恢复、Ubuntu bridge冻结退出、麒麟真实分辨率变化退出及恢复2K重连均已测。4项单测和清洁patch应用通过。构建器会规范CRLF但严格上下文;现场本轮未重建部署其他任务0004,不能据构建器宣称现场包含0004。
|
||||
- 普通Ubuntu初次收尾约4分42秒触发stale/mismatched退出,发现校验时间在读取前取得的原子替换竞态,改为读后取时间。该轮未区分未来/实际过期,不能认定唯一根因。仅修时序版先6分钟通过,之后9分32秒真实heartbeat超时;最终状态等待上限改2秒、主机TTL4秒,增加X11读耗时诊断。已测暂停Ubuntu代理1.1秒时指针继续移动且会话存活。旧1da552…形状/点击证据保留;最终版持续运行另记。
|
||||
- Ubuntu原Sunshine在11:12:07恢复测试阶段停止超过10秒、看门狗SIGTRAP,11:12:14恢复;未修复上游停止看门狗问题,原crash报告保留且未外发。
|
||||
- 最终2秒策略Ubuntu6分钟/65960次状态读取存活,含1.1秒暂停;读前时间出现1次合法未来值、读后0次,证实竞态检查顺序需要修正。随后同会话七形状/28点击通过;bridge冻结2.6秒后2.904秒检查客户端退出,原服务恢复。详见ubuntu-final-soak/release-probe/final-stale-state等最终证据。
|
||||
- 最终新SHA的Windows2K45/RKMPP/EGL无独立光标env回归;麒麟2K30七形状/28点击及普通桌面重连后退出,原服务active且无临时drop-in。Ubuntu保留2K30/原生指针PID299860/父235482,guard按设计active,临时GTK源/盒子探针均已停。报告docs/linux-native-cursor-20261105.md,证据docs/evidence/native-cursor-20261105/。
|
||||
|
||||
## 2026-11-05 Ubuntu画质与鼠标样式调优
|
||||
- Ubuntu .72/test2 已由1080p升级真实2560×1440,盒子只改Ubuntu对象为2K30,自动请求30Mbps。原VM虚拟VGA在当前内核可直接切2K,未改PVE硬件或重启VM/GDM。
|
||||
- 用户服务启动前newmode/addmode/output创建2K;删模式后服务启动重建通过。GNOME monitors.xml保存单屏2K/100%缩放,最终锁屏/解锁仍2K;整机重启恢复未验收。DisplayConfig持久调用曾待确认后回退,已重新保存显示配置并重新核验最终尺寸。
|
||||
- DMZ-White/24px、flat鼠标加速、关闭GNOME动画;不是复制Windows整套主题,也没有叠加本地假箭头。无GPU render节点,仍X11/libx264 ultrafast/zerolatency默认线程。
|
||||
- .72 host-color-range=0,正常kiosk最终PID240476/父235482,2K30/30Mbps、RKMPP/EGL、服务端MPEG和真实源2K核对;最终保留Ubuntu普通桌面连接,临时GTK服务已停止。
|
||||
- 正常入口2K短测:45请求full21.49FPS/F9 P50 373.2ms;limited22.83/376.7ms;单线程+无动画17.98/402.9ms(单线程已撤回);最终30请求limited/默认线程/无动画21.40/320.9ms。F9每轮24翻转无超时,但一轮客体有额外F9事件且无序号,不能证明逐键延迟。四轮启动快速点击只记录24/24/23/21且单线程有坐标重复,不能说全部通过;补做稳定正常会话24次慢速交替点击24/24、误差<1px、F8收到,见final-input-check.json。源GTK本身约15–20绘制/秒,视频含重复帧;不能声称独立运动21–23帧、稳定30/45或物理延迟降低/Windows等效。
|
||||
- limited灰阶保持黑白层次,未显著截断;单像素/彩字仍受4:2:0限制。两端备份~/vdi-terminal/backups/ubuntu-quality-20261105/,Ubuntu monitors.xml原不存在;.72 range原不存在。原Sunshine配置/服务、gsettings原值均保留。测试仅补装python3-gi-cairo,无整体升级。
|
||||
- 文档docs/ubuntu-quality-20261105.md、证据docs/evidence/ubuntu-quality-20261105/。Windows/麒麟卡片结构比较未变,未改客户端二进制、全局渲染或USB任务修改。
|
||||
|
||||
## 2026-11-05 Windows固定PCI设备不再可弹出
|
||||
- 用户要求日常体验像普通电脑、托盘只留真实U盘,授权“你自己保存然后重启”。交互会话仅设置/系统窗口,无打开文档;未强制关闭应用。
|
||||
- 09:28正常shutdown.exe /s /t 0后冷启动VM100。原args保留GPU子厂商设置,追加三个global:ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off、pci-bridge.shpc=off、pcie-root-port.hotplug=off。PCI增删须关机,USB设备级热插拔保留;没改总线/设备地址/驱动,没有重启USB隧道。
|
||||
- 原托盘四项(GTX965M/VirtIO网卡/串口/附加AHCI)Capabilities从6变0,InstanceId相同,Status OK;GPU错误0,系统盘Online/Healthy,IP .70、Sunshine及Guest Agent正常。
|
||||
- PVE备份/工具/root/vdi-fixed-pci-20261105/(backup/plan.json、vm.conf.before),set-fixed-pci.py支持prepare/apply/restore且拒绝在线变更。4项单测和无磁盘/网络后端/直通GPU的隔离QEMU测试通过(PCI关闭、usb-redir添加/删除)。
|
||||
- 盒子无物理U盘,仅键鼠,USB服务active;Moonlight随关机退出,用户须重连Windows。托盘UIA未暴露传统图标子项,外观与真实U盘插拔待用户验收,不冒充目视完成。文档docs/fixed-pci-tray-20261105.md及evidence/fixed-pci-20261105。
|
||||
|
||||
## 2026-11-04 云桌面图形关机/重启按钮限制
|
||||
- 用户明确要求Windows/Ubuntu/麒麟桌面内图形关机、重启按钮也禁用,前面的快捷键补丁不足。本轮已通过PVE Guest Agent实际部署,不依赖未构建的Moonlight 0004。
|
||||
- Windows VM100/test HKU Explorer NoClose=1,交互用户任务广播Policy并SHRestricted(2)=1;无Explorer重启/注销/VM重启,任务已删除。首次原值备份C:\ProgramData\VDI\power-policy\original.clixml。
|
||||
- 麒麟VM101/test1使用polkit .pkla,Ubuntu VM102/test2使用.rules。两台均额外部署/etc/dbus-1/system.d/zz-vdi-desktop-power.conf,按客户UID拒绝login1电源方法和Can*查询,只ReloadConfig。root CanPowerOff仍yes。
|
||||
- 两台实际图形进程pkcheck基础reboot仍授权(内部原因未确定),所以不能只靠polkit宣称已屏蔽。D-Bus错误签名安全探测八项全部Access denied,七项Can*拒绝;并未发送有效关机请求。麒麟定位时仅重启过polkit一次,Sunshine保持active。
|
||||
- 三台running,Windows LastBoot及Linux boot ID与部署前一致;未逐项目视验收菜单外观、未重启验收。限制当前客户账户图形电源入口,不防管理员命令/改策略,不涉及注销/锁屏,不实现盒子开关联动VM生命周期。新用户需重新针对账户应用。
|
||||
- 工具tools/deploy-guest-power-policy.py apply/query/restore --vm 100或--vm 101 --user test1或--vm 102 --user test2;Linux首次备份/var/lib/vdi/power-policy。详见docs/guest-power-policy-20261104.md及evidence/guest-power-policy-20261104。
|
||||
|
||||
## 2026-11-04 盒子过夜重启观察(最新)
|
||||
- 用户要求今晚开机观察并记录重启/原因。19:35启用vdi-overnight-observer.service,active/enabled,每60秒fsync心跳、boot ID/uptime计数、温度/负载/内存/网络/进程/桌面几何;跨午夜分日志。当前计数0是启用后范围,不代表历史没重启。
|
||||
- 中文汇总~/vdi-terminal/logs/overnight-summary.txt;JSON行overnight-health-YYYYMMDD.log;持久状态~/vdi-terminal/state/overnight-state.json。同boot的服务重启不计为整机重启;11项状态/证据/跨日测试通过,实际分钟样本和服务重启状态保持已验证,未实际重启盒子。
|
||||
- /var/log是zram,journal链接到eMMC /var/log.hdd/journal。新增70-vdi-observer-journal.conf配置persistent/64MB/7day/30s,flush/sync确认磁盘journal;只重启journald和本观察服务,不动Moonlight/显示/网络/GPU。
|
||||
- root拥有/usr/local/libexec/vdi-observer-pstore只读采集pstore到/var/lib/vdi-observer/pstore.json(root:systemd-journal 640),观察服务低权限读取。旧归档41字节reboot行仅基线;仅内容变化作为新线索。无法确认的断电/硬复位明确标原因未确认,不能伪造重启因果。
|
||||
- 最后Windows串流PID137139、2K45和显示守护保留,boot ID afb6a02f-ba7c-478a-8704-c78a993d23c6。文档docs/overnight-box-observation-20261104.md及evidence/overnight-observer-20261104;备份~/vdi-terminal/backups/overnight-observer-20261104-*。明早读取两天日志和汇总,无明早自动通知任务。
|
||||
|
||||
## 2026-11-04 麒麟鼠标掉帧继续排查(最新)
|
||||
- 保留原版Sunshine SHA256 1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71、单线程、暂停合成器、真实2K30/30Mbps。未改PVE或其他桌面。
|
||||
- wrapper支持~/.config/vdi-terminal/host-color-range/<host>的0/1协商;仅.71设0,消除软件full-range额外转换。同场景2K45请求:原full21.42FPS,limited28.81FPS;不代表真实45或物理延迟与Windows一致。
|
||||
- 两线程25.67、BGRA27.17、full-chroma25.61、bilinear30.79均未保留。精确SHA校验后仅创建过独立实验副本,现已删除三副本及服务drop-in、恢复原程序,无额外capability。
|
||||
- wrapper已部署,语法/三主机隔离/显式环境覆盖/非法配置拒绝检查通过。用户明确允许退出Windows切回麒麟后,已完成正常kiosk重连:PID135145/父kiosk92843,真实2K30/30Mbps,env颜色0,RKMPP/EGL和服务端MPEG日志确认;最终保留麒麟正常桌面连接。
|
||||
- 完整同参数2K30对照各24次合成F9回显,无超时:full23.35FPS、P50 150.99/P95 186.38ms;limited27.25FPS、P50 143.57/P95 173.03ms。分布重叠且网络有差异,不能称显著低延迟或Windows等效;排除物理USB及面板扫描。临时GTK和测试串流均已停。
|
||||
- 文档docs/kylin-mouse-20261104.md,证据docs/evidence/kylin-mouse-20261104/,两端备份~/vdi-terminal/backups/kylin-mouse-20261104/。
|
||||
|
||||
## 2026-11-04 麒麟真实2K已落地(最新)
|
||||
- 用户明确授权关闭并启动VM101;已正常关机、只改vga=std,memory=64并启动,PCI BAR确认64MB;配置diff仅vga,未动其他VM或宿主。
|
||||
- 麒麟Virtual-1和视频真实2560x1440;自定义241.50MHz/59.95Hz模式,Sunshine用户服务启动前自动创建/添加/切换。删模式后启动服务验证通过,未第二次整机重启验收新配置。
|
||||
- 盒子只改麒麟对象为2560x1440/30FPS,沿用2K自动30Mbps;Windows、Ubuntu对象不变,源码/客户端二进制未改。
|
||||
- 最终X11/libx264 ultrafast/zerolatency原单线程;Sunshine服务启动暂停UKUI合成、停止恢复。有效2K局部运动:开合成18.89FPS、两线程18.85、暂停合成22.97(采用),队列0.12ms。未测物理鼠标到屏幕延迟,不能称稳定30/45FPS。
|
||||
- 有效测试48/48跨屏点击误差<=1px,F8收到;正常kiosk连接/退出/重连2K验证,最终保持麒麟桌面。临时GTK/测试串流已停,未关闭长期锁屏。
|
||||
- 早期2K样本有锁屏污染,相关ultrafast/damage非valid文件排除性能结论。KMS探测失败恢复X11,最终入口及实体二进制getcap为空。
|
||||
- 备份两端~/vdi-terminal/backups/kylin-quality-20261104/;详情docs/kylin-quality-20261104.md,证据docs/evidence/kylin-quality-20261104/。此前接入文档的麒麟1080p为历史状态。
|
||||
|
||||
## 2026-11-04 Ubuntu / 麒麟已接入(最新)
|
||||
- 盒子192.168.1.188正常列表已可连接Ubuntu .72/test2、麒麟 .71/test1,两台安装并配置Sunshine,原Moonlight客户端保留。
|
||||
- Ubuntu24.04.4使用Sunshine2026.914.233613;麒麟V10 SP1/glibc2.31使用官方v0.21.0 Ubuntu20.04构建,/opt解包部署并单文件KYSEC认证,未关闭全局策略。
|
||||
- 用户明确授权Ubuntu test2自动登录+Xorg;GDM/AccountsService原配置备份/var/backups/vdi-linux-20261104-100722/;重启GDM后已生效,未整机重启验收。麒麟沿用已有图形会话,锁屏仅临时解锁。
|
||||
- 用户服务vdi-sunshine active/enabled,XDG自启导入会话环境;X11/libx264、Virtual-1 1920x1080、30FPS配置;固定虚拟音频vdi_audio,audio_sink填sink名而非.monitor,音质未验收。
|
||||
- 真实盒子RKMPP/EGL入口两台画面、F8及鼠标左键已验证;最后Ubuntu/麒麟/Windows依次连接回归通过并退回列表。Linux短测约29FPS,未验收2K/长期性能/重启恢复。
|
||||
- Windows原对象2560x1440/45FPS保留,未改源码默认地址。盒子配置备份~/vdi-terminal/backups/linux-desktops-20261104-101507/desktops.json。
|
||||
- 两台管理随机凭据只在各自~/.config/sunshine/vdi-admin.json(600),不要复制进仓库。临时输入/Xvfb测试已停。详见docs/linux-desktop-onboarding-20261104.md及evidence/linux-desktops-20261104。
|
||||
|
||||
## 2026-11-03 U 盘真实设备透传 POC
|
||||
- 用户明确要求 Windows 枚举真实 USB。已开发 Rust terminal/usb-monitor(标准库),usbredir+受限SSH隧道+PVE QMP usb-redir;盒子不调用PVE API。
|
||||
- Kingston 0951:1666 / USB序列号408D5C1653D4E44179789251,物理3-1(USB2/480M);Windows正常枚举USB Mass Storage / Kingston USBSTOR,KINGSTON F:、FAT32、Removable、Healthy。
|
||||
- 独立VDI_USB_TEST_20261103-173342目录新增1MiB文件并复制回VM,三份SHA一致;用户安全弹出后真实拔插,17:34:43断开、17:34:53重连,F:恢复且文件SHA一致。原文件未改,测试目录保留。
|
||||
- 仅固定VID/PID/serial纯存储,拒绝键鼠/复合设备/本地挂载/holder/swap;udev只禁止这只盘本地自动挂载。最初口报-71,换口后成功,不是软件修好了原口。
|
||||
- 盒子vdi-usb-monitor.service、PVE vdi-usb-tunnel-100.service active/enabled;QMP端点vdi_usb_box_100/vdi_usb_redir_100仅运行时,VM/PVE重启需管理员重新attach。未验收开机恢复/多用户生产能力。
|
||||
- 盒子备份~/vdi-terminal/backups/usb-device-20261103-172709;PVE密钥/etc/vdi-usb-poc/vm100,回环40001经SSH。未装Windows驱动、开测试签名、重启或改GPU/串流;Moonlight PID46934保留,GTX965M错误0。
|
||||
- 详见docs/usb-device-poc-20261103.md及evidence/usb-device-20261103;工具deploy-usb-device-poc.py、usb-device-qmp.py、test-usb-device-poc.ps1。
|
||||
|
||||
## 2026-11-03 Windows保留单个2K主屏(最新)
|
||||
- 用户明确授权:不影响2K才能改,否则不动。现场VDD已变DISPLAY4,原主屏2560×1440@60;Basic DISPLAY1另扩展2K64,NV DISPLAY2原本无活动输出。
|
||||
- 已只停用Basic桌面输出,VDD成为唯一活动主屏,保持2K60;未禁用/卸载任何显卡、未重启。原Moonlight PID46934/2K45继续,截图正常;GPU错误0,Sunshine重建h264_nvenc,编码10%。
|
||||
- 新工具set-single-vdi-display.ps1、run-single-vdi-display.py支持Query/Apply/Restore,有2K60主屏前置条件、XML布局备份、读回失败恢复、设备身份校验;临时任务已删除。备份C:\ProgramData\VDI\single-display-20261103\before.xml。
|
||||
- 布局已按当前用户保存,但未重启/重登验收;设备管理器仍有适配器,不能说卸载设备或保证设置界面不列未连接输出。详见docs/single-display-20261103.md及对应evidence。
|
||||
|
||||
## 2026-11-03 独立光标受控原型
|
||||
- 已实测Windows真实箭头/文字/左右缩放/忙碌静态形状导出和盒子X11本地显示,热点正确,隐藏全透明;短暂停Moonlight小于0.5秒,本地指针仍移动,已恢复SIGCONT。
|
||||
- 最后完整轮32/32跨区点击正确,归一化坐标最大差1px;10项边界测试通过,0.8秒辅助状态租约到期退出/SDL资源归还通过。未测物理输入到屏幕延迟。
|
||||
- 这是脚本形状回放和受控Sunshine全局N快捷键交接,不是自动形状同步,不是生产独立光标;没有替换正常Moonlight/Sunshine二进制。正式须改双方控制通道、帧边界ACK/租约与SDL生命周期,并适配Weston。
|
||||
- 首两轮辅助连接退出后XFixes图像不可用,修正为XFixes临时隐藏下成对切Moonlight本地C快捷键,让SDL重新设置其拥有的隐藏指针;第三轮完整通过。静止画面N切换旧箭头留旧帧也已实测,不能靠快捷键直接上线。
|
||||
- 已停删VDICursorPOC,正常PID31694/2K45/原SHA保留,VDD2K60、GPU错误0/NVENC编码11%、显示守护active;最后本地透明1×1,正常Windows单个白色远程箭头。未重启/改ROM/VFIO/主题。
|
||||
- 详见docs/independent-cursor-feasibility-20261103.md及对应evidence。新增cursor-poc工具仅实验,不自动启动;verify-cursor-poc-box.py点击仅可用于临时Windows测试窗口。
|
||||
|
||||
## 2026-11-03 GPU修复后联调与45帧落地
|
||||
- 用户确认运维修复结束,授权的鼠标优化任务已恢复。现场复核GTX965M错误码0、vBIOS84.04.2b.00.13、
|
||||
Sunshine实际h264_nvenc、NVENC会话1;正常重连后编码占用18%。本轮未改ROM/VFIO、未重启任何机器。
|
||||
- 修正的测试源实测约59.4~59.8FPS;2K30实渲染29.83FPS;2K60接收58.82但只渲染46.11,
|
||||
客户端等待队列7.54ms,GPU89~95%/800MHz。原长队列渲染仍46且排队36ms;nearest实验42;VSync24,均未采用。
|
||||
- 已落地Windows VDD2560x1440@60、正常kiosk票据45FPS;短测实渲染44.22/队列1.30ms,
|
||||
约两分半持续动态会话43.14/队列1.55ms/网络实际丢帧0。不能称稳定2K60或零延迟。
|
||||
- Windows活动VDD变为DISPLAY3,动态查找;登录脚本tools/set-2k-display.ps1默认60、支持RefreshRate30。
|
||||
已备份/部署并手动运行原VDISet2KDisplay任务,独立Query确认60Hz;未通过重启验收登录触发。
|
||||
Windows原脚本/任务XML:C:\ProgramData\VDI\backups\display-refresh-20261103-142920。
|
||||
- tools/set-terminal-fps.py支持30/45/60、原子写入和备份;当前30票据备份
|
||||
~/vdi-terminal/backups/fps-20261103-143333-297521/desktops.json。正常流程重新连接PID31694实际fps45。
|
||||
- 正常二进制仍35de7b...afa00f,RKMPP/DRM PRIME/EGL、最新帧队列、无VSync/无pacing,第三补丁不开。
|
||||
nearest实验保留terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch,已撤回正常源码且不进构建器。
|
||||
- Windows临时基准任务已停止并删除,256次交替点击记录全部位置正确;用户原应用保留,恢复正常Windows串流。
|
||||
显示守护active,2560x1440输出正常。独立光标尚未实现;稳定2K60需继续验证更低开销呈现路径。
|
||||
- 详细对照、限制与回退见docs/2k60-validation-20261103.md,证据docs/evidence/2k60-20261103/。
|
||||
|
||||
## 2026-11-03 午后鼠标进一步优化(历史,已恢复联调)
|
||||
- 用户要求制定计划并执行;随后明确运维正在修复GPU Code43。Windows多次被外部qmshutdown/qmstart,
|
||||
本轮已停止Windows配置/串流测试,不能与运维并发改VM;没有发出VM/宿主/显卡重启指令。
|
||||
- 新增0003-vdi-input-priority.patch:帧前连续输入最多32条/2ms,保留焦点/窗口/退出等边界;
|
||||
VDI_INPUT_PRIORITY=1才启用;VDI_INPUT_TIMING=1才记录SDL派发年龄及主线程render调用统计。
|
||||
已在盒子编译,普通/ASAN测试通过。实际EGL使用独立PacerRender线程,主线程render样本0,未证明新调度收益。
|
||||
- 默认客户端已恢复本轮前哈希35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f;
|
||||
候选另存moonlight.input-priority-20261103,哈希ef0707ed2053f15ad6f62a33a79ac360237b27f307777c79b379b105fabfe3a2。
|
||||
正常wrapper未改用候选,新调度未默认启用;没有添加本地/透明光标。
|
||||
- VDD30Hz已查明,临时2K60应用/读回成功;但旧WinForms基准源本身约32FPS,2K30/60初测不能证明性能提升。
|
||||
本地基准脚本已修正定时器量化,待运维结束再部署;VDD模式未持久化,原登录2K30脚本没改。
|
||||
- 新增set-vdi-display-mode.ps1、run-mouse-display-probe.py、bench-mouse-stream.sh和移动注入工具。
|
||||
现场测试串流已清理;Windows基准窗口随关机退出,VDIInputLatencyBench登记无自动触发器,
|
||||
运维结束后先用run-input-latency-bench.py stop清理,再Query VDD/必要时Restore,再做新版源配对测试。
|
||||
- 独立光标完成源码核实与交接设计,尚未实现;Sunshine全局toggle不足以可靠回退,需要双方协议/帧边界交接。
|
||||
计划/实际结果见docs/mouse-optimization-plan-20261103.md、docs/mouse-optimization-execution-20261103.md;
|
||||
后续设计见docs/independent-cursor-design-20261103.md。不能宣传本轮达到2K60或鼠标到屏幕1ms。
|
||||
|
||||
## 2026-11-03 显示器关开修复(优先于下文旧状态)
|
||||
- 关屏再开后画面变矮:盒子回退1920x1440@75(4:3),Moonlight 16:9视频上下黑边;
|
||||
MATE monitors.xml仍存旧4:3布局,原启动脚本只设置一次2K。
|
||||
- 已部署vdi-display-guard用户服务(active/enabled)和幂等启动恢复,保留297MHz定制2K时序;
|
||||
2秒检查、异常稳定2秒后恢复;断开不强设、失败退避、命令超时、单实例锁,正常不反复切换。
|
||||
修正当前GWR/0x2708显示器旧保存布局。仅部署tools/deploy_display_guard.sh,不重启串流。
|
||||
- 9个场景测试通过,CTest 3/3;两次主动回退均约14秒恢复,Moonlight PID66385和boot ID不变。
|
||||
用户真实关屏再开确认自动全屏;10:03:19断开、10:03:31正确2K模式,10:04现场2560x1440。
|
||||
- 备份~/vdi-terminal/backups/display-guard-20261103-095920/;详情/回退见
|
||||
docs/display-resume-fix-20261103.md。此修复仅限当前Xorg单HDMI 2K POC,未做隔夜老化。
|
||||
|
||||
## 2026-11-03 鼠标/点击跳转优化(优先于下文旧状态)
|
||||
- Moonlight私有客户端新增两个补丁:鼠标合并不跨点击/键盘/窗口边界,绝对鼠标按钮封住前一位置批次;
|
||||
低延迟非pacing队列可丢弃等待帧保留最新帧,wrapper默认VDI_LATEST_FRAME=1。保持单白色Windows光标。
|
||||
- 现场32次交替点击:旧客户端30/32正确(第1、13错位),补丁32/32正确;native输入测试1000轮和ASAN通过。
|
||||
配对串流补丁队列2.14ms/呈现6.12ms,原3.39ms/6.84ms;帧率约28.4/28.1,受网络/软件编码波动,不能称零延迟。
|
||||
- Windows保留DragFullWindows=1、MouseTrails=0;已在test交互会话将MinAnimate从1改0、菜单400改100ms,读回通过,
|
||||
原值备份在test的LocalAppData/VDI;临时执行/测试任务已清理。
|
||||
- 用户确认蓝框在盒子登录/选择页:QFrame:hover错误匹配QLabel。移除蓝色悬停框和登录焦点蓝框,限定卡片对象样式,
|
||||
局部编译CTest3/3,部署新版kiosk;悬停截图亮蓝像素8302→0。UI/wrapper备份input-rendering-20261103-114542。
|
||||
详情见docs/input-rendering-optimization-20261103.md和terminal/moonlight/README.md。
|
||||
|
||||
## 2026-11-02 最新实测(优先于下文旧状态)
|
||||
- 2026-11-02晚:已在用户目录完成MPP+FFmpeg-Rockchip+Moonlight6.2.0私有构建,
|
||||
实际2K串流选中h264_rkmpp/drm_prime与EGL/GLES呈现,非仅FFmpeg样本测试。
|
||||
2K20 smooth会话平均呈现18.97FPS、解码10.53ms、呈现2.54ms;2K30 low-latency
|
||||
呈现23.46FPS、解码9.11ms、队列2.39ms、呈现5.41ms。服务端仍Code43/libx264。
|
||||
2K30 smooth队列31.95ms,VSync开/帧步调关也有28.93ms,故该盒子选低延迟档。
|
||||
原始日志和限制见 `docs/rendering-execution-20261102.md`。
|
||||
通过 `~/.config/vdi-terminal/render.conf` 显式选择私有wrapper+hardware,系统客户端不覆盖;
|
||||
Windows桌面票据改30FPS,原20FPS文件备份为desktops.before-rkmpp-20261102.json。
|
||||
构建脚本 `tools/build-rkmpp-client.sh` 修Qt5 optional、EGL探测缓存与旧对象不重编;
|
||||
kiosk就绪判断兼容RKMPP独立测试解码器日志,CTest 2/2通过。
|
||||
鼠标MouseTrails=0、DragFullWindows=1;现场仍有VDIApplyTransparentCursor刷新参数任务,
|
||||
先前“任务已删除”的记录不准确,本轮未改变光标配置;截图单白光标正常。
|
||||
不能把23FPS、硬解接通解释为GPU已恢复、2K60或端到端零延迟。
|
||||
- 已部署真实2K:盒子HDMI 2560x1440约60Hz、Windows VDD 2560x1440@30、Moonlight 2560x1440@20/30Mbps。
|
||||
- RK3528旧PHY拒绝EDID的241.70MHz,实测用297MHz固定时钟和扩展消隐成功:
|
||||
`297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync`;盒子重启已验证,link Good。
|
||||
- Windows VDD版本11.30.4.434,生效XML在 `C:\VirtualDisplayDriver\vdd_settings.xml`。
|
||||
登录任务VDISet2KDisplay动态定位Virtual Display Driver(不能写死DISPLAY编号),脚本在
|
||||
`C:\ProgramData\VDI\set-2k-display.ps1`,仓库副本 `tools/set-2k-display.ps1`;VM重启后执行成功。
|
||||
- 鼠标保留白色Windows远程光标,已删除本地黑色光标自动开启和透明光标任务;GLES2、关闭MATE合成器保留。
|
||||
- **GPU并非稳定完成**:运维曾打通582.78/NVENC,连续动画可发送59FPS;但本次VM重启后Code43复发,
|
||||
nvidia-smi失败、Sunshine当前实际为libx264软件编码。客户机重启设备/隔离VDD/VM停启均无效;
|
||||
无效VDIRecoverNvidia任务已删除。未改宿主VFIO模块、未重启宿主。ROM为gtx965m-pci.rom。
|
||||
- NVENC正常时2K20实测19.67FPS、网络丢帧0%、抖动丢帧0.94%;这些数据不能作为当前软编性能保证。
|
||||
- 详细最新状态见 `docs/moonlight-kiosk-20261028.md` 的“当前现场(2026-11-02 15:45)”。
|
||||
|
||||
## 当前状态
|
||||
- 项目目录:`D:\DAIMA\vdi-cloud`
|
||||
- 终端盒子:RK3528 PRO DDR4,Armbian Ubuntu Noble,aarch64,IP `192.168.1.188`
|
||||
- 当前图形:LightDM + Xorg;正式文档要求 Weston + Wayland + Qt,当前只是 POC
|
||||
- Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面);
|
||||
VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2),
|
||||
SSH 可登,**均已安装 Sunshine 并验证盒子正常串流**(2026-11-04,见顶部最新记录);登录凭据由管理员保管
|
||||
- `kiosk-shell`(Qt/C++):**开机直进桌面列表**(2026-10-30 起,设备 SN 即用户自动登录,
|
||||
登录页代码保留待平台正式认证接入)→ 选择桌面 → Moonlight 会话;**RDP 代码已于 2026-10-29
|
||||
全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖
|
||||
- 新增退出三层与会话过场层:`Ctrl+Alt+Shift+Q` 退串流回列表;列表页"退出登录";
|
||||
登录页无退出按钮(退出终端只在管理菜单);点连接有全屏过场层,首帧后淡出。
|
||||
详见 `docs/moonlight-kiosk-20261028.md` 2026-10-29 下午节
|
||||
- 代码位置:`terminal/kiosk-shell`
|
||||
- 远程安装路径:`~/vdi-terminal/bin/`(`kiosk-shell`、`vdi-kiosk-monitor`、`vdi-moonlight`)
|
||||
|
||||
## GPU 直通进行中(2026-10-30 下午,见 moonlight-kiosk 文档当日追加节)
|
||||
- 宿主侧已就绪:nouveau 已黑名单、GPU 在 vfio-pci、IOMMU 组 2(root port+GPU)
|
||||
- 关键坑:vfio 清零 Subsystem ID(必须 `x-pci-sub-vendor-id/device-id` 注入 1558:6557);
|
||||
移动卡 vBIOS 需 sysfs 抽取(/usr/share/kvm/gtx965m.rom);NVIDIA 安装器在 SYSTEM 会话
|
||||
挂起(用 7zr 解包 + pnputil);582.78 绑定后 D3D 全灭已换 566.36(D3D 待验证)
|
||||
- **当前 VM 100 未挂 GPU**:另一并行会话在改配置(hv_vendor_id 方案),本会话已停手避撞,
|
||||
两个会话不要同时改 VM 100 配置!
|
||||
- Sunshine 配对 2026.914 改为"Web UI 批准式":`moonlight pair --pin NNNN` +
|
||||
`GET /api/csrf-token` + `POST /api/pin {pairing_id,pin}`;Web 凭据由环境管理员保管;
|
||||
删 sunshine_state.json = 全部失配;盒子 Moonlight 配置目录缓存旧 uniqueid 会卡死配对
|
||||
|
||||
## 必须遵守
|
||||
- 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。
|
||||
- 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。
|
||||
- 正式状态机:`BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR`
|
||||
- 正式 Kiosk 选型:Weston + Wayland + Qt;会话通道统一 Moonlight(RDP 已删,勿在代码里复活协议分支)。
|
||||
- `/cert:ignore`、空密码仅限局域网 POC。
|
||||
- 项目经理新增待调研:RDP 是否适合 GPU/CAD/3D;研究 Moonlight/Sunshine,但不要在调研前删除 RDP、确定 Moonlight 参数或定版双协议。
|
||||
|
||||
## 已知关键结论
|
||||
- WiFi:约 -47 dBm,协商约 200 Mbps,零丢包/重传;卷帘问题不能简单归因于 WiFi。
|
||||
- HDMI 当前原生:`1920x1440@75Hz`(10-23 记录);2026-10-28 实测盒子系统已是 `1920x1080@60Hz`,
|
||||
与 Sunshine 侧虚拟显示器一致,串流 1:1 无缩放。以现场 `xrandr` 为准。
|
||||
- `/dev/mpp_service`、`/dev/rga` 存在,但 `mppvideodec` GStreamer 插件未确认;Weston/DRM 零拷贝待验证。
|
||||
- FreeRDP 3:`gdi_init` 必须在 PostConnect;NLA 空密码传 `strdup("")`;BGRX32 需转换为 Qt RGB;`/gfx` 是能力组合语义。
|
||||
- Moonlight/Sunshine(2026-10-28 实测):Windows 防火墙挡 ICMP,ping 不通不代表不通,
|
||||
可达性判断必须用 TCP 探测;`moonlight-qt` 的 app id 会随服务端重装失效,
|
||||
命令行要用应用名 `Desktop`;RK3528 上 `--video-decoder auto` 会选到不可用的
|
||||
`h264_v4l2m2m`,必须显式 `software`。
|
||||
- **21.6 FPS 上限根因(2026-10-29 修正)**:不是 x264 编码算力——ultrafast 前后帧率分毫不差,
|
||||
串流中 VM CPU 仅 0.5%、sunshine 累计 14 秒 CPU/3 分钟会话,鼠标运动也不变;
|
||||
真正瓶颈在虚拟显示/捕获节奏(MttVDD 呈现 + Desktop Duplication 脏帧 + QEMU 显示栈,
|
||||
VM CPU 型号 `QEMU Virtual CPU version 2.5+` 未透传宿主 AVX2)。
|
||||
根治 = GPU 直通(NVENC + 真实显示栈);备选 = VirtIO-GPU / cpu=host 排除 QEMU 显示栈因素。
|
||||
客户端解码 10~15 ms、网络丢包 0%、延迟 3 ms,链路健康。
|
||||
|
||||
## Web 控制台(2026-10-28 方案确认)
|
||||
- `console/` 是管理员 Web 管理控制台,不是 RK3528 盒子的用户登录页。
|
||||
- 正式技术栈:Vue3 + Vite + TypeScript 严格模式 + Element Plus;后端通过 api-gateway,JWT + RBAC。
|
||||
- 页面:仪表盘、终端管理、桌面管理、用户与绑定、GPU 池、监控、OTA、审计日志。
|
||||
- 角色:超级管理员、运维、只读。
|
||||
- 终端产品流程仍应是 `kiosk-shell 登录/首配 -> 桌面列表 -> 获取短期连接票据 -> RDP/Moonlight 会话`;
|
||||
当前开机直连 Moonlight 仅是串流技术验证,不是最终产品登录流程。
|
||||
- 开发策略:先做功能骨架、Mock API、OpenAPI 契约、RBAC 与 E2E;项目经理 UI 稿到后替换视觉层。
|
||||
- 完整方案:`docs/web-console-mvp-plan-20261028.md`。
|
||||
|
||||
## 尚未完成
|
||||
- 服务端 GPU 直通:GPU 为 CLEVO 准系统上的 GM204M/965M(10de:13d9,宿主被 nouveau 占用需解绑);
|
||||
**IOMMU 已启用**(组 2 = root port 00:01.0 + GPU 01:00.0,整组直通);VM 内需装 R580 legacy 驱动。
|
||||
操作单与 GPU 看板见 `docs/gpu-passthrough-20261030.md`,看板 `http://192.168.1.51:8090/`(systemd 自启)
|
||||
- RK3528 VPU 硬解(h264_v4l2m2m 等缺可用设备,需 MPP FFmpeg 补丁或 V4L2 request 驱动)
|
||||
- 2K 分辨率链路验证(虚拟显示器 + 盒子显示模式需同步调整)
|
||||
- Weston/DRM/mppvideodec
|
||||
- 后端 API 注册、配置、MQTT 心跳(Moonlight 路径目前是本地静态配置)
|
||||
- 终端音频出音现场确认
|
||||
- 协议最终选型定版(RDP / Moonlight / 双协议,需实测数据 + 产品方案共同确认)
|
||||
- 监控与运维脚本、OTA、USB 重定向等终端其余能力
|
||||
|
||||
先读 `PROJECT_CONTEXT.md`,再查看 `git status`,不要丢弃未提交改动。
|
||||
- 2026-11-06 Ubuntu点击/拖拽修复:根因是Moonlight会话中断或失焦丢失BUTTONUP后未清理远端鼠标按钮;新增terminal/moonlight/patches/0006-vdi-release-stuck-mouse-buttons.patch,焦点丢失/会话退出释放五个按钮。盒子新SHA a09140ef…,旧SHA 5d4330f…已备份;三项输入测试通过,Ubuntu退出后普通Sunshine active、native-cursor guard inactive。报告 docs/ubuntu-mouse-release-20261106.md。
|
||||
整理前本文件与 PROJECT_CONTEXT、README 的完整内容,以及 35 份原专题文档,都可通过[历史归档](docs/archive/README.md)恢复。历史结论按日期与当前证据核对,不直接当作现网状态。
|
||||
|
||||
@ -1,409 +1,17 @@
|
||||
# 数字员工 VDI 云终端项目上下文
|
||||
# 项目上下文入口
|
||||
|
||||
> 这是本项目后续继续开发时的必读上下文。所有实现必须优先以项目经理提供的正式文档和会议纪要为准;文档未明确的地方只能标记为待确认/待调研,不能擅自定版。
|
||||
>
|
||||
> 本文件整理自截至 2026-10-23 的完整开发对话。
|
||||
更新:2026-11-09。旧文件主要记录 10 月的 RDP、软解、1080p 和开机直连试验,已完整保存在[历史归档](docs/archive/README.md)的同名文件中;继续开发以以下主题文档核对当前状态。
|
||||
|
||||
## 1. 项目目标
|
||||
- [当前架构、环境与验证边界](docs/current-status.md)
|
||||
- [终端启动、进出、快捷键、构建](docs/terminal-guide.md)
|
||||
- [三系统显示、输入、USB 与电源策略](docs/desktop-guide.md)
|
||||
- [监控分工、日志与恢复](docs/operations.md)
|
||||
- [未来计划及暂缓范围](docs/roadmap.md)
|
||||
|
||||
项目是“云-管-端” VDI 云桌面方案:
|
||||
当前协议主线为 Moonlight/Sunshine;终端 Qt/C++,现场 LightDM/Xorg 独占 kiosk,正式目标 Weston/Wayland。盒子开机直接进入选择页;11-09 已接通真实平台认证、授权桌面和票据,现场配置见[真实接入说明](docs/cloud-live-integration.md)。平台 Go,控制台 Vue3/TypeScript/Element Plus,Agent/OTA/USB 方向为 Rust;已有 POC 和完整产品交付需区分。
|
||||
|
||||
```text
|
||||
RK3528 云终端
|
||||
-> 终端接入程序
|
||||
-> 自研管理平台 API
|
||||
-> Proxmox VE 管理的虚拟机
|
||||
-> Windows / Ubuntu / 麒麟 / 后续安卓桌面
|
||||
```
|
||||
终端不调用 PVE API、不保存 PVE 管理凭据。`console/` 是管理员 Web 控制台,和盒子客户选择页不同。盒子开关不会自动控制 VM 生命周期;客户图形电源入口限制保留管理员维护能力。
|
||||
|
||||
终端侧最终职责:
|
||||
原始需求来自 `docs/` 三份 Word 与用户提供的会议资料,出处见[索引](docs/README.md)。旧文档中的协议建议、状态机和失败实验不自动覆盖后续已确认的实现;未定事项写入计划,不能把猜测写为定版。
|
||||
|
||||
- 开机进入 Kiosk/首配登录页;
|
||||
- WiFi/有线联网;
|
||||
- 终端注册、心跳、配置下发、日志上报;
|
||||
- 连接远程桌面;
|
||||
- 显示远程画面并传递键盘、鼠标、U 盘等外设;
|
||||
- 断线重连;
|
||||
- 独立 Monitor 看门狗;
|
||||
- A/B OTA;
|
||||
- 后续使用 VPU/DRM 硬件解码与零拷贝显示。
|
||||
|
||||
项目经理新增方向:RDP 可能不适合 GPU/CAD/3D 高质量低延迟串流,周一需要调研 RDP 与 Moonlight。Moonlight 只作为待调研/待验证的协议方向,不能在未验证前替换 RDP 或擅自确定端口、认证字段、嵌入方式。
|
||||
|
||||
## 2. 权威需求来源
|
||||
|
||||
1. `E:\Desktop\数字员工\VDI云终端产品方案-定版摘要(1).docx`
|
||||
- 产品定版 v2.1。
|
||||
- 规定 RK3528 终端、2K 默认/4K 上限、FreeRDP 3、GStreamer + mppvideodec、Proxmox/KVM/VFIO、GPU 算力池、管理平台等。
|
||||
2. `E:\Desktop\数字员工\VDI云桌面软件开发方案(1).docx`
|
||||
- 软件开发 v1.0。
|
||||
- 规定 monorepo、终端/云端/控制台技术栈、20 项需求追踪矩阵、测试层次、M1-M4 里程碑。
|
||||
3. `E:\Documents\xwechat_files\wxid_q6y4us6kip0g22_aa63\msg\file\2026-10\export_1792654201375.docx`
|
||||
- 会议纪要。
|
||||
- 规定当前优先:先拿盒子、终端基础连接、Proxmox 逻辑/API、2K/4K/发热/网络验证、Monitor、OTA 后置;张帅协助 P100 GPU 验证。
|
||||
4. 会议纪要图片:项目技术要点思维导图。
|
||||
|
||||
核心纪律:如果代码实现与上述文档不一致,先指出偏差并等待确认;不能以“更快”或“更方便”为理由改用未定版技术栈。
|
||||
|
||||
## 3. 正式技术选型与边界
|
||||
|
||||
正式开发方案 2.1/2.2 节:
|
||||
|
||||
```text
|
||||
vdi-cloud/
|
||||
├─ terminal/
|
||||
│ ├─ meta-vdi/ # Yocto:BSP/内核/设备树/U-Boot/Kiosk/Agent 配方
|
||||
│ ├─ kiosk-shell/ # Weston + 全屏 Qt 壳
|
||||
│ ├─ freerdp-rk/ # FreeRDP 3 fork + MPP/VPU 硬解补丁
|
||||
│ ├─ vdt-agent/ # Rust:注册/心跳/命令/日志
|
||||
│ ├─ ota-updater/ # Rust:manifest/签名/A-B/回滚
|
||||
│ └─ usb-monitor/ # Rust:udev/挂载/重定向策略
|
||||
├─ platform/ # Go 云端服务
|
||||
├─ console/ # Vue3 + TS + Element Plus
|
||||
├─ deploy/ # compose/Helm/离线包
|
||||
├─ docs/ # OpenAPI/MQTT/DB schema
|
||||
└─ tools/ # 烧录/压测/CI
|
||||
```
|
||||
|
||||
终端技术栈定版:
|
||||
|
||||
- Kiosk 壳:C/C++ + Qt,目标显示环境 Weston/Wayland;
|
||||
- FreeRDP 插件:C/C++;
|
||||
- Agent/OTA/USB:Rust;
|
||||
- 协议主线:FreeRDP 3;SPICE 备用;自研 H.265 over QUIC 只作为后续预留;
|
||||
- 云端:Go;
|
||||
- 前端:Vue3 + TypeScript + Element Plus;
|
||||
- 数据库:PostgreSQL + VictoriaMetrics;
|
||||
- 通信:HTTPS + MQTT;
|
||||
- 云端底座:Proxmox VE + KVM/QEMU + VFIO;
|
||||
- GPU:NVIDIA CUDA、华为 CANN 等按卡型池化;终端不安装 GPU 计算组件。
|
||||
|
||||
开发方案 3.2 对 kiosk-shell 的明确要求:
|
||||
|
||||
- 技术:Weston(Wayland)+ 全屏 Qt 壳进程;
|
||||
- 状态机:`BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR`;
|
||||
- 首配页:SN 绑定、WiFi 扫码、服务器地址;
|
||||
- 状态条:网络、会话、OTA 进度;
|
||||
- FreeRDP:以 `libfreerdp3` API 内嵌,不拉起外部进程;
|
||||
- 全局热键:`Ctrl+Alt+Shift+M` 呼出管理菜单。
|
||||
|
||||
当前实现是在现有 Armbian 的 LightDM + Xorg 环境中先完成 Qt/Kiosk POC;这不是对正式 Weston 选型的替代。Weston/DRM 必须继续验证。
|
||||
|
||||
## 4. 盒子与联调环境
|
||||
|
||||
### 盒子
|
||||
|
||||
- 型号:RK3528 PRO DDR4;
|
||||
- OS:Armbian v25.05 rolling,Ubuntu Noble 用户空间;
|
||||
- Kernel:`6.1.115-vendor-rk35xx`;
|
||||
- 架构:`aarch64`;
|
||||
- 内存:约 3.83 GB;
|
||||
- 存储:约 29 GB;
|
||||
- IP:`192.168.1.188`;
|
||||
- SSH:`ssh armbian@192.168.1.188`;
|
||||
- 图形:LightDM + Xorg,当前不是 Wayland;
|
||||
- 显示器:`HDMI-1 1920x1440 @ 75Hz`,没有 2560x1440 EDID 模式;
|
||||
- DRM:`/dev/dri/card0`、`card1`、`renderD128`、`renderD129`;
|
||||
- 权限:用户已在 `video`、`render` 组;
|
||||
- 温度:空载约 53°C;
|
||||
- WiFi:`wlan0`,SSID `YXTech`,信号约 -47 dBm,协商约 200 Mbit/s,实测无丢包/重传;
|
||||
- 设备节点:`/dev/mpp_service`、`/dev/rga` 存在;
|
||||
- OpenGL:Mali450,Mesa 25.0.7;Xorg 日志显示 modesetting + glamor initialized。
|
||||
|
||||
### 已安装环境
|
||||
|
||||
- gcc/g++ 13;
|
||||
- make、cmake 3.28、ninja、pkg-config、git;
|
||||
- Qt5 5.15.13(Core/Gui/Widgets/Wayland 包);
|
||||
- Weston 13.0.0;
|
||||
- `freerdp3-dev` 3.32.1;
|
||||
- `freerdp3-x11`,实际命令:`xfreerdp3`;
|
||||
- `freerdp3-wayland`,实际命令:`wlfreerdp3`,但当前没有 Wayland 会话;
|
||||
- `mesa-utils`;
|
||||
- `xdotool`(测试用);
|
||||
- 当前未确认 `mppvideodec`,Ubuntu 官方源没有 Rockchip MPP GStreamer 插件。
|
||||
|
||||
### apt 源
|
||||
|
||||
旧 `/etc/apt/sources.list` 中普通 Ubuntu 源会造成 ARM64 404,已备份为 `sources.list.bak` 并注释。有效配置在 `/etc/apt/sources.list.d/ubuntu.sources`,使用 TUNA 的 `ubuntu-ports`。
|
||||
|
||||
不要执行:
|
||||
|
||||
- `sudo apt upgrade`:曾显示约 298 个包待升级,当前不要整体升级;
|
||||
- `do-release-upgrade`:不要升级系统版本;
|
||||
- 不要在盒子上安装 Proxmox,盒子是 ARM64 终端,不是 Proxmox 主机。
|
||||
|
||||
本机已经给盒子安装了 SSH 公钥(注释 `zcode-dev`),可以免密远程执行:
|
||||
|
||||
```bash
|
||||
ssh armbian@192.168.1.188
|
||||
```
|
||||
|
||||
SSH 中执行图形命令要带:
|
||||
|
||||
```bash
|
||||
export DISPLAY=:0
|
||||
export XAUTHORITY=/home/armbian/.Xauthority
|
||||
```
|
||||
|
||||
## 5. Proxmox/Windows 联调环境
|
||||
|
||||
- Proxmox VE:9.2.2;
|
||||
- 节点:`starhe`;
|
||||
- VM:101,名称 `win10-gongchang01`;
|
||||
- Windows:Windows 10 22H2 专业版;
|
||||
- VM IP:`192.168.1.70`;
|
||||
- RDP:TCP 3389;
|
||||
- 用户:`test1`;
|
||||
- 当前 POC 允许空密码登录,正式环境不能使用空密码。
|
||||
|
||||
边界:终端最终不应直接调用 Proxmox API。最终关系是:
|
||||
|
||||
```text
|
||||
终端 -> 自研管理平台 API -> Proxmox API -> 虚拟机
|
||||
终端 -> 管理平台返回的桌面连接地址/网关 -> RDP 或其他协议
|
||||
```
|
||||
|
||||
## 6. 已完成工作
|
||||
|
||||
### 6.1 网络与远程桌面基础验证
|
||||
|
||||
已完成:
|
||||
|
||||
- 盒子 WiFi 联通;
|
||||
- Windows 10 VM 可达;
|
||||
- 手工命令 `xfreerdp3` 成功显示 Windows 10 桌面;
|
||||
- 键盘鼠标可操作;
|
||||
- 退出全屏最可靠方式:从 SSH 执行 `pkill -f xfreerdp3`;
|
||||
- 当前 X11 环境下不要直接在 SSH 无 DISPLAY 会话中启动 GUI。
|
||||
|
||||
### 6.2 monorepo
|
||||
|
||||
项目已在:
|
||||
|
||||
```text
|
||||
D:\DAIMA\vdi-cloud
|
||||
```
|
||||
|
||||
已初始化 git。最近已提交:
|
||||
|
||||
```text
|
||||
589cde4 feat(kiosk-shell): FreeRDP 3 内嵌客户端跑通,登录页可进入远程桌面
|
||||
419630f feat(kiosk-shell): 首版登录页与状态机骨架
|
||||
```
|
||||
|
||||
当前 git 在日报暂停开发时有未提交改动,主要是 Monitor、心跳、网络显示修复等。不要丢弃这些改动。
|
||||
|
||||
### 6.3 kiosk-shell
|
||||
|
||||
位置:`D:\DAIMA\vdi-cloud\terminal\kiosk-shell`
|
||||
|
||||
已实现:
|
||||
|
||||
- Qt5 全屏登录页;
|
||||
- 深色 UI;
|
||||
- SN 显示;
|
||||
- 服务器地址、账号、密码、显示分辨率;
|
||||
- 记住连接参数;
|
||||
- 本地配置文件:`~/.config/vdi-terminal/client.conf`;
|
||||
- 状态机:`BOOT/CONFIG/CONNECTING/SESSION/RECONNECT/ERROR`;
|
||||
- `Ctrl+Alt+Shift+M` 管理菜单;
|
||||
- RDP 会话状态;
|
||||
- 连接断开后重连状态;
|
||||
- 主进程心跳文件设计:`/tmp/vdi-kiosk.heartbeat`;
|
||||
- CMake + Ninja 原生 ARM64 编译通过过多次。
|
||||
|
||||
当前盒子上的实际运行路径:
|
||||
|
||||
```text
|
||||
~/vdi-terminal/bin/kiosk-shell
|
||||
```
|
||||
|
||||
配置示例:
|
||||
|
||||
```text
|
||||
version=1
|
||||
sn=...
|
||||
deviceKey=
|
||||
server=192.168.1.70
|
||||
username=test1
|
||||
password=
|
||||
wifiSsid=
|
||||
wifiPassword=
|
||||
resolution=1920x1440
|
||||
remember=1
|
||||
```
|
||||
|
||||
### 6.4 libfreerdp3 内嵌
|
||||
|
||||
已实现 `rdp_session.cpp/h`,不拉起外部 `xfreerdp3` 进程:
|
||||
|
||||
```text
|
||||
freerdp_new()
|
||||
freerdp_context_new_ex()
|
||||
freerdp_connect()
|
||||
FreeRDP 3 AuthenticateEx
|
||||
GDI primary buffer
|
||||
Qt QImage/QLabel 显示
|
||||
```
|
||||
|
||||
已实测修复的坑:
|
||||
|
||||
1. `gdi_init` 必须放在 `PostConnect` 中;连接前调用会在 `gdi_init_ex` 段错误;
|
||||
2. 不能依赖扩展 `ContextSize` 保存自定义指针,`gdi_init_ex` 可能重新分配 context 导致段错误;当前使用按 `freerdp*` 的静态注册表和 `RdpgfxClientContext.custom`;
|
||||
3. NLA 认证回调空密码必须传 `strdup("")`,不能传 `nullptr`,否则出现 `SEC_E_NO_CREDENTIALS`;
|
||||
4. FreeRDP 3 设置键使用 `FreeRDP_ServerHostname`、`FreeRDP_Username`、`FreeRDP_DesktopWidth` 等,不要照抄 FreeRDP 2.x 键名;
|
||||
5. `PIXEL_FORMAT_BGRX32` 是 B/G/R/X 四字节布局,Qt 显示前需要 BGR -> RGB 转换;
|
||||
6. `/gfx` 命令行是能力组合语义,不能把 `AVC420`/`AVC444` 当作单个值使用。实测可解析形式包括 `progressive|RFX|AVC420|AVC444`。
|
||||
|
||||
重要:当前 POC 已成功显示 Windows 10 的真实远程桌面画面,颜色正确,进程保持运行。这是目前最重要的实测成果。
|
||||
|
||||
这部分属于早期 FreeRDP POC 历史记录;对应的临时笔记文件已从仓库移除。
|
||||
当前终端会话实现和维护说明以 `terminal/kiosk-shell/` 及其 README 为准。
|
||||
|
||||
### 6.5 显示问题
|
||||
|
||||
曾出现“从上到下卷帘式渲染”。目前确认:
|
||||
|
||||
- WiFi 信号/速率/丢包不是主要原因;
|
||||
- 显示器原生是 1920x1440,而曾请求 2560x1440,存在缩放;
|
||||
- X11/MATE/FreeRDP CPU 侧渲染路径性能有限;
|
||||
- 正式文档要求 Weston + DRM plane + GStreamer mppvideodec 零拷贝,这正是后续方向;
|
||||
- 该问题按用户要求暂时不阻断当前 GUI/登录页主线,后续处理。
|
||||
|
||||
注意:此前对“X11 无 glamor 硬件合成”的判断不准确,Xorg 日志实际显示 `glamor X acceleration enabled on Mali450`;后续不要继续引用这个错误结论。正确表述是:当前路径仍未达到目标流畅度,具体瓶颈需要后续基准测试确认。
|
||||
|
||||
### 6.6 Moonlight 云桌面开机直达(2026-10-28,历史记录)
|
||||
|
||||
本节保留当时的联调过程和取舍。2026-11-03 的实际部署状态、显示恢复、输入补丁及
|
||||
渲染参数以后续 `MEMORY.md` 和对应日期的 `docs/` 记录为准。
|
||||
|
||||
RDP/Moonlight 实测对比见 `docs/rdp-vs-moonlight-comparison-20261028.md`,
|
||||
落地记录见 `docs/moonlight-kiosk-20261028.md`。
|
||||
|
||||
当时盒子开机默认走 Moonlight 直连云桌面,链路如下:
|
||||
|
||||
```text
|
||||
~/.config/autostart/vdi-moonlight.desktop
|
||||
-> vdi-kiosk-monitor --heartbeat-file /tmp/vdi-moonlight.heartbeat --max-restart 200
|
||||
-- /home/armbian/vdi-terminal/bin/vdi-moonlight
|
||||
-> moonlight-qt stream 192.168.1.70 Desktop (fullscreen, 1920x1080, H.264, 软解)
|
||||
```
|
||||
|
||||
仓库新增:`terminal/kiosk-shell/scripts/vdi-moonlight`、`etc/moonlight.conf`、
|
||||
`vdi-moonlight.desktop`;`src/monitor.cpp` 改为按进程组结束整棵进程树。
|
||||
|
||||
实测:满屏云桌面 1:1 无缩放;杀客户端 5 秒内重连;杀启动器 3 秒内被看门狗拉起;
|
||||
性能叠图 1080p60 实际 21.59 FPS、网络丢包 0%、延迟 3 ms、解码 15.04 ms、抖动丢帧 13.64%。
|
||||
|
||||
关键坑位(后续维护必须知道):
|
||||
|
||||
1. Windows 防火墙屏蔽 ICMP,盒子 ping 不到云桌面主机,但 TCP/UDP 正常;
|
||||
可达性判断必须用 TCP 探测,ping 会误判;
|
||||
2. `moonlight-qt` 的应用 id 随服务端重装/升级失效,旧 id 会报“找不到应用程序 <id>”;
|
||||
命令行要用应用名 `Desktop`;
|
||||
3. RK3528 上 `--video-decoder auto` 会选到不可用的 `h264_v4l2m2m`,
|
||||
必须显式 `--video-decoder software`;
|
||||
4. RDP 路径的自启项已禁用但保留,可随时切回。
|
||||
|
||||
|
||||
## 7. 当前未完成/未验证
|
||||
|
||||
1. 服务端 GPU 直通未做:宿主机 `starhe` 有 GTX 965M(`0000:01:00.0`,IOMMU Group 2 独占),
|
||||
主机同时有 Intel 核显 `0000:00:02.0` 可继续驱动本机显示,具备直通条件;
|
||||
直通后 Sunshine 才能用 NVENC 硬编码,把 1080p 帧率从 ~21.6 FPS 提到 60 FPS;
|
||||
2. RK3528 VPU 硬解不可用:`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
|
||||
`Could not find a valid device`,当前只能用软件解码,4K 目标必须先解决;
|
||||
3. 2K 分辨率链路未验证:盒子屏幕现为 1920x1080@60Hz,Sunshine 侧虚拟显示器也是 1920x1080@60Hz;
|
||||
4. 当前实现仍使用 X11 POC,正式方案要求 Weston/Wayland;
|
||||
5. 当前 RDP 会话使用 CPU GDI -> Qt 复制,尚未接 GStreamer/mppvideodec/VPU;
|
||||
6. `mppvideodec` 来源和可用性未确认;
|
||||
7. 终端注册、MQTT 心跳、配置下发、日志上报、OTA、USB 尚未接后端 API;
|
||||
8. 前端/后端管理平台尚未接入;Moonlight 路径目前读本地静态配置文件;
|
||||
9. 终端音频出音未现场确认(`--audio-on-host` 已开);
|
||||
10. 协议最终选型未定版:实测已证明 Moonlight 画面质量与 CPU 代价优于 RDP,
|
||||
但“办公用 RDP、GPU/CAD/3D 用 Moonlight”的双协议结论仍需产品方案确认。
|
||||
|
||||
## 8. 下一步建议(按优先级)
|
||||
|
||||
### A. 服务端 GPU 直通(当前最高价值项)
|
||||
|
||||
Sunshine 在 VM 内用 libx264 软件编码,4 核同时承担 1080p 捕获与编码,
|
||||
是 21.6 FPS 与 13.64% 抖动丢帧的唯一原因;客户端侧链路完全健康。
|
||||
|
||||
```text
|
||||
宿主机 starhe(Xeon E3-1200 v3 + Intel 核显 + GTX 965M)
|
||||
-> 确认内核 IOMMU 已启用(PCI 设备已有独立 IOMMU group)
|
||||
-> vfio-pci 绑定 0000:01:00.0,提取 vBIOS ROM
|
||||
-> VM 100 增加 hostpci,Windows 内装 GeForce 驱动
|
||||
-> nvidia-smi 正常后 Sunshine 切 NVENC 硬编码
|
||||
-> 复测 1080p60 帧率、抖动丢帧、服务端 CPU
|
||||
```
|
||||
|
||||
主机 root 密码已知(`yx66668888`),可用 `SSH_ASKPASS` 非交互登录。
|
||||
|
||||
### B. 收尾当前终端 POC
|
||||
|
||||
1. 保留当前 Moonlight 开机直达版本作为可回退基线(`docs/moonlight-kiosk-20261028.md`);
|
||||
2. 确认开机自启在真实重启后生效(当前只验证了手动拉起与被守护重启);
|
||||
3. 网络状态显示修复(此前显示错误的 `600`)尚未确认最新二进制效果;
|
||||
4. RDP 路径代码保留但自启已禁用,按需切回。
|
||||
|
||||
### C. 协议策略与代码结构
|
||||
|
||||
实测结论已经支持双协议方向,但仍未定版:
|
||||
|
||||
```text
|
||||
普通办公 -> RDP
|
||||
GPU/CAD/3D -> Moonlight
|
||||
```
|
||||
|
||||
如果确认需要双协议,代码结构应演进为:
|
||||
|
||||
```text
|
||||
DesktopSession 接口
|
||||
├─ RdpSession # 保留当前 libfreerdp3
|
||||
└─ MoonlightSession # 当前是 shell 启动器 + moonlght-qt 子进程
|
||||
SessionManager # 根据 API 下发 protocol 选择实现
|
||||
```
|
||||
|
||||
不要在定版前擅自确定 Moonlight 的嵌入方式、认证字段或删除 RDP 代码。
|
||||
|
||||
|
||||
### C. 后续 API 对接
|
||||
|
||||
与后端确认:
|
||||
|
||||
- `/api/v1/terminal/register`;
|
||||
- 配置拉取接口;
|
||||
- MQTT 心跳和命令格式;
|
||||
- 桌面连接信息字段;
|
||||
- 返回 VM 地址还是 RDP/Moonlight 网关地址;
|
||||
- 是否使用一次性票据;
|
||||
- GPU 桌面如何标记协议/资源池;
|
||||
- 终端不直接调用 Proxmox API。
|
||||
|
||||
## 9. 安全与工程注意事项
|
||||
|
||||
- `/cert:ignore` 只能用于当前局域网 POC,正式版本必须证书校验;
|
||||
- 空密码只能用于当前测试 VM,正式环境必须禁用;
|
||||
- 不要把密码提交到 git;当前本地配置仅用于 POC;
|
||||
- 终端正式版本不要长期保存 Windows 密码,应使用管理平台/网关/短期凭据方案;
|
||||
- 不要执行 `git reset --hard`、删除用户已有改动或覆盖未审阅文件;
|
||||
- 不要把推测写成“已实现”;日报和项目状态要区分“已验证/已完成/待验证/待调研”。
|
||||
|
||||
## 10. 今日日报关联
|
||||
|
||||
日报模板:`E:\Desktop\日报.txt`。
|
||||
已按 10 月 23 日实际情况填入并保存:
|
||||
|
||||
- 终端硬件与系统基线;
|
||||
- 编译环境;
|
||||
- monorepo/kiosk-shell;
|
||||
- libfreerdp3 内嵌 RDP;
|
||||
- Windows 10 远程桌面实机验证;
|
||||
- 2K 显示异常初步定位;
|
||||
- 明日 Monitor/Weston/mppvideodec/API 待办;
|
||||
- AVC444、mppvideodec、Weston、API 契约风险。
|
||||
|
||||
如果继续写日报,必须以实际工具执行结果为准,不能把未编译或未实测项目写成完成。
|
||||
开发前查看 `git status`,不丢弃未提交改动;不随手整体升级盒子系统或改变宿主/GPU 配置。部署、设备重启、回退应结合当前任务授权与现场使用情况执行。本次文档整理未执行现场操作。
|
||||
|
||||
273
README.md
@ -1,164 +1,46 @@
|
||||
# VDI 云桌面项目
|
||||
|
||||
本项目是一套面向企业云桌面场景的“云—管—端”VDI 解决方案,目标是在 RK3528 云终端上提供统一的用户登录、云桌面选择、远程会话接入、设备管理和运维能力。
|
||||
面向 RK3528 云终端的“云—管—端”VDI 项目。当前已接通 Windows、Ubuntu、麒麟的 Moonlight/Sunshine 会话,仍处于终端 POC 与产品流程验证阶段。
|
||||
|
||||
当前仓库采用 monorepo 结构,包含终端嵌入式软件、云端服务、Web 管理控制台、部署配置和配套工具。
|
||||
## 文档入口
|
||||
|
||||
> 当前开发阶段:终端 POC 与产品流程验证。仓库内已实现的功能、已验证的功能和规划功能会在本文档中分别标注。
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [文档索引](docs/README.md) | 全部维护文档、历史归档与原始需求 |
|
||||
| [当前状态](docs/current-status.md) | 三系统配置、最新实施结果与验证边界 |
|
||||
| [终端说明](docs/terminal-guide.md) | 开机直达选择页、进出交接、快捷键、显示与构建 |
|
||||
| [云桌面说明](docs/desktop-guide.md) | 编码、光标、客体电源按钮、USB |
|
||||
| [运维说明](docs/operations.md) | 日志、监控分工、排障和回退 |
|
||||
| [云管开发说明](docs/cloud-platform-development.md) | Go API、Vue 控制台、本机启动、接口与联调边界 |
|
||||
| [未来计划](docs/roadmap.md) | 产品化与明确暂缓的 VPN、软件白名单、品牌画面 |
|
||||
|
||||
## 一、项目目标
|
||||
|
||||
终端产品的目标使用流程:
|
||||
## 当前流程与边界
|
||||
|
||||
```text
|
||||
盒子开机
|
||||
-> 设备身份自动登录
|
||||
-> 获取并展示可用云桌面列表
|
||||
-> 用户选择 Windows / Ubuntu / 麒麟等桌面
|
||||
-> 获取一次性连接票据
|
||||
-> 启动 Moonlight 会话
|
||||
-> 会话结束后返回桌面列表
|
||||
盒子开机 → LightDM 独占 kiosk → 选择云桌面 → Moonlight 串流 → 返回选择页
|
||||
```
|
||||
|
||||
总体系统关系:
|
||||
盒子使用私有 RKMPP 硬解/EGL,Windows 使用 NVENC,Linux 仍以软件编码为主。当前是 Xorg POC,Weston/Wayland 是后续方向;现场盒子已接入平台设备认证、授权列表和一次性连接票据,详见[真实接入说明](docs/cloud-live-integration.md)。正式边界为终端 → 自研平台 API → Proxmox,终端不直接管理 VM。
|
||||
|
||||
```text
|
||||
RK3528 云终端
|
||||
-> 自研管理平台 API
|
||||
-> Proxmox VE / KVM 虚拟化平台
|
||||
-> Windows / Ubuntu / 麒麟云桌面
|
||||
```
|
||||
11-09 已修复退出露出绿色桌面和进入左上角小窗;三系统 X11 采样通过,尚无外置逐帧采集。详细版本与证据见当前状态,不将历史性能测试当作实时健康报告。
|
||||
|
||||
终端不会直接调用 Proxmox API。桌面生命周期、用户授权、连接信息和协议策略统一由管理平台下发。
|
||||
## 仓库结构
|
||||
|
||||
## 二、仓库结构
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `terminal/kiosk-shell/` | Qt 全屏入口、会话、看门狗和显示守护 |
|
||||
| `terminal/moonlight/`、`terminal/native-cursor/` | 私有硬解客户端补丁、Linux 原生光标 |
|
||||
| `terminal/usb-monitor/` | USB 存储设备透传 POC |
|
||||
| `terminal/meta-vdi/`、`terminal/vdt-agent/`、`terminal/ota-updater/` | 系统层、Agent 与 OTA 后续工作 |
|
||||
| `platform/`、`console/`、`deploy/` | Go API、Vue 管理台;演示部署与独立真实现场环境 |
|
||||
| `tools/` | 管理员构建、部署、诊断与测试工具 |
|
||||
| `docs/` | 主题说明、需求原件、历史归档与测试证据 |
|
||||
|
||||
```text
|
||||
vdi-cloud/
|
||||
├─ terminal/ # 终端嵌入式软件
|
||||
│ ├─ meta-vdi/ # Yocto 层:BSP、内核、设备树、U-Boot、配方
|
||||
│ ├─ kiosk-shell/ # Qt 全屏壳:桌面列表、Moonlight 会话、看门狗
|
||||
│ ├─ vdt-agent/ # Rust:注册、心跳、命令、日志
|
||||
│ ├─ ota-updater/ # Rust:OTA 签名校验、A/B 切换、回滚
|
||||
│ └─ usb-monitor/ # Rust:USB 监听、挂载与重定向策略
|
||||
├─ platform/ # Go 云端服务
|
||||
├─ console/ # Vue 3 + TypeScript Web 管理控制台
|
||||
├─ deploy/ # Docker Compose、Helm、离线部署包
|
||||
├─ docs/ # 方案文档、实施记录、测试证据和运维资料
|
||||
└─ tools/ # 烧录、构建、压测和 CI 工具
|
||||
```
|
||||
运行监控由独立 [vdi-monitor 项目](../vdi-monitor/README.md) 维护;本仓库保存实现、验证与回退资料。监控最新快照和 7 天指标不能替代全部历史证据。
|
||||
|
||||
当前有实现代码的模块为 `terminal/kiosk-shell`,其余模块保留目录结构,后续按里程碑逐步开发。
|
||||
## 构建与配置
|
||||
|
||||
## 三、当前功能
|
||||
|
||||
### 1. 终端登录与桌面列表
|
||||
|
||||
已实现:
|
||||
|
||||
- 开机后使用设备 SN 作为当前身份,自动进入桌面列表;
|
||||
- Windows、Ubuntu、麒麟三类桌面卡片;
|
||||
- **桌面连接可配置**:每张卡片有“设置”按钮,可配置服务器地址(支持 IP、域名或 VPN 隧道地址)、Sunshine 应用名、分辨率和帧率;
|
||||
- 连接配置持久化在 `~/.config/vdi-terminal/desktops.json`,
|
||||
地址为空的桌面显示“待分配”且不可连接;
|
||||
- 老环境自动迁移:首次运行会读取 `client.conf` 里的 server 地址填充 Windows 桌面;
|
||||
- `Ctrl+Alt+Shift+M` 管理菜单提供“桌面连接设置”统一入口;
|
||||
- 会话退出后返回桌面列表。
|
||||
|
||||
不同客户部署环境的桌面地址不同:换环境时在“设置”里改地址即可,不需要改代码。
|
||||
这里的 VPN 仅指网络层能力:盒子上的 VPN 客户端建立隧道后,桌面地址字段可以填写隧道地址。让云桌面内部浏览器和应用通过 Clash/Mihomo 访问 GPT 等外部服务属于后续规划,见“下一步计划”第 8 项。
|
||||
|
||||
设备身份自动登录是正式认证 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。
|
||||
|
||||
### 2. Moonlight/Sunshine 会话
|
||||
|
||||
已实现:
|
||||
|
||||
- `MoonlightSession` 使用 `QProcess` 参数数组启动 `moonlight-qt`;
|
||||
- 不通过 Shell 拼接用户输入,降低命令注入风险;
|
||||
- H.264 软件解码;
|
||||
- 绝对鼠标模式;
|
||||
- 点击连接后立即锁定操作,保证任何时刻最多一个 Moonlight 会话;
|
||||
- 解析 Moonlight 输出,只有测试解码成功、渲染器选定且全屏串流窗口已激活后才淡出过场;
|
||||
- ARM/X11 环境通过 `GL_IS_SLOW=0` 缩短不可用 DRM 路径的探测时间;
|
||||
- 连接阶段有结构化耗时日志和 30 秒就绪超时,失败时自动关闭错误窗口并返回列表;
|
||||
- 会话进程退出后自动返回桌面列表;
|
||||
- `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置;
|
||||
- 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。
|
||||
|
||||
2026-11-04 现场已接通 Windows、Ubuntu、麒麟三种桌面的正常选择与连接流程。Ubuntu 与麒麟现均为真实 2K / 30 FPS 请求配置与30Mbps,使用 `libx264` 软件编码和逐主机limited范围协商,盒子使用 RKMPP 硬解。2026-11-05 两套 Linux 已启用受限 X11 原生独立光标:同步实际形状/热点/显隐,由盒子显示指针位置,移动不再等待视频;Ubuntu恢复Adwaita/24px及默认鼠标加速,保留关闭动画。Windows保持原视频光标路径。形状、编号点击、断线恢复已实测,完整失焦与物理输入延迟尚未验收;点击页面及拖窗内容仍受软件编码限制,不能称定量等同Windows。方案和边界见 [原生独立光标实施记录](docs/linux-native-cursor-20261105.md);历史画质证据见 [Linux接入](docs/linux-desktop-onboarding-20261104.md)、[Ubuntu调优](docs/ubuntu-quality-20261105.md)、[麒麟2K调优](docs/kylin-quality-20261104.md)。
|
||||
|
||||
2026-11-05 后续只对 Ubuntu VM102 启用 `cpu=host`(AVX2)及 `virtio-gl`(共享Intel核显渲染)。受控长按框选的回显中位数约398→224ms,2K与原生光标保留;45FPS和4编码线程未继续改善延迟,最终仍用30FPS及默认线程。尚未达到Windows/原生目标,仍需解决捕获和软件编码瓶颈,详见 [Ubuntu框选与渲染调优](docs/ubuntu-drag-render-20261105.md)。
|
||||
|
||||
### 3. 状态机
|
||||
|
||||
当前终端状态机:
|
||||
|
||||
```text
|
||||
BOOT
|
||||
-> CONFIG / LOGIN
|
||||
-> AUTHENTICATING
|
||||
-> DESKTOP_LIST
|
||||
-> CONNECTING
|
||||
-> SESSION
|
||||
-> RECONNECT
|
||||
-> DESKTOP_LIST / ERROR
|
||||
```
|
||||
|
||||
### 4. Monitor 看门狗
|
||||
|
||||
已实现独立 `vdi-kiosk-monitor` 进程:
|
||||
|
||||
- 监控 kiosk-shell 进程是否退出;
|
||||
- 通过心跳文件检测主进程假死;
|
||||
- 异常退出后自动重启;
|
||||
- 连续启动失败后熔断;
|
||||
- 子进程独立进程组,结束时清理整棵进程树,避免遗留孤儿串流进程。
|
||||
|
||||
## 四、技术栈
|
||||
|
||||
### 终端
|
||||
|
||||
- C++17
|
||||
- Qt 5 Widgets / Qt Test
|
||||
- Moonlight Qt
|
||||
- 当前开发环境:Armbian + LightDM + Xorg
|
||||
- 正式目标环境:Weston + Wayland
|
||||
|
||||
### 云端(规划)
|
||||
|
||||
- Go
|
||||
- PostgreSQL
|
||||
- VictoriaMetrics
|
||||
- HTTPS + MQTT
|
||||
- Proxmox VE / KVM / VFIO
|
||||
|
||||
### Web 控制台(规划)
|
||||
|
||||
- Vue 3
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Element Plus
|
||||
|
||||
## 五、构建 kiosk-shell
|
||||
|
||||
### 1. 依赖
|
||||
|
||||
目标设备或 ARM64 构建环境需要:
|
||||
|
||||
```bash
|
||||
sudo apt install \
|
||||
build-essential cmake ninja-build pkg-config \
|
||||
qtbase5-dev libqt5widgets5 libqt5test5
|
||||
```
|
||||
|
||||
Moonlight 会话还需要安装:
|
||||
|
||||
```bash
|
||||
sudo apt install moonlight-qt
|
||||
```
|
||||
|
||||
### 2. 编译
|
||||
在具备 Qt5 开发依赖的 ARM64 环境:
|
||||
|
||||
```bash
|
||||
cd terminal/kiosk-shell
|
||||
@ -167,101 +49,10 @@ cmake --build build
|
||||
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
编译产物:
|
||||
产物为 `kiosk-shell`、`vdi-kiosk-monitor`。产品连接配置在盒子 `~/.config/vdi-terminal/desktops.json`,基础配置为 `client.conf`;`moonlight.conf` 仅供旧独立调试入口使用。完整部署和客户端构建入口见[终端说明](docs/terminal-guide.md#build)。
|
||||
|
||||
```text
|
||||
build/kiosk-shell
|
||||
build/vdi-kiosk-monitor
|
||||
```
|
||||
## 开发约定
|
||||
|
||||
### 3. 运行
|
||||
保留用户已有改动;事实、计划和待验收结论分开记录。正式需求来源保留于 `docs/` 的三份 Word,后续明确确认的实现与当前用户要求优先于旧历史描述。账号、密码、私钥和连接票据通过安全配置提供,不新增到文档或提交。
|
||||
|
||||
在当前 X11 开发环境中:
|
||||
|
||||
```bash
|
||||
export DISPLAY=:0
|
||||
export XAUTHORITY="$HOME/.Xauthority"
|
||||
|
||||
./build/vdi-kiosk-monitor -- ./build/kiosk-shell
|
||||
```
|
||||
|
||||
产品自启入口为:
|
||||
|
||||
```text
|
||||
vdi-kiosk-monitor -> kiosk-shell
|
||||
```
|
||||
|
||||
Moonlight 只能在用户登录并选择桌面后由 kiosk-shell 启动,不应与 kiosk-shell 分别设置开机自启。
|
||||
|
||||
## 六、本地配置
|
||||
|
||||
终端基础配置路径:
|
||||
|
||||
```text
|
||||
~/.config/vdi-terminal/client.conf
|
||||
```
|
||||
|
||||
Moonlight 调试配置路径:
|
||||
|
||||
```text
|
||||
~/.config/vdi-terminal/moonlight.conf
|
||||
```
|
||||
|
||||
当前配置文件仅用于 POC。正式版本中,终端身份、配置版本和连接策略应由 `vdt-agent` 从管理平台同步。
|
||||
|
||||
不要将以下信息提交到仓库:
|
||||
|
||||
- Windows 或平台账号密码;
|
||||
- Proxmox root 密码;
|
||||
- Gitea 密码或访问令牌;
|
||||
- 终端设备密钥;
|
||||
- Sunshine 管理账号密码;
|
||||
- 私钥、证书和一次性连接票据。
|
||||
|
||||
## 七、当前联调环境
|
||||
|
||||
| 项目 | 当前环境 |
|
||||
|---|---|
|
||||
| 终端硬件 | RK3528 PRO DDR4 |
|
||||
| 终端架构 | ARM64 |
|
||||
| 终端系统 | Armbian / Ubuntu Noble |
|
||||
| 图形环境 | LightDM + Xorg(POC) |
|
||||
| 云平台 | Proxmox VE 9 |
|
||||
| Windows 云桌面 | Windows 10 22H2 |
|
||||
| Moonlight 服务端 | Sunshine |
|
||||
| 当前串流分辨率 | 1920×1080@60Hz |
|
||||
|
||||
具体 IP、测试账号和密码不写入 README,统一通过本地配置或部署环境变量管理。
|
||||
|
||||
## 八、已知限制
|
||||
|
||||
1. 终端用户认证、桌面列表和连接票据目前是本地演示实现,尚未接管理平台 API;
|
||||
2. Ubuntu 和麒麟桌面尚未配置实际连接地址;
|
||||
3. 当前 Moonlight 使用软件解码,RK3528 的 VPU/MPP 硬解尚未打通;
|
||||
4. Sunshine 当前使用软件编码,未启用 NVENC;
|
||||
5. 当前仍是 X11 POC,尚未完成 Weston/Wayland/DRM 零拷贝路径;
|
||||
6. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。
|
||||
|
||||
## 九、下一步计划
|
||||
|
||||
按优先级:
|
||||
|
||||
1. 保持终端、Sunshine 和虚拟显示器三端分辨率及色彩空间一致;
|
||||
2. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC;
|
||||
3. 定义并联调终端用户认证、桌面列表和一次性连接票据 API;
|
||||
4. 实现 Ubuntu、麒麟桌面连接配置;
|
||||
5. 适配 RK3528 MPP/VPU 硬解;
|
||||
6. 切换到 Weston/Wayland 正式显示路径;
|
||||
7. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台;
|
||||
8. **云桌面内上网代理(规划)**:在终端登录首页提供代理配置入口,支持导入/更新 Clash 或 Mihomo 订阅、选择节点、启停和连通性测试;由云端服务将策略分别下发到 Windows、Ubuntu 和麒麟云桌面,使桌面内的浏览器和应用可以访问 GPT 等外部服务,同时为 Moonlight 串流、管理平台和必要内网地址保留稳定的直连/专用路由。该功能当前不实现、不部署;
|
||||
9. **云桌面软件白名单与商业合规(规划)**:建立按客户/桌面生效的软件允许列表、版本校验、策略下发、审计和回滚流程。Windows 与麒麟计划通过签名的系统插件或代理组件执行白名单策略;Ubuntu 计划在系统底层实施,并在定版前完成权限模型、更新机制和例外流程评审。该功能当前不实现、不部署,正式销售前必须完成授权范围和合规审查;
|
||||
10. **退出闪帧防护与盒子品牌安全画面(规划)**:解决云桌面退出、断流、客户端异常退出和 kiosk 重启时可能短暂暴露盒子底层桌面的问题。主方案是完善独立安全遮罩、X11/SDL 窗口交接并最终采用 Weston/Wayland 独占 kiosk;兜底方案是将盒子底层改为全屏公司 Logo、隐藏面板和桌面内容。该功能当前仅列入计划,不实现、不部署。
|
||||
|
||||
## 十、开发规范
|
||||
|
||||
- 已验证、已实现、待验证和规划中必须明确区分;
|
||||
- 终端不能直接调用 Proxmox API;
|
||||
- Moonlight 是当前唯一远程会话协议,连接参数由管理平台连接票据决定;
|
||||
- 所有密钥和密码只能通过安全配置注入,不得提交到 Git;
|
||||
- 正式代码变更应通过编译、真机测试和会话恢复测试;
|
||||
- 本 README 随功能、接口、目录和验证结论持续更新。
|
||||
日常直接更新对应主题,计划统一维护在 `docs/roadmap.md`;测试输出放入 `docs/evidence/<专题-日期>/`。2026-11-09 以前的零散记录及本次整理前的三份根说明完整保留在[历史归档](docs/archive/README.md)。
|
||||
|
||||
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
<template><router-view /></template>
|
||||
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
1
console/src/assets/brand-mark.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
console/src/assets/kylinsoft.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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; }
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
# GPU修复后刷新率与鼠标画面优化验收(2026-11-03)
|
||||
|
||||
## 结论
|
||||
|
||||
Windows虚拟屏已调整为2560×1440、60Hz,登录脚本已备份并更新;客户端默认已设为45FPS,并通过正常选择桌面入口重连。当前盒子的Xorg/EGL路径在请求60FPS时实际只能渲染约46FPS,因此不宣称稳定2K60。45FPS短测实际44.22FPS,持续测试43.14FPS;相对30FPS基线29.83FPS提高约45%~48%,排队约1.3~1.6ms。
|
||||
|
||||
## GPU复核
|
||||
|
||||
本轮实际查询NVIDIA错误码0,驱动582.78,vBIOS 84.04.2b.00.13。动态串流期间Sunshine实际创建h264_nvenc,NVIDIA有1个编码会话,检查时编码引擎约24%。运维报告的重启测试作为其提供的验收记录,本轮未自行重启VM、宿主或GPU,也未修改直通和ROM配置。
|
||||
|
||||
## 同一修正测试源对照
|
||||
|
||||
修正后的Windows测试窗口实测约59FPS,避免了原WinForms定时器将源限制到约32FPS的问题。所有测试保持2560×1440、H.264、30Mbps、RKMPP硬解;默认客户端为已有两个输入补丁的版本,第三补丁调度未启用。除同步实验外,均关闭VSync与帧步调。
|
||||
|
||||
| 档位 | 接收/渲染FPS | 主机处理平均 | 解码平均 | 帧队列平均 | 客户端队列丢弃指标 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| VDD30 / 客户端30 | 29.95 / 29.83 | 7.5ms | 6.81ms | 0.60ms | 0.41% |
|
||||
| VDD60 / 客户端60、最新帧队列 | 58.82 / 46.11 | 7.5ms | 7.37ms | 7.54ms | 21.61% |
|
||||
| VDD60 / 客户端60、原队列 | 58.81 / 46.10 | 7.6ms | 7.13ms | 36.13ms | 10.28% |
|
||||
| VDD60 / 客户端60、精确尺寸nearest实验 | 58.20 / 42.01 | 7.5ms | 6.94ms | 7.66ms | 27.77% |
|
||||
| VDD60 / 客户端60、VSync开/帧步调关 | 57.88 / 24.39 | 7.5ms | 7.26ms | 8.12ms | 57.80% |
|
||||
| VDD60 / 客户端45、最新帧队列 | 44.84 / 44.22 | 7.6ms | 8.54ms | 1.30ms | 1.38% |
|
||||
| VDD60 / 客户端45、持续测试 | 43.98 / 43.14 | 7.6ms | 7.32ms | 1.55ms | 1.91% |
|
||||
|
||||
上表“客户端队列丢弃指标”来自上游标成`Frames dropped due to network jitter`的统计,但实际字段是`pacerDroppedFrames`,本项目最新帧队列也计入其中。不能把它全部归因于网络,原队列溢出统计也并不完整;应结合接收/解码/渲染FPS及帧队列时间评估。上述会话真正的网络丢帧字段均为0%,网络延迟约2~7ms;这些是现场顺序测试,不是严格实验室基准。
|
||||
|
||||
60FPS会话采样盒子图形核心约89%~95%负载、800MHz;DDR频率已在786MHz最高公布档位,MATE合成器关闭。测试温度约62~65℃,没有通过提高频率/电压或更改CPU/GPU调速器来制造结果。原队列没有提高帧率却增加排队,nearest采样和VSync实验均未显示收益,未部署。
|
||||
|
||||
nearest实验保留在`terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch`供复查,已从盒子正常源码撤回,不进入默认构建流程。正常二进制SHA256仍为`35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。
|
||||
|
||||
## 配置和回退
|
||||
|
||||
- Windows实际活动VDD编号已变为DISPLAY3,工具动态查找,未写死设备号。旧DISPLAY2备份不能直接作为新设备的恢复依据,因此探测器增加`--backup-tag`区分验收批次。
|
||||
- `tools/set-2k-display.ps1`默认60Hz,支持`-RefreshRate 30`回退;已更新原VDISet2KDisplay登录任务使用的脚本,未修改任务动作或用户。
|
||||
- Windows部署前原脚本和任务XML备份:`C:\ProgramData\VDI\backups\display-refresh-20261103-142920`。部署工具按本次新生成的结果日志和任务退出码确认成功,随后独立Query确认60Hz。本轮没有重启验证登录触发。
|
||||
- `tools/run-mouse-display-probe.py Apply --hz 30 --backup-tag nvenc-20261103`可临时恢复30Hz;要保持30Hz应恢复原登录脚本,或将登录任务脚本参数设为`-RefreshRate 30`。
|
||||
- `tools/set-terminal-fps.py --fps 45 --apply`只更改盒子Windows桌面票据FPS,自动备份和原子写入,不更改其他桌面;回退用`--fps 30 --apply`。
|
||||
|
||||
部署工具前两次因Windows任务调度器LastRunTime与墙上时钟不同步而误判成功任务为失败,已自动恢复旧脚本;随后改为新结果日志证明执行新鲜度,部署成功。受配置切换影响的`nvenc45-sustained`不纳入持续测试结论,重新执行`nvenc45-sustained-final`。
|
||||
|
||||
## 持续测试和现场收尾
|
||||
|
||||
`nvenc45-sustained-final`在视频就绪后注入20秒鼠标移动和32次点击,再持续120秒,正常退出。接收43.98/渲染43.14FPS,主机平均7.6ms、解码7.32ms、排队1.55ms、呈现1.55ms,实际网络丢帧0%。这是约两分半钟的动态会话结果,不是长期稳定性保证。
|
||||
|
||||
三组Windows点击记录共256次左右交替点击,脚本校验全部符合预期,包含被排除的持续性能试验中的32次;点击正确性可核验,但其帧率数据不纳入持续性能结论。修正测试源在保存的状态中约59.4~59.8FPS,足够进行本轮对照。
|
||||
|
||||
已执行`set-terminal-fps.py --fps 45 --apply`,原30帧票据备份于盒子`~/vdi-terminal/backups/fps-20261103-143333-297521/desktops.json`。只改变Windows卡片FPS,其余桌面、分辨率和编码配置保持原值。
|
||||
|
||||
已停止并删除VDIInputLatencyBench临时任务;最终Windows查询确认`benchmark_task_present=False`。通过正常kiosk页面点击Windows连接,实际新进程PID31694带`--resolution 2560x1440 --fps 45`,RKMPP/DRM PRIME/EGL路径正常。再次查询错误码0、NVENC会话1、编码占用18%,Sunshine本次实际创建h264_nvenc。显示守护active,正常客户端二进制未换成实验版本。
|
||||
|
||||
证据目录:`docs/evidence/2k60-20261103/`,包含逐会话日志、点击记录、最终GPU/编码查询和页面截图。实际拖窗与鼠标的主观体验仍需用户现场确认;本轮自动点击和统计不能代替物理输入到屏幕测量。
|
||||
|
||||
## 后续边界
|
||||
|
||||
独立光标尚未实现,仍保留单个Windows远程白色光标。下一阶段要进一步达到稳定2K60,需要验证更低开销的显示路径及正式Weston/Wayland架构,或评估终端硬件;当前不能通过提高FPS参数或安装所谓流畅插件绕过呈现瓶颈。独立光标双方协议与单光标交接设计见`docs/independent-cursor-design-20261103.md`。本轮未测量物理输入到屏幕的P50/P95,不能称零延迟。
|
||||
37
docs/README.md
Normal file
@ -0,0 +1,37 @@
|
||||
# 项目文档索引
|
||||
|
||||
整理日期:2026-11-09。日常阅读以下 5 份正文;历史过程放入归档,避免把早期失败或临时配置误当成当前状态。
|
||||
|
||||
| 文档 | 查什么 |
|
||||
| --- | --- |
|
||||
| [真实云管接入](cloud-live-integration.md) | 正式环境启动、现场 PVE/盒子、凭据与回退 |
|
||||
| [当前状态](current-status.md) | 当前架构、三种桌面配置、已完成与未验收事项 |
|
||||
| [终端说明](terminal-guide.md) | 开机直达选择页、进入/退出交接、快捷键、显示恢复、构建入口 |
|
||||
| [云桌面说明](desktop-guide.md) | Windows/Linux 编码与画质、原生光标、电源按钮、U 盘 |
|
||||
| [运维说明](operations.md) | vdi-monitor 分工、日志位置、排障顺序、备份回退 |
|
||||
| [未来计划](roadmap.md) | 后续产品化、性能路线,以及暂不实施的 VPN、白名单、品牌画面 |
|
||||
|
||||
另保留正在讨论的[云端服务与 Web 控制台功能设计](cloud-console-functional-design-20261109.md),用于产品/UI/后端任务拆分;它是讨论稿,不属于本次清理的历史实施记录。
|
||||
|
||||
[云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。
|
||||
|
||||
[控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。
|
||||
|
||||
## 监控项目与本仓库的分工
|
||||
|
||||
`D:\DAIMA\vdi-monitor` 负责日常监控页面、指标采集与日志解读,具体维护方法见该项目的 [README](../../vdi-monitor/README.md) 和 [运维说明与已知限制](../../vdi-monitor/docs/运维说明与已知限制.md)。
|
||||
|
||||
监控当前约每 30 秒采集一次盒子数据,保存最新原始快照和状态,并保留约 7 天指标历史。这不等于完整历史日志备份,也不覆盖开发决策、测试场景、失败实验和回退配置。`vdi-cloud` 保留这些资料,监控页面的使用说明以后在 `vdi-monitor` 维护。
|
||||
|
||||
## 历史记录与原始资料
|
||||
|
||||
- [归档索引](archive/README.md):35 份原专题记录,以及整理前的根目录 README、MEMORY、PROJECT_CONTEXT,共 38 份文件,逐字节保存在 ZIP 中;索引包含原文件到新文档的对应关系和校验方式。
|
||||
- [测试证据目录](evidence/):截图、JSON、原始日志、配置快照和失败实验保留原路径,仍可供脚本输出和复查。
|
||||
- 原始需求保留为本目录的 [产品方案](VDI云终端产品方案-定版摘要(1).docx)、[软件开发方案](VDI云桌面软件开发方案(1).docx)、[云管端完整方案](VDI云桌面完整方案-云管端.docx)。它们是原始需求来源,早期协议和环境描述需结合后续已确认的实现理解。
|
||||
- [虚拟显示器配置](vdd_settings.xml) 与 [历史驱动安装辅助脚本](idd-install-helper.bat) 保留作参考,不能直接当作当前环境的一键部署方案。
|
||||
|
||||
本次清理删除的是归档后散落的 35 份 Markdown 副本,没有删除唯一测试证据,也没有因为监控项目存在而停用盒子服务。完整性结果见归档索引。
|
||||
|
||||
## 后续维护方式
|
||||
|
||||
日常变更更新对应主题文档,注明日期、实际部署状态、证据位置和限制;计划统一写入 `roadmap.md`。测试原始输出放在 `evidence/<专题-日期>/`,一般不再新增一份顶层日报文档。发生配置冲突时,以较新的部署证据核对现场,不把历史测试成功直接视为当前状态。
|
||||
85
docs/archive/README.md
Normal file
@ -0,0 +1,85 @@
|
||||
# 历史归档
|
||||
|
||||
归档日期:2026-11-09。日常查看[维护文档](../README.md),历史文件中的配置、密码提示、失败结论和“最新”字样均按原日期理解,不直接当作当前部署说明。
|
||||
|
||||
## 内容与完整性
|
||||
|
||||
[history-20261109.zip](history-20261109.zip) 按仓库相对路径保存 38 份原件:35 份 docs 专题 Markdown,以及整理前根目录 README、PROJECT_CONTEXT、MEMORY。内容未经改写,ZIP 中保留原路径。三份需求 Word、XML、BAT 和全部测试证据仍在原位置。
|
||||
|
||||
[manifest.json](manifest.json) 列出每份原件的字节数、SHA-256、新文档对应位置与 ZIP 自身 SHA-256。归档创建后已检查 ZIP CRC,并逐文件读取验证字节数及 SHA-256;删除散落副本前再次核对原文件与归档完全一致。
|
||||
|
||||
这次减少的是散落的文档副本与重复阅读入口,没有删除唯一历史结论。547 个 evidence 文件保留原路径,整理前后逐文件 SHA-256 一致。证据约 169.7 MiB,不属于此次空间清理范围。
|
||||
|
||||
## 恢复或读取
|
||||
|
||||
在仓库根目录执行以下命令,将原文解压到临时目录查阅,不覆盖当前维护文档:
|
||||
|
||||
```powershell
|
||||
Expand-Archive -LiteralPath .\docs\archive\history-20261109.zip -DestinationPath (Join-Path $env:TEMP 'vdi-cloud-docs-history-20261109')
|
||||
```
|
||||
|
||||
归档内的相对路径以原仓库为基准,单独解压的文档中部分代码/证据链接需回到本仓库查看。查历史时先按下表定位原文件,再从 ZIP 读取;新文档保留主要结论与操作入口,完整逐步过程在原件中。
|
||||
|
||||
在仓库根目录复核归档(Python):
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import hashlib, json, zipfile
|
||||
base = Path('docs/archive')
|
||||
manifest = json.loads((base / 'manifest.json').read_text(encoding='utf-8'))
|
||||
archive = base / manifest['archive']
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == manifest['sha256']
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
assert z.testzip() is None
|
||||
assert len(z.namelist()) == manifest['file_count']
|
||||
for row in manifest['files']:
|
||||
data = z.read(row['path'])
|
||||
assert len(data) == row['size']
|
||||
assert hashlib.sha256(data).hexdigest() == row['sha256']
|
||||
print('38 files verified')
|
||||
```
|
||||
|
||||
## 原文件对应表
|
||||
|
||||
表中原文件名是 ZIP 内的路径,原 Markdown 副本已从 docs 顶层移除。每条都可以在 manifest 中查到独立校验值。
|
||||
|
||||
| 归档内原路径 | 日常查阅位置 |
|
||||
| --- | --- |
|
||||
| `docs/2k60-validation-20261103.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/display-resume-fix-20261103.md` | [终端说明](../terminal-guide.md#display) |
|
||||
| `docs/entry-surface-fix-20261109.md` | [终端说明](../terminal-guide.md#session) |
|
||||
| `docs/fixed-pci-tray-20261105.md` | [云桌面说明](../desktop-guide.md#usb) |
|
||||
| `docs/gpu-passthrough-20261030.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/gpu-restart-diagnosis-20261102.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/guest-power-policy-20261104.md` | [云桌面说明](../desktop-guide.md#power) |
|
||||
| `docs/independent-cursor-design-20261103.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/independent-cursor-feasibility-20261103.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/input-rendering-optimization-20261103.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/kylin-mouse-20261104.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/kylin-quality-20261104.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/kylin-quality-20261106.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/linux-desktop-onboarding-20261104.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/linux-native-cursor-20261105.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/moonlight-kiosk-20261028.md` | [终端说明](../terminal-guide.md#session) |
|
||||
| `docs/moonlight-rendering-profile-20261102.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/mouse-optimization-execution-20261103.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/mouse-optimization-plan-20261103.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/overnight-box-observation-20261104.md` | [运维说明](../operations.md#monitor) |
|
||||
| `docs/rdp-vs-moonlight-comparison-20261028.md` | [未来计划](../roadmap.md) |
|
||||
| `docs/rendering-execution-20261102.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/return-surface-fix-20261109.md` | [终端说明](../terminal-guide.md#session) |
|
||||
| `docs/single-display-20261103.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/sunshine-vm-setup-for-colleague.md` | [云桌面说明](../desktop-guide.md#windows) |
|
||||
| `docs/ubuntu-deep-latency-20261105.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/ubuntu-drag-render-20261105.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/ubuntu-latency-plan-20261106.md` | [未来计划](../roadmap.md#performance) |
|
||||
| `docs/ubuntu-mouse-release-20261106.md` | [云桌面说明](../desktop-guide.md#cursor) |
|
||||
| `docs/ubuntu-quality-20261105.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/ubuntu-vram-20261106.md` | [云桌面说明](../desktop-guide.md#linux) |
|
||||
| `docs/usb-any-storage-20261104.md` | [云桌面说明](../desktop-guide.md#usb) |
|
||||
| `docs/usb-device-poc-20261103.md` | [云桌面说明](../desktop-guide.md#usb) |
|
||||
| `docs/web-console-mvp-plan-20261028.md` | [未来计划](../roadmap.md) |
|
||||
| `docs/windows-unexpected-exit-20261104.md` | [运维说明](../operations.md#diagnosis) |
|
||||
| `README.md` | [当前入口](../../README.md) |
|
||||
| `PROJECT_CONTEXT.md` | [当前入口](../../PROJECT_CONTEXT.md) |
|
||||
| `MEMORY.md` | [当前入口](../../MEMORY.md) |
|
||||
BIN
docs/archive/history-20261109.zip
Normal file
238
docs/archive/manifest.json
Normal file
@ -0,0 +1,238 @@
|
||||
{
|
||||
"created": "2026-11-09",
|
||||
"archive": "history-20261109.zip",
|
||||
"sha256": "2630dbf89e358dbd42790de1765b2a285d295cac9f9214f9cc34f7baf839278c",
|
||||
"file_count": 38,
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/2k60-validation-20261103.md",
|
||||
"size": 6363,
|
||||
"sha256": "df2aec506b0f604d56b6b26196ec393d85d188dce7a52c708fe4d9991f93f6eb"
|
||||
},
|
||||
{
|
||||
"path": "docs/display-resume-fix-20261103.md",
|
||||
"size": 5221,
|
||||
"sha256": "e3368c4c74cea6e8d99108f013f455f31613d24486c9ac653a02561876bb8a1c"
|
||||
},
|
||||
{
|
||||
"path": "docs/entry-surface-fix-20261109.md",
|
||||
"size": 4093,
|
||||
"sha256": "8a6b50cb40e1362a42032004858084f10ba04ce12ab817077154a84c05026016"
|
||||
},
|
||||
{
|
||||
"path": "docs/fixed-pci-tray-20261105.md",
|
||||
"size": 5222,
|
||||
"sha256": "6c14d19b89131893c20994a944d0ef42c9a2743c37f0d0846ce1e3806b34cdf1"
|
||||
},
|
||||
{
|
||||
"path": "docs/gpu-passthrough-20261030.md",
|
||||
"size": 5657,
|
||||
"sha256": "5c3fbd00141ac7540db76e45e28f6a8c65ff8f3005f4edb8ad640ff99b06d239"
|
||||
},
|
||||
{
|
||||
"path": "docs/gpu-restart-diagnosis-20261102.md",
|
||||
"size": 3566,
|
||||
"sha256": "a09f0c92e58890056573b9f59faebce4084dc779d5125d5e8f77093208cf72a8"
|
||||
},
|
||||
{
|
||||
"path": "docs/guest-power-policy-20261104.md",
|
||||
"size": 4569,
|
||||
"sha256": "aca2a267c349ee42ff6f44ee2cb8be44f739d7bfac222160e1368eb03360d4f8"
|
||||
},
|
||||
{
|
||||
"path": "docs/independent-cursor-design-20261103.md",
|
||||
"size": 6189,
|
||||
"sha256": "b0c5ea8d7178609da9c778cea63c3f612bd659b557ec4d752084503b43aebe7c"
|
||||
},
|
||||
{
|
||||
"path": "docs/independent-cursor-feasibility-20261103.md",
|
||||
"size": 7960,
|
||||
"sha256": "1124adb59d9d392a4bcbe52749d92c748c248cb3c9da98388985ad05bdb6efde"
|
||||
},
|
||||
{
|
||||
"path": "docs/input-rendering-optimization-20261103.md",
|
||||
"size": 6356,
|
||||
"sha256": "cd292c5a1e4b122857fa51bfda66c08496faf9186a39f68acdd42b3d75a53bea"
|
||||
},
|
||||
{
|
||||
"path": "docs/kylin-mouse-20261104.md",
|
||||
"size": 6394,
|
||||
"sha256": "93c2737b197cb6c65b7af3ff51a10a5e94d34e754bae67f12483346c20943dc1"
|
||||
},
|
||||
{
|
||||
"path": "docs/kylin-quality-20261104.md",
|
||||
"size": 6553,
|
||||
"sha256": "d1d145dcb6244b8d4414e4fef28c499fddc47f242582583a74504da83331f38e"
|
||||
},
|
||||
{
|
||||
"path": "docs/kylin-quality-20261106.md",
|
||||
"size": 1910,
|
||||
"sha256": "e6ccc96fee07089bebd58e1c5af575aa81561bfbffab91e07ce2d14f15fd2957"
|
||||
},
|
||||
{
|
||||
"path": "docs/linux-desktop-onboarding-20261104.md",
|
||||
"size": 8501,
|
||||
"sha256": "bcc8400f20231f6cedaebf83d7b163a162cfe3df16310de278a17811ea7f7f5c"
|
||||
},
|
||||
{
|
||||
"path": "docs/linux-native-cursor-20261105.md",
|
||||
"size": 12273,
|
||||
"sha256": "b57101a8dee2faccc5bb52b5a0e434645530f4d792c8f0b76d4ffc5a75f47e62"
|
||||
},
|
||||
{
|
||||
"path": "docs/moonlight-kiosk-20261028.md",
|
||||
"size": 22237,
|
||||
"sha256": "af481932ed9e2151081ef187981a788669526277190bde93615aa45753cfcbf4"
|
||||
},
|
||||
{
|
||||
"path": "docs/moonlight-rendering-profile-20261102.md",
|
||||
"size": 3595,
|
||||
"sha256": "b6fbd7b64730593b03f32a242f9ac04089385eb78d8f8c9a0a5b330c4e043bd6"
|
||||
},
|
||||
{
|
||||
"path": "docs/mouse-optimization-execution-20261103.md",
|
||||
"size": 6735,
|
||||
"sha256": "7e4a97c3a64f7e333c8c22f97b4c0ee82b3bf61a5358764e8e0454dbbb918d8d"
|
||||
},
|
||||
{
|
||||
"path": "docs/mouse-optimization-plan-20261103.md",
|
||||
"size": 4473,
|
||||
"sha256": "ef979b20d8c234ab0756888fd8eb7eef3e70d619bcfe401e41902cf2424f4a9c"
|
||||
},
|
||||
{
|
||||
"path": "docs/overnight-box-observation-20261104.md",
|
||||
"size": 4899,
|
||||
"sha256": "1b6f16a052d08373e774499844e45a92c7db700c7be731ba2f2c89bcc1970643"
|
||||
},
|
||||
{
|
||||
"path": "docs/rdp-vs-moonlight-comparison-20261028.md",
|
||||
"size": 4218,
|
||||
"sha256": "096c675df33fe269c1ebc6b22ac458e9c60c9009ee0a01c0e71ddf44f22a8226"
|
||||
},
|
||||
{
|
||||
"path": "docs/rendering-execution-20261102.md",
|
||||
"size": 10440,
|
||||
"sha256": "8e1dba5a7c9e0753907cfad882084d1d1f1a2861b0a4300bdbaaeeb6b5df4ada"
|
||||
},
|
||||
{
|
||||
"path": "docs/return-surface-fix-20261109.md",
|
||||
"size": 3100,
|
||||
"sha256": "0646f6f09185e6f5ea67d007dd89e64ade35e4fd2ad0c6d40396984ece43bb01"
|
||||
},
|
||||
{
|
||||
"path": "docs/single-display-20261103.md",
|
||||
"size": 3293,
|
||||
"sha256": "a0739849c641997b10599a153c829cbb2d6c756f19fb5cb42a42adb517ce4392"
|
||||
},
|
||||
{
|
||||
"path": "docs/sunshine-vm-setup-for-colleague.md",
|
||||
"size": 2979,
|
||||
"sha256": "d0fc2d0f32e538b01331311545aeb0fd0e0ab92b8b82a0e3082404453163a1f5"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-deep-latency-20261105.md",
|
||||
"size": 2498,
|
||||
"sha256": "09cd5591762d05762455a1cf9566cad84f6949cb1a59da969f25e7e08c8c09ec"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-drag-render-20261105.md",
|
||||
"size": 7696,
|
||||
"sha256": "5731bbe457794575d9ef2d451ab9a9840e2fc8be8af2e73bb43f39bb34d04ed7"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-latency-plan-20261106.md",
|
||||
"size": 6157,
|
||||
"sha256": "27cdc6907e45f9086572fb4f0b4b7ced19d6a5b222dd364276b27ee321038098"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-mouse-release-20261106.md",
|
||||
"size": 1725,
|
||||
"sha256": "73b76b19e6f01282f3c2e4b74c3fe2fb3743f901abef0438c024497d18fcdd3e"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-quality-20261105.md",
|
||||
"size": 7334,
|
||||
"sha256": "fa44aa64b99ded46dfc2dbba8275ed1c48299ff1800a5d7872b65a3bc24b80a8"
|
||||
},
|
||||
{
|
||||
"path": "docs/ubuntu-vram-20261106.md",
|
||||
"size": 1553,
|
||||
"sha256": "d8afa6409c6407adee9b47540ac1d9c270dcab607292bae9003be570601b47b8"
|
||||
},
|
||||
{
|
||||
"path": "docs/usb-any-storage-20261104.md",
|
||||
"size": 6219,
|
||||
"sha256": "912e60cd9f590b89d7a6236116f142e5a4f0d5a8ee60f759d20090fb4238fde9"
|
||||
},
|
||||
{
|
||||
"path": "docs/usb-device-poc-20261103.md",
|
||||
"size": 6604,
|
||||
"sha256": "63052ea7f4df222fa8093c1c85afbd532ce3b509fcf0f8d4a1a0de1ae76cdc9d"
|
||||
},
|
||||
{
|
||||
"path": "docs/web-console-mvp-plan-20261028.md",
|
||||
"size": 10122,
|
||||
"sha256": "ef129f7dab233af52b1beb57b8853a620679f3d2622d334dc9cd17443c0c1196"
|
||||
},
|
||||
{
|
||||
"path": "docs/windows-unexpected-exit-20261104.md",
|
||||
"size": 2269,
|
||||
"sha256": "9721d09d718fbc76762d8ccd31c54d4318ee8124c845861b06072f5839d4b2ff"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"size": 11694,
|
||||
"sha256": "4824cb7311cc8180b0d1fc2755899c524f48c1fe44d4fc4389d32a99b9d04316"
|
||||
},
|
||||
{
|
||||
"path": "PROJECT_CONTEXT.md",
|
||||
"size": 16479,
|
||||
"sha256": "a27159a82e5cc236f242eb0b88cfb894cadfb6e44204643e94bcafe5c2687745"
|
||||
},
|
||||
{
|
||||
"path": "MEMORY.md",
|
||||
"size": 37521,
|
||||
"sha256": "7cd14ff3b7b86716ae3db629cefb834024064d81632841400cd30b7d3b8399f1"
|
||||
}
|
||||
],
|
||||
"consolidated_to": {
|
||||
"docs/2k60-validation-20261103.md": "docs/desktop-guide.md#windows",
|
||||
"docs/display-resume-fix-20261103.md": "docs/terminal-guide.md#display",
|
||||
"docs/entry-surface-fix-20261109.md": "docs/terminal-guide.md#session",
|
||||
"docs/fixed-pci-tray-20261105.md": "docs/desktop-guide.md#usb",
|
||||
"docs/gpu-passthrough-20261030.md": "docs/desktop-guide.md#windows",
|
||||
"docs/gpu-restart-diagnosis-20261102.md": "docs/desktop-guide.md#windows",
|
||||
"docs/guest-power-policy-20261104.md": "docs/desktop-guide.md#power",
|
||||
"docs/independent-cursor-design-20261103.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/independent-cursor-feasibility-20261103.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/input-rendering-optimization-20261103.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/kylin-mouse-20261104.md": "docs/desktop-guide.md#linux",
|
||||
"docs/kylin-quality-20261104.md": "docs/desktop-guide.md#linux",
|
||||
"docs/kylin-quality-20261106.md": "docs/desktop-guide.md#linux",
|
||||
"docs/linux-desktop-onboarding-20261104.md": "docs/desktop-guide.md#linux",
|
||||
"docs/linux-native-cursor-20261105.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/moonlight-kiosk-20261028.md": "docs/terminal-guide.md#session",
|
||||
"docs/moonlight-rendering-profile-20261102.md": "docs/desktop-guide.md#windows",
|
||||
"docs/mouse-optimization-execution-20261103.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/mouse-optimization-plan-20261103.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/overnight-box-observation-20261104.md": "docs/operations.md#monitor",
|
||||
"docs/rdp-vs-moonlight-comparison-20261028.md": "docs/roadmap.md",
|
||||
"docs/rendering-execution-20261102.md": "docs/desktop-guide.md#windows",
|
||||
"docs/return-surface-fix-20261109.md": "docs/terminal-guide.md#session",
|
||||
"docs/single-display-20261103.md": "docs/desktop-guide.md#windows",
|
||||
"docs/sunshine-vm-setup-for-colleague.md": "docs/desktop-guide.md#windows",
|
||||
"docs/ubuntu-deep-latency-20261105.md": "docs/desktop-guide.md#linux",
|
||||
"docs/ubuntu-drag-render-20261105.md": "docs/desktop-guide.md#linux",
|
||||
"docs/ubuntu-latency-plan-20261106.md": "docs/roadmap.md#performance",
|
||||
"docs/ubuntu-mouse-release-20261106.md": "docs/desktop-guide.md#cursor",
|
||||
"docs/ubuntu-quality-20261105.md": "docs/desktop-guide.md#linux",
|
||||
"docs/ubuntu-vram-20261106.md": "docs/desktop-guide.md#linux",
|
||||
"docs/usb-any-storage-20261104.md": "docs/desktop-guide.md#usb",
|
||||
"docs/usb-device-poc-20261103.md": "docs/desktop-guide.md#usb",
|
||||
"docs/web-console-mvp-plan-20261028.md": "docs/roadmap.md",
|
||||
"docs/windows-unexpected-exit-20261104.md": "docs/operations.md#diagnosis",
|
||||
"README.md": "README.md",
|
||||
"PROJECT_CONTEXT.md": "PROJECT_CONTEXT.md",
|
||||
"MEMORY.md": "MEMORY.md"
|
||||
}
|
||||
}
|
||||
165
docs/cloud-console-functional-design-20261109.md
Normal file
@ -0,0 +1,165 @@
|
||||
# VDI 云端服务与 Web 控制台功能设计
|
||||
|
||||
版本:v0.1 讨论稿 · 日期:2026-11-09 · 用途:产品范围讨论、UI 页面设计和后端任务拆分。
|
||||
|
||||
本稿保留设计时的现状基线。11-09 后续已开始第一阶段实现,最新代码能力、启动方式和未完成边界见[云管开发说明](cloud-platform-development.md)。
|
||||
|
||||
建议将产品定位为“基于 Proxmox 的云桌面业务管理平台”。管理员在我们的控制台完成桌面发放、终端管理和运维,平台在后端调用 Proxmox 等组件。画质和延迟优化继续独立推进,本轮不改串流链路。
|
||||
|
||||
## 1. 文档依据与当前状态
|
||||
|
||||
- `VDI云桌面软件开发方案(1).docx`:第 4 章七个云端服务,第 5 章控制台页面与三角色,以及 R8–R20 中的平台相关需求。
|
||||
- `VDI云桌面完整方案-云管端.docx`:第 3 章虚拟化、模板、桌面池与 GPU,第 4 章管理平台。
|
||||
- `VDI云终端产品方案-定版摘要(1).docx`:第 8 章“管终端 + 管桌面”的最小闭环。
|
||||
- `web-console-mvp-plan-20261028.md`:既有控制台 MVP、接口和 Mock 策略,原件见[历史归档](archive/README.md),日常计划见[未来计划](roadmap.md)。
|
||||
- 仓库现状:`platform/`、`console/` 目前仅有占位文件;终端已有桌面列表和 Moonlight 接入,平台认证、注册、授权和连接票据尚未接通。
|
||||
|
||||
以下是基于原文档整理的功能建议,不代表已经实现或替换定版需求。页面原型全部采用演示数据。
|
||||
|
||||
## 2. “类似 Proxmox”具体做什么
|
||||
|
||||
```text
|
||||
管理员浏览器 → 自研 Web 控制台 → 自研 Go 平台 → Proxmox API → VM / 模板 / 节点
|
||||
↕
|
||||
PostgreSQL / 指标存储
|
||||
↕ HTTPS + MQTT
|
||||
终端 Agent 与 Kiosk
|
||||
|
||||
实际桌面画面:终端 Moonlight ↔ VM 内 Sunshine
|
||||
```
|
||||
|
||||
控制台提供面向云桌面的资源视图和管理操作。虚拟机运行、克隆、节点资源查询复用 Proxmox;用户能用哪些桌面、哪台盒子归谁、终端配置、命令执行、会话记录由平台维护。管理员控制台与盒子的桌面选择页是两个产品界面。
|
||||
|
||||
| 能力 | 现成组件提供什么 | 我们需要开发什么 |
|
||||
| --- | --- | --- |
|
||||
| 节点与 VM | Proxmox 提供资源查询、开关机、配置、模板和克隆等 API | 接入配置、资源同步、桌面业务映射、任务跟踪和页面 |
|
||||
| 桌面池 | Proxmox 提供 VM、模板、克隆、快照等基础能力 | 公共/专属池、容量、分配、占用、回收和还原编排 |
|
||||
| 用户与授权 | 可选 AD/LDAP 提供身份来源 | 平台账号、管理权限、用户/终端/桌面授权关系 |
|
||||
| 终端管理 | MQTT 提供消息传输 | Agent 注册、设备认证、心跳、分组、配置、命令回执 |
|
||||
| GPU 管理 | Proxmox 提供 PCI 配置;驱动工具提供设备信息 | 卡清单、独占占用、绑定校验、失败处理、健康状态 |
|
||||
| 监控 | Proxmox 提供节点/VM 指标;VictoriaMetrics 保存时序数据 | Agent 会话采样、采集汇总、告警规则和查询权限 |
|
||||
| OTA | 对象存储提供包下载 | 签名元信息、版本、灰度批次、Agent 安装和 A/B 回滚闭环 |
|
||||
| 桌面连接 | Moonlight/Sunshine 提供串流 | 授权检查、端点发现、会话跟踪及配对/连接凭据集成 |
|
||||
|
||||
文档中 `/api/v1/...` 是自研平台的接口设计,不是 Proxmox 已经提供的同名接口。现有其他外部系统如能提供其中某部分,应以其真实接口清单、认证方式、版本和测试环境为准;目前仓库未见完整清单。
|
||||
|
||||
Proxmox 的 REST/JSON API 支持第三方管理系统集成,依据:[Proxmox 官方管理手册](https://pve.proxmox.com/pve-docs/pve-admin-guide.pdf)。具体端点和权限以现场版本 API Viewer 为准。
|
||||
|
||||
## 3. 第一版先交付什么
|
||||
|
||||
第一版验收场景:**接入 Proxmox → 纳管已有桌面 → 注册终端 → 授权桌面 → 盒子获取列表并连接 → 控制台查看状态 → 执行操作并追踪结果。**
|
||||
|
||||
| 阶段 | 功能范围 | 完成标准 |
|
||||
| --- | --- | --- |
|
||||
| A 基础闭环 | 管理员登录与三角色;接入一个 Proxmox 集群;同步节点和已有 VM;终端注册心跳;桌面授权;桌面启停;任务与审计;总览 | 一台真实盒子通过平台获得授权桌面并连接;管理员操作有真实最终回执 |
|
||||
| B 发放与运维 | 模板登记与检查;克隆发放;桌面池;配置版本下发;日志收集;监控告警;GPU 清单和经验证的独占绑定 | 从模板发放一台可用桌面;执行配置下发;可查故障原因和 GPU 占用 |
|
||||
| C 完整交付 | OTA 签名包与灰度/A-B 回滚;CSV 导入;公共池还原;可选 AD/LDAP;跨网段接入 | OTA 失败可回滚、公共池按策略回收;各可选能力单独完成联调验收 |
|
||||
|
||||
这是建议的实施顺序,不覆盖原文档 M1–M4 的项目里程碑。阶段 A 先纳管现有 Windows/Ubuntu/麒麟 VM,减少等待模板自动化的依赖;B/C 在首轮页面原型中保留功能入口。
|
||||
|
||||
## 4. 控制台页面清单
|
||||
|
||||
整体采用“左侧导航 + 顶部账号栏 + 主内容区 + 全局任务入口”。资源列表优先,详情使用详情区或抽屉;UI 同事负责视觉稿、品牌、颜色和组件规范。
|
||||
|
||||
| 导航 | 页面展示的核心信息 | 主要操作 | 阶段 |
|
||||
| --- | --- | --- | --- |
|
||||
| 总览 | 在线终端/总数、运行桌面/总数、活跃会话、待处理告警、节点水位、最近失败任务 | 点击指标进入对应列表;进入任务详情 | A |
|
||||
| 计算资源 | 集群、节点状态、CPU/内存/存储水位、最近同步时间、接口连通性 | 接入 Proxmox、验证连接、同步资源;节点详情 | A |
|
||||
| 桌面管理 / 桌面 | 名称、OS、VMID、节点、池、所有者、VM 电源状态、连接服务状态、当前会话 | 纳管已有 VM、从模板创建、授权、开机、正常关机、重启、还原镜像 | A/B/C |
|
||||
| 桌面管理 / 桌面池 | 公共/专属、模板版本、总量/空闲/占用、最大容量、回收方式 | 新建、扩容、发放、回收、维护 | B/C |
|
||||
| 桌面管理 / 镜像模板 | Windows/Ubuntu/麒麟、版本、源 VM、适用节点/存储、连接服务检查 | 登记已有模板、验证、发布/停用 | B |
|
||||
| 终端管理 | SN、名称、分组、网络在线状态、会话状态、IP、系统/Agent 版本、最近心跳 | 激活/停用、改分组、授权、重启盒子、重置会话、配置下发、收集日志 | A/B |
|
||||
| 用户与授权 | 终端用户、启用状态、可用桌面、关联终端;管理员账号与角色另设子页 | 创建/停用、分配/撤销桌面、终端关联;CSV 导入 | A/C |
|
||||
| GPU 资源 | 厂商/型号、节点、PCI 地址、健康、可分配状态、占用桌面 | 查询、绑定、解绑、维护;实际温度/显存按采集能力展示 | B |
|
||||
| 监控告警 | 会话数、FPS、网络 RTT、终端温度;节点水位;告警级别/对象/时间 | 时间范围筛选、查看会话、确认告警、规则配置 | B |
|
||||
| OTA 中心 | 版本/签名状态/适配机型、批次、目标终端、成功/失败/回滚数量 | 登记升级包、测试批次、灰度发布、暂停/继续、查失败设备 | C |
|
||||
| 任务与审计 | 任务类型、目标、操作者、提交/开始/结束时间、状态、错误;审计记录 | 查询、展开执行日志、按权限重试可重试任务 | A |
|
||||
| 系统设置 | 平台管理员、角色、Proxmox 接入、配置模板;可选身份源 | 维护权限和接入;检查依赖服务 | A/B/C |
|
||||
|
||||
补充基础页面:管理员登录、登录过期、无权限、接口异常、空列表。PVE 不可达时显示最后同步时间和“数据可能过期”,不能继续展示为实时正常。
|
||||
|
||||
### 4.1 桌面列表布局
|
||||
|
||||
```text
|
||||
桌面管理 [纳管已有 VM] [从模板创建]
|
||||
[名称/VMID 搜索] [OS] [电源状态] [桌面池] [查询]
|
||||
名称 OS 节点 电源 连接服务 授权用户 操作
|
||||
设计桌面 Windows node-a 运行中 可连接 张三 详情 / 更多
|
||||
办公桌面 Ubuntu node-a 已关机 未就绪 李四 详情 / 开机
|
||||
|
||||
详情:基本信息 | 资源配置 | 用户与终端 | 会话 | 任务记录
|
||||
底部/全局任务入口:克隆 / 启停 / 配置下发的执行状态和错误原因
|
||||
```
|
||||
|
||||
“重置会话”只结束终端连接;“重启桌面”重启 VM;“还原镜像”会回退系统盘数据,三者分别命名。PVE `status/reset` 是硬重启,不等于还原镜像。还原实现依赖已经验证的快照或重建流程,并明确用户数据盘是否保留。
|
||||
|
||||
### 4.2 创建与发放桌面
|
||||
|
||||
向导字段:桌面名称 → OS/模板版本 → 目标节点/存储 → CPU/内存/磁盘规格 → 专属/公共池 → 可选 GPU → 可选授权用户 → 确认创建。
|
||||
|
||||
检查项:模板能否在目标存储克隆、容量是否充足、GPU 是否空闲、连接服务是否已预装。完成克隆之后,还需要 VM 内机器标识/账号/IP 初始化、Sunshine 服务可用性与配对准备检查;不能把“VM 克隆成功”直接显示成“桌面可连接”。
|
||||
|
||||
### 4.3 用户 终端 桌面的关系
|
||||
|
||||
- 平台管理员账号与最终桌面使用者分开管理,管理员角色不自动获得桌面使用权限。
|
||||
- 用户可以获得多个桌面;终端显示当前身份有权使用的列表,保留现有 Windows/Ubuntu/麒麟选择能力。
|
||||
- 第一版建议保留设备绑定模式:终端注册认证后按设备授权取得列表;后续可启用账号登录模式。SN 是标识,预置密钥等才用于设备身份验证。
|
||||
- 一个盒子同时只允许一个活动桌面会话;专属桌面同一时刻的占用规则由平台检查。
|
||||
- 修改授权后刷新列表;撤销授权至少阻止后续连接,是否立即中断现有会话需形成明确策略。
|
||||
|
||||
## 5. 云端服务怎么落地
|
||||
|
||||
沿用正式文档的 Go、PostgreSQL、VictoriaMetrics、HTTPS/MQTT 和七个服务职责。首期优先实现 A 阶段路径,部署数量按原方案执行;本稿不将微服务部署形态另行改成定版结论。
|
||||
|
||||
| 服务 | 首期开发职责 |
|
||||
| --- | --- |
|
||||
| api-gateway | 统一 API、管理员认证/权限、请求 ID、审计、错误码 |
|
||||
| auth-svc | 管理账号、终端用户、授权关系;设备/用户连接授权检查 |
|
||||
| terminal-svc | 注册、在线判断、设备列表、配置版本、命令与回执 |
|
||||
| desktop-svc | PVE 接入、资源同步、VM 纳管与启停、异步任务;随后增加模板和桌面池 |
|
||||
| mqtt-bus | 心跳消费、命令投递、seq/commandId 去重、回执关联 |
|
||||
| metric-svc | 首期基础状态汇总;随后增加会话指标和告警 |
|
||||
| gpu-broker | 后续接卡清单、独占分配、直通配置和健康探测 |
|
||||
|
||||
业务表沿用原文档的 terminal、desktop、pool、user、binding、gpu_card、config_template、ota_batch、session_log;补充平台接入记录、task、command、audit_log。desktop 保存 PVE 集群/节点/VMID 映射;GPU 锁定和公共池占用必须在后端保证原子性。
|
||||
|
||||
状态分别存储:终端网络状态、终端会话状态、VM 电源状态、VM 内连接服务状态、任务状态;避免“VM 开机 = Sunshine 就绪 = 用户已连接”的错误推断。
|
||||
|
||||
## 6. 接口先定哪些
|
||||
|
||||
原文档已有接口语义优先沿用,下面的“建议补充”路径是讨论用契约,实施前统一到 OpenAPI。旧文档中 singular/plural、监控路径和方法存在差异,需要统一,不能同时做成多套业务逻辑。
|
||||
|
||||
| 接口组 | 原文档已有 | 建议补充的能力 |
|
||||
| --- | --- | --- |
|
||||
| 身份与权限 | `/api/v1/auth/login`、用户 CRUD | 当前账号/退出、角色、终端身份验证、授权查询 |
|
||||
| PVE 接入 | PVE client 职责 | 接入配置、验证连接、资源同步与纳管;接入凭据仅后端保管 |
|
||||
| 终端 | `/terminal/register`、`/terminal/{id}/config`、`/terminal/{id}/status` | 管理列表/详情、远程命令、回执、配置版本 |
|
||||
| 桌面和池 | `/desktop/assign`、`/pool`、`/pool/{id}/action` | 桌面列表/详情、VM 纳管、模板、单桌面动作 |
|
||||
| GPU | `/gpu/cards`、`/gpu/bind` | 独占校验、解绑、健康状态 |
|
||||
| 任务和审计 | 原文档有审计要求 | `GET /api/v1/tasks/{id}`、任务列表、审计列表 |
|
||||
| 终端连接 | 旧 MVP 提出终端登录/列表/票据 | 当前身份可用桌面、连接申请、短期凭据及会话事件 |
|
||||
| 监控和 OTA | `/monitor/query`、`/ota/manifest` | 受权限约束的指标查询、告警、版本/批次管理 |
|
||||
|
||||
命令型请求返回 `taskId` 或 `commandId`。页面先显示“已提交”,随后显示“排队/执行中/成功/失败/超时”;按 PVE 任务或 Agent 回执确认最终结果。超时表示结果尚未确认,需要核对实际状态后再决定能否重试;不自动重放克隆、还原等操作。MQTT QoS1 仍需业务去重。
|
||||
|
||||
连接票据是平台授权的一部分,不能凭空替代 Moonlight/Sunshine 配对和协议认证。需要单独验证“平台放行 → 终端可安全连接”的桥接方式。Guacamole/RDP 的 443 网关也不能直接作为当前 Moonlight 的网关方案。本轮先以局域网的现有串流链路接平台。
|
||||
|
||||
监控界面把指标写清楚:网络 RTT、解码耗时、FPS 分开;未采集的显示“暂无数据”,不填 0,不把 RTT 标成整体输入延迟。SN、令牌和配对信息不出现在审计正文。
|
||||
|
||||
## 7. UI 同事可以据此出什么稿
|
||||
|
||||
建议先出:应用框架/登录、桌面列表与详情、终端列表与详情、授权弹窗、创建桌面向导、任务详情六组主稿,再复用表格/表单扩展其他模块。
|
||||
|
||||
通用组件:筛选栏、状态标签、资源表格、详情抽屉、批量操作条、确认弹窗、步骤表单、任务进度、空态/错误态。规格包括正常/加载/无数据/失败/无权限,以及桌面运行中/关机/创建中/故障等状态。
|
||||
|
||||
权限沿用三角色:超级管理员负责接入、用户授权与 GPU;运维负责常规设备/桌面操作和升级;只读账号只查看。最终按服务端 RBAC 执行,原型的角色切换仅用于交互演示。
|
||||
|
||||
附带功能原型展示十个导航模块及桌面操作、终端命令、授权和任务回执。所有变更只影响演示数据,UI 可整体替换视觉;阶段 B/C 的原型页面不代表对应后台已经可用。
|
||||
|
||||
## 8. 开工前需要形成结论的三个问题
|
||||
|
||||
1. 外部接口清单:除了 PVE,是否还有既有账号、设备管理、OTA 或 GPU 调度服务?提供方和责任边界是什么?
|
||||
2. 身份模式:首发保持“盒子绑定后自动显示桌面”,还是增加“使用者输入账号登录”?两者页面和认证接口不同。
|
||||
3. 首发范围:建议 A 阶段作为首轮联调,B/C 作为后续功能包;是否有客户必须首发验收的模板、公共池或 OTA 功能?
|
||||
|
||||
这些问题不影响当前功能稿和原型评审;真实接口联调和最终排期以结论为准。
|
||||
47
docs/cloud-console-ui-20261109.md
Normal file
@ -0,0 +1,47 @@
|
||||
# 管理控制台 UI 接入记录
|
||||
|
||||
日期:2026-11-09。设计来源为仓库提交 `8276da0` 的 `ui/index.html` 和 `ui/design-spec.md`。本次将后台设计接入 `console/` 的真实 Vue 应用;`ui/terminal/` 仍是终端设计参考,本次没有部署终端界面改动。
|
||||
|
||||
## 页面与样式
|
||||
|
||||
复用设计稿品牌标识、蜂蜜金橙主色、深浅背景色、18px 圆角、卡片、左侧导航及账号区域。补充页面使用同一套 token 与 Element Plus 图标。Ubuntu、麒麟图标复用设计资源;品牌资源的源文件仍保留在 `ui/`。
|
||||
|
||||
| 页面 | 当前接入 |
|
||||
| --- | --- |
|
||||
| 登录 | 品牌区域、管理员表单、错误提示、主题切换 |
|
||||
| 总览 | 实际运行桌面、活动连接、已上报 RTT、异常任务、节点水位、最近任务;导出当前汇总 CSV |
|
||||
| 桌面管理 | 按设计稿使用系统卡片,保留列表视图、筛选、详情、纳管和电源操作 |
|
||||
| 远程会话 | 在线设备的连接预约和已连接会话、已上报 FPS/RTT、终端信息、远程断开确认和任务回执 |
|
||||
| 终端管理 | 登记、激活信息、列表、详情、远程命令弹窗统一主题 |
|
||||
| 计算资源 | 节点表格、水位与过期状态统一主题 |
|
||||
| 用户与授权 | 用户列表、创建、绑定与授权弹窗统一主题 |
|
||||
| 任务与审计 | 列表、筛选、任务详情和诊断下载统一主题 |
|
||||
| 系统设置 | 分类导航、平台接入、权限矩阵、已接入与待开发能力 |
|
||||
|
||||
首次访问默认深色,可切换为浅色;选择保存在本浏览器的 `vdi.theme` 中。窄屏使用折叠侧栏,表格在组件内横向滚动。主题覆盖表格、表单、下拉菜单、弹窗、详情抽屉、确认框和错误状态。
|
||||
|
||||
## 数据和操作边界
|
||||
|
||||
设计稿中的 128 台桌面、18ms、60FPS、趋势图与 GPU 使用率没有复制到真实应用。未采集指标显示“暂无数据”,历史使用趋势尚无存储来源,因此总览相应位置展示真实节点水位。帧率请求参数与实测 FPS 分开展示。
|
||||
|
||||
远程会话页复用现有 `/terminals`、`/desktops`、`/users` 和任务接口。仅在线设备的 `connecting` / `connected` 状态显示在会话页;连接预约不计为实际已连接。断开操作使用现有 `reset-session`,提交后等待设备回执。管理端没有接入远程画面采集,因此预览区明确显示“画面预览未接入”。
|
||||
|
||||
本次没有新增桌面池、克隆、GPU 调度、策略下发或 OTA 后端;系统设置的功能范围显示这些待开发项。没有增加浏览器串流连接按钮或可保存但不生效的策略开关。
|
||||
|
||||
## 验证
|
||||
|
||||
- 前端类型检查、ESLint、3 项已有单元测试、生产构建通过。
|
||||
- 4 项隔离的 Playwright UI 测试通过:会话指标为空、取消与确认断开、只读角色、连接预约、主题持久化和窄屏导航。命令由浏览器测试接口拦截,不发送到真实盒子。
|
||||
- 使用现场真实账号验证 8 个后台页面的深浅主题,在 1440px 和 390px 下无整页横向溢出;检查卡片/列表、空态、详情抽屉、登记弹窗、权限页、快照下载和退出登录。测试没有提交 PVE 电源或设备操作。
|
||||
- 页面截图保存在 `docs/evidence/cloud-console-ui-20261109/`。完整矩阵与检查输出保存在本机忽略提交的 `.local/ui-review/`。含活动连接的测试截图由 `e2e/ui.spec.ts` 使用隔离数据生成,不能作为现场串流验收证据。
|
||||
|
||||
## 启动和维护
|
||||
|
||||
现场入口仍为 `http://127.0.0.1:5182`,账号与数据库沿用当前真实环境。正常启动见[真实接入说明](cloud-live-integration.md)。只更新前端时可执行:
|
||||
|
||||
```powershell
|
||||
docker compose --env-file .local/live/.env -f deploy/compose.live.yml build console
|
||||
docker compose --env-file .local/live/.env -f deploy/compose.live.yml up -d --no-deps console
|
||||
```
|
||||
|
||||
样式集中在 `console/src/styles/tokens.css`,主题逻辑为 `useTheme.ts`,公共导航为 `ConsoleLayout.vue`。本次未提交或推送本机所有开发改动。
|
||||
89
docs/cloud-live-integration.md
Normal file
@ -0,0 +1,89 @@
|
||||
# 现场真实接入
|
||||
|
||||
更新:2026-11-09。与原演示环境分开,当前 API 使用 `production` 模式和独立 PostgreSQL,不生成演示节点、桌面或用户。服务运行在开发机 Docker Desktop;这是接入现场设备的联调环境,尚未迁到独立云服务器。
|
||||
|
||||
## 日常启动与入口
|
||||
|
||||
打开 Docker Desktop,在项目根目录运行:
|
||||
|
||||
```powershell
|
||||
.\tools\start-cloud-live.ps1
|
||||
```
|
||||
|
||||
代码修改后加 `-Build`。控制台为 `http://127.0.0.1:5182`,初始管理员账号密码保存在本机忽略提交的 `.local/live/admin-login.txt`。原 `5181` 仍是独立演示环境,不作为现场入口。正式数据库命名卷 `vdi-cloud-live_vdi-live-postgres`;停止容器不会删除数据,勿执行 `down -v`。
|
||||
|
||||
盒子通过 `https://192.168.1.159:8443` 访问平台,校验专用 CA;HTTP 控制台端口只映射本机。开发机必须保持运行、内网地址可达,IP 变化时需更新证书、Compose 地址和盒子配置。浏览器从其他机器访问 HTTPS 时,需要信任本机生成的 CA;本轮没有将 CA 安装进系统证书库。
|
||||
|
||||
## 当前现场映射
|
||||
|
||||
| 对象 | 接入方式 |
|
||||
| --- | --- |
|
||||
| PVE `192.168.1.51` / 节点 `starhe` | 专用 `vdi-cloud@pve!console` Token;CA 验证开启,证书 DNS 名 `starhe` 通过容器 extra_hosts 映射实际 IP |
|
||||
| Windows VM100 / `.70` | 纳管已有 VM,2560×1440 / 45FPS 请求 |
|
||||
| 麒麟 VM101 / `.71` | 纳管已有 VM,2560×1440 / 30FPS 请求 |
|
||||
| Ubuntu VM102 / `.72` | 纳管已有 VM,2560×1440 / 45FPS 请求 |
|
||||
| 盒子 `.188` / SN `ee621cae7199a2e9` | Qt 平台适配器 `qt-platform/0.2.0`,设备 Token、HTTPS 心跳和命令轮询 |
|
||||
|
||||
FPS 是串流请求参数,不是实际性能验收值。桌面与设备 ID 由数据库生成;本机 `.local/live/site-state.json` 保存本轮映射,不把固定演示 ID 用于真实环境。
|
||||
|
||||
PVE Token 继承专用用户权限:根路径 `PVEAuditor` 负责只读发现,只有 `/vms/100`、`/vms/101`、`/vms/102` 授予自定义 `VDICloudPower`(`VM.Audit VM.PowerMgmt`)。子路径 ACL 会覆盖继承权限,因此该角色同时包含 VM.Audit。平台没有 PVE root 密码、宿主重启、VM 创建、删除、改硬件权限。
|
||||
|
||||
## 终端实际流程
|
||||
|
||||
1. 管理员登记 SN,生成一次性激活密钥。盒子读取 `~/.config/vdi-terminal/platform.json`,通过注册接口取得设备 Token,以 0600 原子保存并删除激活密钥。
|
||||
2. 每 30 秒发送真实心跳;每 15 秒在选择页刷新授权桌面;每 3 秒领取远程命令。平台配置存在时不回退到本地授权或本地票据,列表与连接参数由平台维护。
|
||||
3. 点击连接后申请、消费单次票据。消费只登记“连接中”,Moonlight 实际画面就绪后才上报 connected,计入活动连接。
|
||||
4. 自然退出、连接失败、管理员重置都会按 sessionId 释放预约。结束事件和回执写入待发队列,网络恢复后重试;已执行的命令不自动重放。旧会话事件或迟到的重置回执不能清理新会话。
|
||||
5. “重置会话”等待 Moonlight 进程真正退出后回执。“收集日志”上传受限诊断信息与脱敏日志尾部;管理员/运维可在成功任务详情下载,只读角色不可读取。
|
||||
6. “重启盒子”通过固定 `sudo -n /usr/bin/systemctl reboot` 调用,记录原 boot ID,下一次启动确认 boot ID 改变后才回执;未做现场重启验收。没有远程任意命令或 shell 参数接口。
|
||||
|
||||
本轮由 Qt 直接接入已有设备 API;未将它称为 Rust Agent 或 MQTT。Moonlight/Sunshine 继续使用原有配对;平台票据不是 Sunshine 的新认证方式。没有修改串流编码、光标、显示守护、USB 隧道配置。
|
||||
|
||||
## 初次配置其他现场
|
||||
|
||||
安装 Python 的 `paramiko`、`cryptography` 后,先确认 SSH known_hosts 中存在核验过的 PVE 主机公钥,再执行:
|
||||
|
||||
```powershell
|
||||
python tools/provision-pve-access.py --host <PVE-IP> --vmids <VM-ID...> --output .local/live/pve-access.json
|
||||
python tools/prepare-live-config.py --ip <平台内网-IP> --pve-hostname <PVE证书内DNS名称> --pve-ip <PVE-IP>
|
||||
.\tools\start-cloud-live.ps1 -Build
|
||||
```
|
||||
|
||||
PVE SSH 密码通过交互提示或 `VDI_PVE_SSH_PASSWORD` 读取;工具不在控制台打印 Token。已存在配置或账号时会停止,防止覆盖已有身份。不要将本轮现场的 `.local/live` 文件复制给其他客户。
|
||||
|
||||
从控制台发现并纳管 VM、登记设备和创建用户授权。盒子配置格式如下,路径按现场用户调整:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://平台内网地址:8443",
|
||||
"caFile": "/home/armbian/.config/vdi-terminal/platform-ca.pem",
|
||||
"activationSecret": "控制台生成的一次性密钥"
|
||||
}
|
||||
```
|
||||
|
||||
配置和证书通过受信 SSH 安装;配置权限 0600。注册成功后由终端维护 deviceToken、待发事件和回执,不再手工覆盖。备份时同时保留数据库、PVE Token、平台 CA 私钥和终端身份文件,均按凭据管理。
|
||||
|
||||
## 盒子回退
|
||||
|
||||
本轮初始备份路径在盒子 `~/vdi-terminal/backups/platform-live-current-backup` 指向的目录。备份包括接入前 kiosk-shell、client.conf、desktops.json。维护时先结束串流,再原子恢复该 kiosk-shell 并将 platform.json 移至受限备份目录,由原有监控进程重启 kiosk。恢复后是原本的本地 POC 模式,不再从平台授权。无需回退 Moonlight 二进制或重启 VM。
|
||||
|
||||
只撤销 PVE 接入时,可禁用 `vdi-cloud@pve` 用户或移除 `console` Token;不要删除 root 或其他账号。独立部署、用户管理与密码轮换、登录限流、备份恢复演练、设备撤销/重新激活、OTA 和多租户仍属于后续产品化范围。
|
||||
|
||||
## 本轮现场验收
|
||||
|
||||
- PVE 严格 CA/DNS 验证通过,发现并纳管三台真实 VM;节点/VM 状态每 30 秒自动同步。
|
||||
- 盒子实际注册,心跳上报 `qt-platform/0.2.0`;控制台显示 1 台在线,串流请求使用现场原有配对。
|
||||
- Windows、Ubuntu、麒麟分别从真实授权列表申请并消费票据,实际画面就绪事件被平台记录;控制台分别下发 reset-session,Moonlight 实际退出后收到成功回执,最终活动连接均归零。
|
||||
- 首次 Windows 重置因同步检查异步退出而错误返回 failed,已保留任务;修复为异步等待后重测通过,未删除失败记录。
|
||||
- Go 测试与专用 PostgreSQL 集成测试、go vet 通过,新增覆盖会话事件隔离、预约不冒充活动串流、日志读取权限与迟到回执不清理新会话。盒子 CTest 3/3、前端静态检查、类型检查、单元测试和 Docker 构建通过。
|
||||
- 现场 Playwright 页面流程通过:正式登录、3 台真实桌面、真实设备在线、下发日志采集、收到成功回执并下载实际 boot ID/uptime/日志内容。截图见 [真实总览](evidence/cloud-live-20261109/overview.png)、[真实桌面](evidence/cloud-live-20261109/desktops.png)。
|
||||
- 现场 kiosk-shell SHA256:`2cca9d2d183c2f34f0f6172db207649decb2799414f9443043d175bbb19d33a1`。Moonlight 二进制未更换。
|
||||
|
||||
浏览器现场用例独立执行,避免运行演示库用例操作真实 VM:
|
||||
|
||||
```powershell
|
||||
cd console
|
||||
npx playwright test --config=playwright.live.config.ts
|
||||
```
|
||||
|
||||
该用例读取本机私有初始账号文件,验证正式登录、真实桌面/设备以及日志收集下载,不发 VM 电源操作。完整演示 E2E 仍要求 demo 模式;数据库集成测试仍只指向专用 vdi_test。
|
||||
144
docs/cloud-platform-development.md
Normal file
@ -0,0 +1,144 @@
|
||||
# 云端服务与控制台开发说明
|
||||
|
||||
更新:2026-11-09。当前交付是第一阶段本地可运行版本。Vue 控制台通过真实 HTTP 调用 Go API,账号、授权、设备、任务和审计保存在 PostgreSQL;演示 VM 的电源操作由明确标记的演示计算适配执行。本文件主要描述演示开发环境;随后已接通[独立真实环境](cloud-live-integration.md),控制台端口 5182、设备 HTTPS 8443。
|
||||
|
||||
## 启动
|
||||
|
||||
### Windows 本机开发
|
||||
|
||||
需要 Node.js 22.12+、Go 1.25+、Docker Desktop。仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
.\tools\start-cloud-dev.ps1
|
||||
```
|
||||
|
||||
已安装前端依赖时可加 `-SkipInstall`。脚本启动专用 PostgreSQL 容器,编译并启动本机 API,再启动 Vite。默认控制台地址 `http://127.0.0.1:5180`,API 地址 `http://127.0.0.1:8088`,数据库仅映射本机 `55438`。可用 `-ApiPort` 和 `-ConsolePort` 改开发端口。不会停止其他占用端口的程序。
|
||||
|
||||
演示账号 `admin`、`operator`、`viewer`,统一演示密码 `Demo@2026`。对应超级管理员、运维、只读角色,仅 `VDI_MODE=demo` 会创建这些账号。首次数据库初始化后,重新启动保留业务数据。
|
||||
|
||||
进程 PID 和日志在忽略提交的 `.local/` 中。关闭本机开发进程时根据 `.local/cloud-dev.json` 确认 PID 和命令路径,只停止本次启动的 API 与 Vite。数据库可通过 `docker compose -f deploy/compose.dev.yml stop postgres` 停止;不删除卷即可保留数据。
|
||||
|
||||
### 全容器开发
|
||||
|
||||
与本机模式二选一,避免占用同一端口:
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/compose.dev.yml up --build -d
|
||||
```
|
||||
|
||||
控制台仍是 `http://127.0.0.1:5180`。Nginx 代理同源 `/api` 到 Go 服务,PostgreSQL 使用命名卷。该 compose 是开发演示配置,不是客户生产部署包。
|
||||
|
||||
端口被其他开发进程占用时,可先设置 `$env:VDI_CONSOLE_PORT='5181'`、`$env:VDI_API_PORT='8089'` 再执行 compose。当前本机验收使用该端口组合,预览地址为 `http://127.0.0.1:5181`。
|
||||
|
||||
### 仅前端模拟
|
||||
|
||||
```powershell
|
||||
cd console
|
||||
npm ci
|
||||
$env:VITE_API_MODE='mock'
|
||||
npm run dev
|
||||
```
|
||||
|
||||
MSW 在浏览器层模拟 HTTP,只有开发构建允许启用,不需要 Go 或数据库。模拟操作不控制真实资源,刷新页面会重置模拟业务数据。正常的 `api` 模式不注册模拟 Worker。
|
||||
|
||||
## 当前可用范围
|
||||
|
||||
| 模块 | 当前实现 |
|
||||
| --- | --- |
|
||||
| 登录 | bcrypt 密码校验、JWT、服务端会话记录、退出撤销、刷新页面恢复、三角色服务端授权 |
|
||||
| 总览 | 终端在线、VM 电源、实际画面就绪连接、异常任务;连接预约单独记录 |
|
||||
| 计算资源 | 节点列表;配置 PVE 后每 30 秒同步节点/已纳管 VM,显示同步时间与过期标记 |
|
||||
| 桌面 | 演示桌面;发现 PVE VM、纳管、查看详情、开机/正常关机/重启;PVE UPID 跟踪 |
|
||||
| 终端 | 管理员登记、一次性激活、设备独立令牌、心跳、桌面授权列表、命令领取与回执 |
|
||||
| 授权 | 创建最终使用者、每设备关联一位使用者、每使用者多个桌面、停用和撤销后阻止新连接 |
|
||||
| 任务 | 持久化排队/执行/成功/失败/结果未确认、请求去重、同对象任务互斥、最终回执审计 |
|
||||
| 连接契约 | 短期单次票据、终端隔离、消费时重新校验授权、同桌面活动占用检查 |
|
||||
|
||||
当前连接票据仅授权并返回 Moonlight 端点与参数;终端仍需既有 Sunshine 配对。消费票据登记连接预约,不证明串流已建立;现场 Qt 已上报实际画面就绪与进程结束事件,详情见真实接入说明。不要把 `credentialMode=existing-pairing` 当作免配对认证。
|
||||
|
||||
本轮将 API 入口与基础业务模块放在一个可运行的 Go 网关进程中,数据库和 Proxmox client 独立封装。原方案的七个服务职责继续作为后续拆分边界;当前尚未交付七个独立部署的服务。MQTT 总线、Rust Agent、VictoriaMetrics 指标接入、模板发放、桌面池、GPU 绑定、OTA、AD/LDAP 尚未完成;Qt 适配现已实际部署。控制台仅显示已接通的第一阶段功能。
|
||||
|
||||
## 真实 Proxmox 接入
|
||||
|
||||
使用新的独立数据库,按 `platform/.env.example` 注入配置。正式模式要求至少 32 字符的 JWT 密钥、管理员账号和至少 12 字符的初始密码。管理员环境变量仅在账号首次创建时生效,不会在每次启动时覆盖已有密码。数据库记录初始化模式,拒绝直接混用演示与正式数据库。
|
||||
|
||||
```text
|
||||
VDI_MODE=production
|
||||
DATABASE_URL=postgres://.../vdi?sslmode=require
|
||||
VDI_JWT_SECRET=...
|
||||
VDI_ADMIN_USERNAME=...
|
||||
VDI_ADMIN_PASSWORD=...
|
||||
PVE_URL=https://pve.example.internal:8006
|
||||
PVE_TOKEN_ID=vdi@pve!console
|
||||
PVE_TOKEN_SECRET=...
|
||||
PVE_CA_FILE=/run/secrets/pve-ca.pem
|
||||
```
|
||||
|
||||
PVE 凭据由后端持有,前端和终端不会接收;证书校验始终开启。PVE_URL 只填 HTTPS origin,不附 `/api2/json`。由运维给 API Token 配置实际需要的节点/VM/存储查询和 VM 电源操作权限。本轮不自动修改 PVE 账号、ACL、VM 或主机配置。
|
||||
|
||||
启动后到“计算资源”手动同步;再到“桌面管理”选择“纳管已有 VM”。纳管仅登记已存在的 VM,不改 VM 配置。连接地址可在纳管表单填写。首次真实接入应使用专门测试 VM 核验 API 版本、证书、任务结果和关机行为,再对业务 VM 开放操作。
|
||||
|
||||
正式环境的 HTTPS 入口、运维凭据轮换、账号维护、登录限流、独立数据库迁移流程与备份恢复仍属于部署产品化工作;当前 compose 不作生产就绪声明。
|
||||
|
||||
## 设备接口联调
|
||||
|
||||
接口契约在 `docs/openapi/platform.v1.json`,API 根路径 `/api/v1`。控制台通过生成的类型和统一请求适配调用接口,页面不拼接 API 地址。
|
||||
|
||||
1. 管理员在终端页登记 SN,取得仅显示一次的 `activationSecret`。
|
||||
2. 终端 `POST /terminal/register` 提交 SN、activationSecret、agentVersion,取得设备令牌;激活密钥随即作废。
|
||||
3. 设备以独立 `Authorization: Bearer <device-token>` 发送心跳、拉配置和获取授权桌面。
|
||||
4. 管理员创建用户并关联终端、勾选多个桌面;设备再次请求 `/terminal/desktops` 即获得更新列表。
|
||||
5. 设备轮询 `GET /terminal/commands` 领取命令,执行后 `POST /terminal/commands/{id}/ack` 报告 success/failed。没有回执的命令约两分钟后显示结果未确认;不会自动重放。
|
||||
6. 申请 `/terminal/connect-ticket`,再用 `/terminal/connect-ticket/consume` 单次消费;消费时再次检查授权与桌面占用。之后仍使用已有 Moonlight/Sunshine 配对。
|
||||
|
||||
`tools/platform-terminal-simulator.py --sn <SN>` 可在本机演示平台注册、发心跳和读取授权桌面。激活密钥通过交互输入或 `VDI_DEVICE_ACTIVATION_SECRET` 传入;模拟器对所有设备操作回失败,避免假装执行了重启或日志收集。它不是可部署的 Rust Agent。
|
||||
|
||||
当前 HTTPS 轮询是 MQTT 接入前的可验证通道;没有声称已提供消息总线。终端令牌的撤销/重新激活、配置热更新将继续扩展契约;Qt 串流事件、固定命令执行和诊断日志上传已在真实环境接通。
|
||||
|
||||
## 检查命令
|
||||
|
||||
```powershell
|
||||
cd console
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
npm run test:e2e
|
||||
npm run api:generate
|
||||
```
|
||||
|
||||
E2E 使用已经启动的本机平台,只允许对 `/healthz` 返回 demo 模式的服务运行;Windows 默认使用已安装的 Edge。其他环境使用 `npx playwright install chromium` 准备测试浏览器。`VDI_E2E_URL` 可覆盖测试地址。测试会创建演示用户和待激活终端、切换演示 VM 电源,不会操作真实 PVE。
|
||||
|
||||
```powershell
|
||||
cd platform
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
数据库集成测试需要 `VDI_TEST_DATABASE_URL` 指向**专用可清空测试数据库**。测试会清空该数据库内平台表,不要指向开发业务库或生产库。
|
||||
|
||||
```powershell
|
||||
docker exec vdi-cloud-dev-postgres-1 psql -U vdi -d postgres -c 'CREATE DATABASE vdi_test'
|
||||
$env:VDI_TEST_DATABASE_URL='postgres://vdi:vdi-local-demo@127.0.0.1:55438/vdi_test?sslmode=disable'
|
||||
go test ./...
|
||||
```
|
||||
|
||||
后端测试覆盖服务端 RBAC、JWT 退出失效、幂等任务、授权变更事务回滚、设备隔离、一次性票据、回执和超时;PVE client 使用本地测试服务器验证 TLS 和 UPID,不向现场 PVE 发送请求。
|
||||
|
||||
## 本轮验收记录(2026-11-09)
|
||||
|
||||
- Go 单元测试、专用 PostgreSQL 集成测试和 `go vet` 通过;包含模式隔离、重启后撤权保留、PVE 明确失败与提交结果未确认的区分。
|
||||
- 前端 ESLint、TypeScript、3 项登录状态单元测试和生产构建通过。
|
||||
- 容器版 `http://127.0.0.1:5181` 的 7 项 Playwright 用例全部通过:登录失败/恢复/退出、桌面命令最终结果、用户多桌面授权持久化、终端激活信息、两种权限角色、空列表/异常/401。
|
||||
- 单独启动 MSW 模式,验证模拟登录和授权页导航;检查 1440px 桌面及 390px 窄屏。
|
||||
- 页面截图:[总览](evidence/cloud-console-20261109/overview.png)、[桌面管理](evidence/cloud-console-20261109/desktops.png)、[窄屏](evidence/cloud-console-20261109/narrow.png)。页面主题集中在 `console/src/styles/tokens.css`,可交给 UI 同事继续调整。
|
||||
- Node 依赖完整审计为 0 项已知漏洞。Vitest 已更新;TypeScript ESLint 的 minimatch 子依赖定向覆盖至 11.0.1+,静态检查通过。
|
||||
- Docker API/控制台镜像构建并启动,数据库保留卷内已有业务数据。未向现场 PVE 或盒子发送操作。
|
||||
|
||||
## 数据与执行边界
|
||||
|
||||
- 在线按最近 90 秒心跳计算;FPS/RTT 缺失为 null,不填 0。PVE 电源状态与 VM 内连接服务状态分开。
|
||||
- 任务成功必须有计算适配结果或设备回执。提交后发生通信异常时保留“结果未确认”,由管理员核对实际状态,不自动重试。
|
||||
- 桌面“重启”只用 PVE reboot;未将硬重启 reset 冒充“还原镜像”。
|
||||
- 当前任务和审计列表展示最近 1000 条,完整记录仍存 PostgreSQL;长期检索和归档后续扩展。
|
||||
- 凭据不写入审计;设备上报的自由文本不直接放入公开任务结果。
|
||||
58
docs/current-status.md
Normal file
@ -0,0 +1,58 @@
|
||||
# 当前状态
|
||||
|
||||
截至 2026-11-09,依据仓库中的实施记录整理。本次文档整理没有重新连接现场设备,下面的“已验证”指相应日期的历史验收,并非实时健康报告。
|
||||
|
||||
## 产品与架构
|
||||
|
||||
当前主线是 RK3528 盒子上的 Qt kiosk,通过 Moonlight/Sunshine 接入 Windows、Ubuntu、麒麟。盒子开机进入“选择云桌面”,选择后启动串流,结束后回到列表。11-09 已在独立 production 数据库接通 PVE VM100/101/102 和真实盒子:设备注册/心跳、授权列表、一次性票据、三系统串流事件与远程会话重置。服务目前在开发机 Docker,详见[真实接入说明](cloud-live-integration.md)。
|
||||
|
||||
```text
|
||||
当前:LightDM 独占 vdi-kiosk 会话 → 看门狗 → Qt 桌面列表 → Moonlight → Sunshine 客体
|
||||
目标:终端 → 自研管理平台 API → Proxmox/KVM 管理桌面与授权
|
||||
```
|
||||
|
||||
终端不保存 PVE 管理凭据、不直接调用 PVE API;仓库 `tools/` 中的 PVE/Guest Agent 操作属于管理员部署工具。
|
||||
|
||||
## 联调基线
|
||||
|
||||
以下内网地址仅标识本次联调环境,新客户使用本地配置或平台下发地址。
|
||||
|
||||
| 对象 | 最近记录的配置/状态 | 需要注意 |
|
||||
| --- | --- | --- |
|
||||
| 盒子 `.188` | RK3528 PRO DDR4,ARM64,Armbian/Ubuntu Noble,LightDM + Xorg 独占会话 | 正式 Weston/Wayland 尚未落地;不运行 MATE 桌面/面板 |
|
||||
| PVE `.51` | Proxmox/KVM,节点 starhe | GTX 965M 分配给 Windows;宿主资源有限 |
|
||||
| Windows VM100 / `.70` | Windows 10,GTX 965M/NVENC,VDD 单活动主屏 2560×1440@60Hz,串流请求 45FPS | 历史实测约 43~44FPS,不能称稳定 2K60;曾有重启 Code43 |
|
||||
| 麒麟 VM101 / `.71` | 2560×1440,30FPS 请求,H.264/libx264,30Mbps;std VGA 64MB,2 vCPU | 11-06 确认 UKUI/KWin 合成器关闭;动态场景约 23FPS |
|
||||
| Ubuntu VM102 / `.72` | 2560×1440,近期测试请求 45FPS,H.264/libx264,30Mbps;cpu=host、virtio-gl | KMS 与 X11 路径随原生光标租约/普通服务切换,不能把早期 KMS 测试概括为所有会话固定 KMS |
|
||||
| 监控 `.51:8090` | 独立 `vdi-monitor` 项目 | 实时状态去监控页面查看;本仓库只保留开发与排障资料 |
|
||||
|
||||
盒子实际使用私有 RKMPP 硬解/EGL 路径;早期文档中的“仅软解、1080p、Linux 未接入”已过时。桌面 FPS 是请求值,不等于实际动态收流/呈现帧率,实际值以 `desktops.json` 与该次日志为准。
|
||||
|
||||
## 最近已完成
|
||||
|
||||
| 事项 | 记录与验证边界 |
|
||||
| --- | --- |
|
||||
| 开机直接显示桌面列表 | 11-09 独占 LightDM 会话;保留[冷启动截图](evidence/boot-direct-list-cold-boot.png) |
|
||||
| 退出短暂露出绿色桌面 | 11-09 列表全屏常驻串流窗口下方;三系统两轮共 3121 个 X11 采样未见底层窗口 |
|
||||
| 进入左上角小窗口 | 11-09 Moonlight 0007 隐藏 CLI Qt 窗口,SDL 创建时铺满屏幕;三系统进入 2695 次、返回 1562 次采样均无违规 |
|
||||
| 显示器关开恢复 | 11-03 显示守护恢复定制 2K 模式,用户现场确认自动恢复全屏 |
|
||||
| Linux 原生独立光标 | 11-05 起已接入形状/热点/显隐与本地位置;有短时故障恢复和点击验证 |
|
||||
| 鼠标按钮残留 | 11-06 失焦及退出时释放五个按钮,输入相关测试通过 |
|
||||
| 客体图形关机/重启限制 | 11-04 按当前用户部署 Windows NoClose 与 Linux polkit/D-Bus 规则;菜单逐项目视及重启后验证不完整 |
|
||||
| U 盘真实透传 | Windows 已识别真实 USBSTOR、完成文件校验与实物拔插;后续扩展纯存储设备策略 |
|
||||
| 固定 PCI 设备不可弹出 | 11-05 Windows 冷启动后四项能力属性 6→0,GPU/网络/系统盘正常;托盘外观和真实 U 盘组合仍需现场验收 |
|
||||
|
||||
进入/退出结果来自 X11 窗口采样与截图,没有外置 HDMI 逐帧采集,不能外推为物理屏幕每一帧的保证。
|
||||
|
||||
## 重要限制与历史冲突
|
||||
|
||||
- Ubuntu 原生光标能即时移动,但点击后页面、拖窗内容仍要等待虚拟显示捕获与 CPU 编码。11-05 KMS 45FPS 长按框选 P50/P95 约 196/247ms;这是合成注入到解码画面的测试,不是物理端到端延迟。
|
||||
- 11-06 Intel VAAPI 直通试验虽识别到编码器,但无法稳定建立显示捕获/串流,已经回退。普通 Sunshine 恢复 X11/software,VM102 无 Intel hostpci;不能写成硬件编码优化成功。
|
||||
- Ubuntu 显存 64MB 试验变差,已恢复默认;Windows 独立光标只有历史受控原型,当前仍保留视频光标。
|
||||
- 11-02 GPU Code43 诊断是历史故障记录;11-03 恢复后实测 NVENC,11-05 冷启动检查也为错误码 0。现网是否再次退回软编必须重新查询。
|
||||
- 快捷键捕获参数已接入;系统电源键策略补丁、客体截图组件和实际传递效果需分别核验,不凭配置就宣称 Win+Shift+S 已完成现场验收。
|
||||
- 盒子开关控制客户端连接,不等于控制 VM 生命周期;平台联动尚未实现。
|
||||
- 第一阶段 Go API、Vue 控制台与 PostgreSQL 已有本机可运行实现,含登录/RBAC、资源/桌面/终端、用户授权、任务审计和 PVE 适配;详见[云管开发说明](cloud-platform-development.md)。随后已用独立账号接通现场 PVE 与 Qt 盒子,三系统实际串流与重置会话通过;PVE 电源操作和盒子重启没有通过现场重启测试验收。
|
||||
- Rust Agent、MQTT、OTA、多租户授权及通用 USB 会话隔离仍待产品化。已有 POC 模块不等于完整功能交付。
|
||||
|
||||
当前构建、策略和排障入口分别见[终端说明](terminal-guide.md)、[云桌面说明](desktop-guide.md)、[运维说明](operations.md);待办统一见[未来计划](roadmap.md)。历史精确配置和回退路径可从[归档索引](archive/README.md)查原文。
|
||||
89
docs/desktop-guide.md
Normal file
@ -0,0 +1,89 @@
|
||||
# 云桌面说明
|
||||
|
||||
状态以[当前状态](current-status.md)为入口,以下归纳三种系统的显示、输入、外设和电源策略。日期是现场实施日期,本次整理没有改动镜像或服务。
|
||||
|
||||
<a id="windows"></a>
|
||||
## Windows 显示与编码
|
||||
|
||||
Windows VM100 使用 GTX 965M 直通、Sunshine NVENC 和 VDD 虚拟显示器。只保留 VDD 为活动主屏,目标分辨率 2560×1440、显示刷新率 60Hz,Moonlight 请求 45FPS;11-03 实际渲染约 43~44FPS。提高请求到 60FPS、换队列/VSync/nearest 采样没有形成稳定 2K60 收益,精确尺寸采样实验未采用。
|
||||
|
||||
GPU 历史上曾出现 Code43,11-02 的 D3cold、ROM 副本、快速启动等实验没有可靠修复,均回退;之后运维修复,11-03 实际 NVENC、11-05 冷启动错误码 0 已验证。故障排查要同时看 `nvidia-smi`、设备错误码、Sunshine 的实际 `h264_nvenc` 和真实会话编码占用,不能只看显卡名称。
|
||||
|
||||
工具入口:[set-2k-display.ps1](../tools/set-2k-display.ps1)、[set-fixed-pci.py](../tools/set-fixed-pci.py)。历史 [2K45 证据](evidence/2k60-20261103/)、[单屏证据](evidence/single-display-20261103/)保留。当前 Windows 使用视频内的单个远程光标;11-03 的独立光标受控原型已收尾,没有进入产品默认路径。
|
||||
|
||||
<a id="linux"></a>
|
||||
## Ubuntu 与麒麟画质、捕获和延迟
|
||||
|
||||
两种 Linux 已接入真实 2560×1440、H.264、30Mbps、盒子 RKMPP 硬解,并按主机处理 limited 色彩范围协商;Ubuntu 光标恢复 Adwaita/24px 和默认加速,保留关闭动画。不要把“2K 请求参数”视为服务端已经真实输出 2K,要同时核对 XRandR、捕获日志及截图。
|
||||
|
||||
| 系统 | 保留的调整 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| Ubuntu VM102 | cpu=host、virtio-gl、原生光标;曾验证 KMS + 原生光标与 45FPS | 原服务在 11-06 直通失败回退后为 X11/software;原生光标租约使用的捕获路径需另查配置 |
|
||||
| 麒麟 VM101 | std VGA 64MB、2 vCPU、真实 2K/30FPS;Sunshine 启动后暂停 UKUI/KWin 合成器 | 11-06 确认合成器 active=false;动态基准约 23FPS,对照开启约 19FPS,仍是 CPU 编码 |
|
||||
|
||||
Ubuntu 的可比较历史数据来自 11-05 同场景合成注入:
|
||||
|
||||
| 场景 | KMS 45FPS | X11 45FPS |
|
||||
| --- | --- | --- |
|
||||
| 长按框选回显 P50/P95 | 约 196/247ms | 约 250/312ms |
|
||||
| 视频中可见新编号更新 | 16.0 次/秒 | 10.6 次/秒 |
|
||||
|
||||
同轮实际 GTK 按钮回显约 304/378ms、masked 输入约 165/182ms;没有将合成提交冒充真实业务登录成功。这些数值不包含物理 USB 与面板扫描,也不等于光标移动延迟。原始结果见 [Ubuntu 深度调优](evidence/ubuntu-deep-latency-20261105/)。
|
||||
|
||||
11-06 的两项失败实验都已撤回:
|
||||
|
||||
- **Intel 核显直通/VAAPI**:Ubuntu 识别 i915 与编码入口,但虚拟显示捕获到编码链路不稳定,出现 KMS monitor 阻塞和 launch 超时;已恢复 VM102 的 virtio-gl、无 hostpci0、普通 Sunshine X11/software。详见[试验最终状态](evidence/ubuntu-latency-plan-20261106/intel-vaapi-trial-final.json)。
|
||||
- **虚拟显存 64MB**:短样本收流约 27.49FPS、主机处理平均 83.4ms,恢复默认后约 32.64FPS/60.8ms,未保留 64MB 参数。见 [VRAM 对照](evidence/ubuntu-vram-20261106/)。
|
||||
|
||||
VirGL 不等于可用的硬件编码。增加编码线程、虚拟显存或修改鼠标速度不能消除 CPU 捕获/编码瓶颈;后续路线见[未来计划](roadmap.md#performance)。
|
||||
|
||||
<a id="cursor"></a>
|
||||
## 输入顺序与 Linux 原生独立光标
|
||||
|
||||
客户端输入补丁保持绝对移动与点击边界顺序,优先处理输入,最新帧队列避免堆积旧视频;这是输入正确性和排队优化,不能承诺零延迟。11-06 的 [0006 补丁](../terminal/moonlight/patches/0006-vdi-release-stuck-mouse-buttons.patch) 在 SDL 失焦与退出时释放左、中、右、X1、X2 五个按钮,修复丢失 BUTTONUP 后点击/拖拽卡住的问题。
|
||||
|
||||
Linux 原生光标实现见 [terminal/native-cursor](../terminal/native-cursor/README.md) 和 Moonlight 0005。通过受限 SSH 通道同步实际形状、热点、显隐及必要位置校正,本地指针移动不等待视频;连接前启用无光标捕获,整次会话结束后恢复普通服务,避免视频内与本地同时出现光标。
|
||||
|
||||
当前约束:单座席 X11、单屏 2560×1440、绝对鼠标,形状最大 128×128。客户端状态超时约 2 秒,主机租约 4 秒;未知形状、分辨率变化或连接故障时结束会话并恢复服务,不做未经确认的运行中切换。客户端和 Linux profile 的 SHA 必须匹配,升级/回退成对处理。
|
||||
|
||||
已验证两系统 7 类形状/热点/显隐、28 次编号点击、故障清理及 Ubuntu 约 6 分钟状态读取;短时暂停 1.1 秒可恢复,持续失效会退出。完整真实失焦、长期稳定、多屏/缩放/Wayland/相对鼠标尚未全面验收。11-09 进入/退出回归沿用原生光标。证据见 [native-cursor](evidence/native-cursor-20261105/);当前二进制校验值见[终端说明](terminal-guide.md#build)。
|
||||
|
||||
<a id="power"></a>
|
||||
## 客体桌面“关机/重启”按钮
|
||||
|
||||
11-04 已按当前客户账户部署持久策略,独立于盒子键盘过滤。目标是禁止客户从桌面图形入口关机、重启、睡眠和休眠,保留管理员与 PVE 维护能力。
|
||||
|
||||
| 系统/账户 | 策略与首次备份 |
|
||||
| --- | --- |
|
||||
| Windows VM100 / test | Explorer `NoClose=1`;`C:\ProgramData\VDI\power-policy\original.clixml` |
|
||||
| 麒麟 VM101 / test1 | polkit `.pkla` + D-Bus 用户级电源方法拒绝;`/var/lib/vdi/power-policy/` |
|
||||
| Ubuntu VM102 / test2 | polkit `.rules` + D-Bus 用户级电源方法拒绝;同上 |
|
||||
|
||||
Linux 仅用 polkit 不够:当时实际图形进程的基础 reboot 授权仍有残留,因此额外使用 `/etc/dbus-1/system.d/zz-vdi-desktop-power.conf` 拒绝该用户的 login1 电源方法与 Can* 查询。麒麟 polkit 文件为 `/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla`,Ubuntu 为 `/etc/polkit-1/rules.d/00-vdi-power.rules`。
|
||||
|
||||
Windows 交互 Shell 有效策略读回为 1;Linux 八类方法使用无效签名安全探测,均 Access denied,七类 Can* 查询拒绝。未发送真实关机请求,未逐项目视验收菜单刷新,也未完成重启后的全流程验收。策略不限制管理员绕过,不改变注销/锁屏和更新行为。新账户需重新应用。
|
||||
|
||||
管理员部署/查询/回退入口为 [deploy-guest-power-policy.py](../tools/deploy-guest-power-policy.py),子工具为 [Windows](../tools/set-guest-power-policy.ps1) 与 [Linux](../tools/set-guest-power-policy.py)。`apply` 可替换成 `query` 或 `restore`,重复应用不覆盖首次备份。例:
|
||||
|
||||
```powershell
|
||||
python tools/deploy-guest-power-policy.py query --vm 100
|
||||
python tools/deploy-guest-power-policy.py query --vm 101 --user test1
|
||||
python tools/deploy-guest-power-policy.py query --vm 102 --user test2
|
||||
```
|
||||
|
||||
证据见 [guest-power-policy](evidence/guest-power-policy-20261104/)。盒子断电仅断开客户端,不等于云端 VM 随之关机;物理开关联动尚未实现。
|
||||
|
||||
<a id="usb"></a>
|
||||
## U 盘透传与 Windows 安全删除菜单
|
||||
|
||||
当前 POC 路径为盒子物理 USB → Rust `vdi-usb-monitor` → 私有 usbredirect → 回环 SSH 隧道 → QEMU usb-redir → Windows 自带 USBSTOR。显示仍走 Moonlight,盒子不保存 PVE 凭据。
|
||||
|
||||
已由固定 Kingston 身份扩展为“任意纯 USB Mass Storage”:所有接口都必须为 class 08,键鼠、Hub、混合 HID 设备拒绝;发现本地挂载、holder、swap 占用时等待,不强制卸载。一个盒子/VM 同时导出一个存储设备。udev 事件即时唤醒,500ms 扫描兜底,必须等接口枚举完整后再判断设备。
|
||||
|
||||
已经验收 Kingston 真实盘符、1MiB 随机文件往返 SHA 一致与一次实物拔插;另一只 SanDisk 双分区 Healthy;用户确认不再自动开两个文件夹、提示菜单响应改善。私有 usbredir 0.15.0 避免旧 exporter 持续占约一个 CPU 核的问题,观察降至约 1%。没有完成多盘大文件吞吐、异常断网/掉电、VM/PVE 重启恢复及多用户隔离验收。
|
||||
|
||||
管理员部署入口为 [deploy-usb-device-poc.py](../tools/deploy-usb-device-poc.py) 的 `--any-storage`;盒子策略在 `/etc/vdi-usb-monitor/device.env`,服务 `vdi-usb-monitor.service`,PVE 隧道 `vdi-usb-tunnel-100.service`。QMP watcher 的源码能力、当时是否现场安装、后续 monitor 交接版本须分别确认;`vdi-monitor` 已记录保留端点的 `--keep-attached` 版本,但不能反推任意运行环境都有同一部署。
|
||||
|
||||
11-05 为 Windows 固定 PCI 设备关闭 Q35 的 ACPI PCI/SHPC/PCIe root-port 热插拔,正常关机再启动后四项能力从 6→0,GPU/网络/系统盘正常。USB 设备级热插拔保留,内部 PCI 增删改为关机维护。此配置不能只在 Windows 点重启生效,必须让 QEMU 完整退出再启动。托盘实际外观与真实 U 盘插入/安全弹出仍待组合验收,不能承诺隐藏全部虚拟化标识。
|
||||
|
||||
回退先在 Windows 安全弹出并物理拔盘,再停止导出服务;只还原本功能配置、公钥行和二进制,不整份覆盖后续更新。精确备份路径及步骤见[历史归档](archive/README.md),测试见 [USB 原型](evidence/usb-device-20261103/) 和 [固定 PCI](evidence/fixed-pci-20261105/)。
|
||||
@ -1,70 +0,0 @@
|
||||
# 2026-11-03 显示器关屏再开后云桌面变矮:修复与验收
|
||||
|
||||
已在 `armbian@192.168.1.188` 部署。用户现场关闭显示器、等待后重新打开,确认“已自动恢复全屏”。修复过程没有重启盒子,也没有重启或重连 Moonlight。
|
||||
|
||||
## 原因与证据
|
||||
|
||||
- 异常时盒子 HDMI 输出为 **1920×1440@75Hz(4:3)**,Windows/Moonlight 视频为 **2560×1440(16:9)**。
|
||||
- Moonlight 全屏窗口跟随盒子变为 1920×1440,视频保持宽高比,因此视频内容为 1920×1080,上下各有约 180 像素黑边,表现为“宽度一样,高度矮了一点”。异常截图见 `evidence/display-resume-20261103/before.png`。
|
||||
- MATE 的 `~/.config/monitors.xml` 还保存着 HDMI-1 的 1920×1440@75Hz 配置;原启动脚本只在启动时设置一次 2K,没有处理后续热插拔和模式回退。
|
||||
- 显示器 EDID 的 241.70MHz 时序仍不适用于此 RK3528 vendor PHY;沿用此前已验证的 297MHz 定制 2K 时序,不切回该原厂时序。
|
||||
|
||||
## 修改
|
||||
|
||||
1. 新增 `terminal/kiosk-shell/scripts/vdi-display-guard`:每 2 秒检查 XRandR 状态,模式异常持续至少 2 秒后恢复 `2560x1440_60_fixed297`。模式已存在时不重复创建,热插拔后丢失关联时重新关联。
|
||||
2. 健康状态只查询,不反复设置分辨率;显示器断开或 X11 尚不可用时等待,不强设;设置失败按 2/4/8/16/32/60 秒退避,并在设置后重新读取验证。XRandR 每条命令有 10 秒超时。
|
||||
3. 新增用户级 `vdi-display-guard.service`,已启用 `default.target` 自启、异常退出重启;使用文件锁防止启动恢复与常驻守护竞争。服务独立受 systemd 管理,不挂无主后台循环。
|
||||
4. `vdi-kiosk-start` 使用相同恢复程序做启动检查,再确保服务运行;保留此前渲染配置解析及单一 kiosk 启动链。
|
||||
5. 将本机已知显示器(HDMI-1,GWR/0x2708)的旧保存布局改为 2560×1440@60,其余布局不改。部署前保存原文件。
|
||||
6. 新增只部署显示修复的 `tools/deploy_display_guard.sh`;完整 `deploy_kiosk.sh` 也接入该步骤,避免下次部署覆盖修复。此次只执行前者,未执行会停止串流的完整部署。
|
||||
|
||||
时序仍为:
|
||||
|
||||
```text
|
||||
297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync
|
||||
```
|
||||
|
||||
本次仅调整盒子本地显示,不修改 Windows、Sunshine、GPU、远程光标或已有硬解参数。
|
||||
|
||||
## 验收
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 自动化显示恢复场景 | 9/9 通过:正常不写、断开/重连、首次创建、重新关联、晚到覆盖、失败退避、设置结果校验、X11 暂不可用等 |
|
||||
| 盒子 CTest | 3/3 通过:display-guard、moonlight-session、desktop-list-page |
|
||||
| 连续两次主动回退 1920×1440@75 | 均自动恢复 2560×1440,全屏窗口恢复 2560×1440,单次从发起切换到观测恢复约 14 秒(含两次驱动切换耗时) |
|
||||
| 真实显示器电源循环 | 用户确认自动恢复全屏;日志在 10:03:19 检测断开,10:03:31 检测到正确 2K 模式,无需额外模式设置 |
|
||||
| 会话连续性 | Moonlight PID 始终为 66385,窗口始终为 44040203;模拟测试前后 boot ID 不变;服务未发生异常重启 |
|
||||
| 最终现场 | 2026-11-03 10:04,HDMI 2560×1440 约 60Hz,Moonlight 窗口 2560×1440,守护 active/enabled |
|
||||
|
||||
原始测试输出:`evidence/display-resume-20261103/recovery-test.txt`。最终模式、进程及包含真实关屏过程的日志:`evidence/display-resume-20261103/final-state.txt`。真实开屏后的截图:`evidence/display-resume-20261103/after-power-cycle.png`。
|
||||
|
||||
这是当前这一台盒子与显示器的现场验收,未执行隔夜或多型号显示器测试。守护固定针对当前单 HDMI、2K、Xorg POC;以后换不同显示器或迁移到 Weston/Wayland 时需改为对应的输出配置机制。当前修复不代表服务端 Code43、帧率或鼠标动态残影问题已解决。
|
||||
|
||||
## 运维
|
||||
|
||||
Windows 开发机在 Git Bash 中只部署此修复:
|
||||
|
||||
```bash
|
||||
bash tools/deploy_display_guard.sh
|
||||
```
|
||||
|
||||
盒子上查看状态与日志(当前环境 `journalctl --user` 看不到用户服务日志,因此使用系统日志的用户单元过滤):
|
||||
|
||||
```bash
|
||||
systemctl --user status vdi-display-guard.service
|
||||
sudo -n journalctl _SYSTEMD_USER_UNIT=vdi-display-guard.service -n 50 --no-pager
|
||||
DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority xrandr --query
|
||||
```
|
||||
|
||||
本次部署备份目录为 `/home/armbian/vdi-terminal/backups/display-guard-20261103-095920/`,包含原 `vdi-kiosk-start` 和原 `monitors.xml`。必要时可回退到原行为;这也会恢复旧的 4:3 保存布局和“仅启动时修复”的限制:
|
||||
|
||||
```bash
|
||||
systemctl --user disable --now vdi-display-guard.service
|
||||
cp -p ~/vdi-terminal/backups/display-guard-20261103-095920/vdi-kiosk-start ~/vdi-terminal/bin/vdi-kiosk-start
|
||||
cp -p ~/vdi-terminal/backups/display-guard-20261103-095920/monitors.xml ~/.config/monitors.xml
|
||||
rm -- ~/.config/systemd/user/vdi-display-guard.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
日常操作:盒子保持开机,显示器可正常关开。若 HDMI 模式确实回退,给驱动和恢复程序约 15 秒完成切换,无需为此重启盒子。
|
||||
BIN
docs/evidence/boot-direct-list-cold-boot.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/evidence/boot-direct-list-final.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/evidence/boot-direct-list.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/evidence/cloud-console-20261109/desktops.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/evidence/cloud-console-20261109/narrow.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/evidence/cloud-console-20261109/overview.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
@ -0,0 +1,11 @@
|
||||
{
|
||||
"mode": "production",
|
||||
"baseURL": "http://127.0.0.1:5182",
|
||||
"realLogin": true,
|
||||
"desktops": 3,
|
||||
"terminalOnline": true,
|
||||
"darkAndLight": true,
|
||||
"mobile": true,
|
||||
"browserErrors": [],
|
||||
"pveOrTerminalCommandsSubmitted": 0
|
||||
}
|
||||
BIN
docs/evidence/cloud-console-ui-20261109/desktops-dark.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/evidence/cloud-console-ui-20261109/desktops-light.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/evidence/cloud-console-ui-20261109/login-dark.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/evidence/cloud-console-ui-20261109/overview-dark.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/evidence/cloud-console-ui-20261109/sessions-light.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/evidence/cloud-live-20261109/desktops.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/evidence/cloud-live-20261109/kiosk.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
5
docs/evidence/cloud-live-20261109/kylin-stream.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"actualStreamReady": true,
|
||||
"resetTaskId": "task_566520ad31e9352714b4a30594e8f634",
|
||||
"realResetAcknowledged": true
|
||||
}
|
||||
BIN
docs/evidence/cloud-live-20261109/overview.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
5
docs/evidence/cloud-live-20261109/ubuntu-stream.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"actualStreamReady": true,
|
||||
"resetTaskId": "task_15c7a5e4709d3709fb7e4186cc15d295",
|
||||
"realResetAcknowledged": true
|
||||
}
|
||||
16
docs/evidence/cloud-live-20261109/validation.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"overview": {
|
||||
"activeSessions": 0,
|
||||
"desktopCount": 3,
|
||||
"failedTasks": 1,
|
||||
"mode": "production",
|
||||
"onlineTerminals": 1,
|
||||
"runningDesktops": 3,
|
||||
"terminalCount": 1
|
||||
},
|
||||
"terminalVersion": "qt-platform/0.2.0",
|
||||
"successfulResets": 3,
|
||||
"successfulDiagnostics": 2,
|
||||
"vmPowerCommandsSubmitted": 0,
|
||||
"initialResetFailureRetained": true
|
||||
}
|
||||
5
docs/evidence/cloud-live-20261109/windows-stream.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"actualStreamReady": true,
|
||||
"resetTaskId": "task_962bb63138b4c1f36d84ce8b7dc41aff",
|
||||
"realResetAcknowledged": true
|
||||
}
|
||||
107
docs/evidence/entry-surface-20261109/entry-final-0.json
Normal file
@ -0,0 +1,107 @@
|
||||
{
|
||||
"desktop_index": 0,
|
||||
"entry_samples": 819,
|
||||
"stream_foreground": true,
|
||||
"returned_to_kiosk": true,
|
||||
"first_mapped_moonlight_geometry": {
|
||||
"14680073": {
|
||||
"t": 4.6786,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
},
|
||||
"14680075": {
|
||||
"t": 5.2771,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
}
|
||||
},
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 0.0114,
|
||||
"windows": [
|
||||
6291462,
|
||||
6291462,
|
||||
6291462
|
||||
],
|
||||
"classes": {
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"6291462": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 0.045,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"6291462": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 4.6786,
|
||||
"windows": [
|
||||
14680073,
|
||||
14680073,
|
||||
14680073
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"14680073": "com.moonlight_stream.Moonlight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 5.1954,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 5.2771,
|
||||
"windows": [
|
||||
14680075,
|
||||
14680075,
|
||||
14680075
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"14680075": "com.moonlight_stream.Moonlight"
|
||||
}
|
||||
}
|
||||
],
|
||||
"violations": [],
|
||||
"x11_errors": [],
|
||||
"ok": true
|
||||
}
|
||||
81
docs/evidence/entry-surface-20261109/entry-final-1.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"desktop_index": 1,
|
||||
"entry_samples": 908,
|
||||
"stream_foreground": true,
|
||||
"returned_to_kiosk": true,
|
||||
"first_mapped_moonlight_geometry": {
|
||||
"14680073": {
|
||||
"t": 5.6925,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
},
|
||||
"14680079": {
|
||||
"t": 6.3673,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
}
|
||||
},
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 5.6801,
|
||||
"windows": [
|
||||
14680073,
|
||||
14680073,
|
||||
14680073
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 6.1982,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 6.2755,
|
||||
"windows": [
|
||||
14680079,
|
||||
14680079,
|
||||
14680079
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
}
|
||||
],
|
||||
"violations": [],
|
||||
"x11_errors": [],
|
||||
"ok": true
|
||||
}
|
||||
107
docs/evidence/entry-surface-20261109/entry-final-2.json
Normal file
@ -0,0 +1,107 @@
|
||||
{
|
||||
"desktop_index": 2,
|
||||
"entry_samples": 968,
|
||||
"stream_foreground": true,
|
||||
"returned_to_kiosk": true,
|
||||
"first_mapped_moonlight_geometry": {
|
||||
"14680073": {
|
||||
"t": 5.8743,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
},
|
||||
"14680079": {
|
||||
"t": 6.4936,
|
||||
"class": "com.moonlight_stream.Moonlight",
|
||||
"geometry": [
|
||||
0,
|
||||
0,
|
||||
2560,
|
||||
1440
|
||||
]
|
||||
}
|
||||
},
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
6291462,
|
||||
6291462,
|
||||
6291462
|
||||
],
|
||||
"classes": {
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"6291462": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 0.0107,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 5.8743,
|
||||
"windows": [
|
||||
14680073,
|
||||
14680073,
|
||||
14680073
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"14680073": "com.moonlight_stream.Moonlight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 6.4012,
|
||||
"windows": [
|
||||
6291472,
|
||||
6291472,
|
||||
6291472
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 6.4817,
|
||||
"windows": [
|
||||
6291472,
|
||||
14680079,
|
||||
14680079
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t": 6.4936,
|
||||
"windows": [
|
||||
14680079,
|
||||
14680079,
|
||||
14680079
|
||||
],
|
||||
"classes": {
|
||||
"6291462": "vdi-kiosk-shell",
|
||||
"6291472": "vdi-kiosk-shell",
|
||||
"14680079": "com.moonlight_stream.Moonlight"
|
||||
}
|
||||
}
|
||||
],
|
||||
"violations": [],
|
||||
"x11_errors": [],
|
||||
"ok": true
|
||||
}
|
||||
BIN
docs/evidence/entry-surface-20261109/entry-final-list.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/evidence/entry-surface-20261109/entry-live-0.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
docs/evidence/entry-surface-20261109/entry-live-1.png
Normal file
|
After Width: | Height: | Size: 754 KiB |
BIN
docs/evidence/entry-surface-20261109/entry-live-2.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@ -0,0 +1,32 @@
|
||||
{
|
||||
"desktop_index": 0,
|
||||
"samples": 525,
|
||||
"kiosk": 6291462,
|
||||
"stream": 14680075,
|
||||
"stream_windows": [
|
||||
14680075
|
||||
],
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
14680075,
|
||||
14680075,
|
||||
14680075,
|
||||
14680075
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 0.5698,
|
||||
"windows": [
|
||||
6291462,
|
||||
6291462,
|
||||
6291462,
|
||||
6291462
|
||||
]
|
||||
}
|
||||
],
|
||||
"returned_to_kiosk": true,
|
||||
"violations": [],
|
||||
"ok": true
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
{
|
||||
"desktop_index": 1,
|
||||
"samples": 527,
|
||||
"kiosk": 6291462,
|
||||
"stream": 16777231,
|
||||
"stream_windows": [
|
||||
16777231
|
||||
],
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
16777231,
|
||||
16777231,
|
||||
16777231,
|
||||
16777231
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 0.6019,
|
||||
"windows": [
|
||||
6291462,
|
||||
6291462,
|
||||
6291462,
|
||||
6291462
|
||||
]
|
||||
}
|
||||
],
|
||||
"returned_to_kiosk": true,
|
||||
"violations": [],
|
||||
"ok": true
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
{
|
||||
"desktop_index": 2,
|
||||
"samples": 510,
|
||||
"kiosk": 6291462,
|
||||
"stream": 14680079,
|
||||
"stream_windows": [
|
||||
14680079
|
||||
],
|
||||
"stack_changes": [
|
||||
{
|
||||
"t": 0.0,
|
||||
"windows": [
|
||||
14680079,
|
||||
14680079,
|
||||
14680079,
|
||||
14680079
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 0.5974,
|
||||
"windows": [
|
||||
6291462,
|
||||
6291462,
|
||||
6291462,
|
||||
6291462
|
||||
]
|
||||
}
|
||||
],
|
||||
"returned_to_kiosk": true,
|
||||
"violations": [],
|
||||
"ok": true
|
||||
}
|
||||