diff --git a/docs/VDI云桌面完整方案-云管端.docx b/docs/VDI云桌面完整方案-云管端.docx new file mode 100644 index 0000000..82808df Binary files /dev/null and b/docs/VDI云桌面完整方案-云管端.docx differ diff --git a/docs/VDI云桌面软件开发方案(1).docx b/docs/VDI云桌面软件开发方案(1).docx new file mode 100644 index 0000000..3571d48 Binary files /dev/null and b/docs/VDI云桌面软件开发方案(1).docx differ diff --git a/docs/VDI云终端产品方案-定版摘要(1).docx b/docs/VDI云终端产品方案-定版摘要(1).docx new file mode 100644 index 0000000..85ce573 Binary files /dev/null and b/docs/VDI云终端产品方案-定版摘要(1).docx differ diff --git a/docs/api-free-rdp3.md b/docs/api-free-rdp3.md deleted file mode 100644 index 6011043..0000000 --- a/docs/api-free-rdp3.md +++ /dev/null @@ -1,33 +0,0 @@ -# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev) - -> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。 -> 记录时间:2026-10-23 - -## 包名 -- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3` -- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`) -- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准) - -## 关键头文件 -| 头文件 | 用途 | -|---|---| -| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free | -| `freerdp/freerdp.h` | 核心上下文 | -| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) | -| `freerdp/channels/cliprdr.h` | 剪贴板通道 | -| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) | -| `freerdp/crypto/tls.h` | TLS | - -## 最小连接流程(伪代码) -```c -freerdp_client_context* ctx = freerdp_client_context_new(); -RdpClientContext* cctx = ...; -freerdp_settings_new(0); // 释放: freerdp_settings_free -freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调 -ctx->Connect = ...; // 事件表 -freerdp_client_context_new / connect -``` - -## 注意事项 -- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`), - 单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。 diff --git a/docs/freerdp3-embedding-notes.md b/docs/freerdp3-embedding-notes.md deleted file mode 100644 index 0f4de08..0000000 --- a/docs/freerdp3-embedding-notes.md +++ /dev/null @@ -1,67 +0,0 @@ -# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1) - -> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。 -> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64) - -## 1. API 结构(与 FreeRDP 2.x 差异很大) - -| 用途 | FreeRDP 3 写法 | -|---|---| -| 创建实例 | `freerdp* instance = freerdp_new();` | -| 建立上下文 | `freerdp_context_new_ex(instance, settings)` | -| 上下文 | `instance->context`(`rdpContext*`,公开结构体) | -| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` | -| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` | -| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` | -| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` | - -设置项键名(与 2.x 不同): -`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` / -`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` / -`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` … - -## 2. 三个必须踩过才知道的坑 - -### 坑一:gdi_init 必须在 PostConnect 里调用 -在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV** -(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`) -把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。 - -### 坑二:不能用 instance->ContextSize 扩展 rdpContext -常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize` -重新分配 context 缓冲区,扩展字段随即失效 → 段错误。 -改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的 -`RdpgfxClientContext.custom` 字段。 - -### 坑三:NLA 认证必须传空字符串而非 nullptr -Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致 -`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。 -认证回调签名: -`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)` - -## 3. /gfx 语法(命令行与设置项的对应) -FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合: - -``` -/gfx:progressive|RFX|AVC420|AVC444 ← 可解析 -/gfx:AVC420 ← parsing failed [-1000] -/gfx:AVC444 ← parsing failed [-1000] -``` - -API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。 - -## 4. 像素格式 -`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。 -QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。 -注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。 - -## 5. 后续演进(对应 freerdp-rk 分支) -当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为: - -```text -FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf) -→ waylandsink(DRM plane) -``` - -盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件 -(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。 diff --git a/docs/idd-install-helper.bat b/docs/idd-install-helper.bat new file mode 100644 index 0000000..a7addb8 --- /dev/null +++ b/docs/idd-install-helper.bat @@ -0,0 +1,39 @@ +@echo off +REM ============================================================ +REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行) +REM 用法:右键本文件 -> 以管理员身份运行 +REM ============================================================ +setlocal +cd /d "%~dp0" + +echo [1/4] 关闭可能占用驱动的程序... +sc stop SunshineService >nul 2>&1 +taskkill /f /im sunshine.exe >nul 2>&1 + +echo [2/4] 安装驱动签名证书... +certutil -addstore -f "Root" "Virtual_Display_Driver.cer" +if errorlevel 1 ( + echo [错误] 证书安装失败,请确认以管理员身份运行 + pause + exit /b 1 +) + +echo [3/4] 安装 MttVDD 驱动... +pnputil /add-driver "MttVDD.inf" /install +if errorlevel 1 ( + echo [警告] 驱动安装返回错误,尝试另一种方式... + rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf +) + +echo [4/4] 显示当前虚拟显示器状态... +pnputil /enum-devices /class Display + +echo. +echo ============================================================ +echo 安装完成。请重启 Windows 后: +echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器 +echo 2) 分辨率选 1920x1080,刷新率选 60Hz +echo 3) 重启 SunshineService 服务 +echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动 +echo ============================================================ +pause diff --git a/docs/rdp-vs-moonlight-comparison-20261028.md b/docs/rdp-vs-moonlight-comparison-20261028.md new file mode 100644 index 0000000..2a2f8ba --- /dev/null +++ b/docs/rdp-vs-moonlight-comparison-20261028.md @@ -0,0 +1,82 @@ +# RDP 与 Moonlight 实测对比(2026-10-28) + +## 测试环境 + +```text +终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg +显示器:1920×1440 @ 75Hz +云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe +终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi +``` + +服务端显示环境改造(本次关键前提): + +```text +改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz +改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz +``` + +改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。 + +## 对比数据 + +| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 | +|---|---|---|---| +| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 | +| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 | +| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% | +| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB | +| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ | +| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS | +| 网络丢包 | 未测 | 0.00% | 0.00% | +| 网络抖动丢帧 | 未测 | 0.08% | 15.28% | +| 网络延迟 | 未测 | 2 ms | 6 ms | +| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms | +| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms | +| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 | + +## 结论 + +1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。** + Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂, + 抓屏确认 1080p 画面文字锐利、任务栏时间正确。 + +2. **Moonlight 的瓶颈在服务端,不在终端,也不���网络。** + 1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%, + 但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。 + 服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在), + 4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。 + +3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%), + 因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`, + 而 Moonlight 只做一次解码和一次窗口呈现。 + +4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。** + `h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报 + `Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和 + /dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。 + +## 后续建议 + +优先级从高到低: + +1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占) + 直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS + 提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、 + `vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。 +2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。 +3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。 +4. **协议选型建议**: + - 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议: + 普通办公用 RDP,GPU/CAD/3D 用 Moonlight; + - 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造, + 使 RDP 在办公场景达到可接受体验。 +5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力, + 需在 4K 目标确定后优先解决硬解。 + +## 附:本次变更 + +- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。 +- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。 +- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。 +- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。 diff --git a/docs/sunshine-vm-setup-for-colleague.md b/docs/sunshine-vm-setup-for-colleague.md new file mode 100644 index 0000000..1e3e337 --- /dev/null +++ b/docs/sunshine-vm-setup-for-colleague.md @@ -0,0 +1,74 @@ +# Sunshine 显卡环境配置指引(转给操作 VM 的同事) + +## 背景 + +我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。 + +不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。 + +## 第 1 步:安装虚拟显示器驱动(IddSampleDriver) + +让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率: + +1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。 + 常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases + (如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ ) +2. 解压后右键 `IddSampleDriver.inf` →「安装」。 +3. 重启 Windows。 +4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。 +5. 把虚拟显示器设置为: + - 分辨率:1920×1080(先用 1080p,稳定后再试 2K) + - 刷新率:60Hz(关键!当前 1Hz 是主要问题) + - 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。 + +## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动) + +打开 PowerShell 执行: + +```powershell +nvidia-smi +``` + +- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。 + - 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。 +- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。 + +## 第 3 步:检查 Sunshine 服务状态 + +Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。 + +PowerShell 执行: + +```powershell +Get-Service SunshineService +Restart-Service SunshineService +``` + +重启服务后再看日志确认识别到新显示器: + +```powershell +Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60 +``` + +需要看到的关键行(类似): + +``` +Desktop resolution [1920x1080] +Display refresh rate [60 Hz] +``` + +## 第 4 步:告知我们结果 + +只需回三条信息: + +1. 虚拟显示器是否装好,分辨率/刷新率是多少; +2. `nvidia-smi` 是否有输出; +3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。 + +我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。 + +## 备注 + +- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345 +- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。 +- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。 diff --git a/docs/vdd_settings.xml b/docs/vdd_settings.xml new file mode 100644 index 0000000..7a1aaed --- /dev/null +++ b/docs/vdd_settings.xml @@ -0,0 +1,20 @@ + + + + + 0 + 0 + 1920 + 1080 + 60 + + 0 + + + 1920 + 1080 + 60 + + + + diff --git a/docs/web-console-mvp-plan-20261028.md b/docs/web-console-mvp-plan-20261028.md new file mode 100644 index 0000000..d3e6b9a --- /dev/null +++ b/docs/web-console-mvp-plan-20261028.md @@ -0,0 +1,287 @@ +# VDI Web 管理控制台 MVP 实施方案 + +> 版本:v0.1(功能先行,UI 稿待项目经理提供) +> 日期:2026-10-28 +> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。 + +## 1. 先明确两个“登录” + +本项目有两个不同的界面,必须分开: + +| 界面 | 使用者 | 运行位置 | 职责 | +|---|---|---|---| +| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 | +| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 | + +正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。 + +“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为: + +```text +盒子开机 + -> kiosk-shell 全屏终端登录/首配页 + -> vdt-agent 注册并获取终端身份 + -> 用户登录或终端绑定校验 + -> 平台返回该用户可用的桌面列表及短期连接信息 + -> 用户选择桌面 + -> kiosk-shell 按 protocol=rdp|moonlight 启动会话 + -> 断开后回桌面列表/登录页 +``` + +## 2. Web 控制台 MVP 目标 + +第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统: + +1. 用户能登录和退出; +2. 根据角色控制菜单和按钮; +3. 管理员能查看终端、桌面、用户及绑定关系; +4. 能对终端下发命令、对桌面执行开关机/重置; +5. 能查看基础状态、操作结果和审计记录; +6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。 + +## 3. 技术栈与工程结构 + +按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。 + +建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。 + +```text +console/ +├─ src/ +│ ├─ api/ # OpenAPI 生成 SDK 的薄封装 +│ ├─ assets/ +│ ├─ components/ # 领域无关组件 +│ ├─ layouts/ # 登录布局、控制台布局 +│ ├─ mocks/ # MSW handlers 与演示数据 +│ ├─ router/ # 路由、鉴权守卫、RBAC meta +│ ├─ stores/ # auth、app、notification +│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理 +│ ├─ views/ +│ │ ├─ login/ +│ │ ├─ dashboard/ +│ │ ├─ terminals/ +│ │ ├─ desktops/ +│ │ ├─ users/ +│ │ ├─ gpu/ +│ │ ├─ monitoring/ +│ │ ├─ ota/ +│ │ └─ audit/ +│ ├─ App.vue +│ └─ main.ts +├─ e2e/ +├─ public/ +├─ .env.development +├─ .env.mock +├─ package.json +└─ vite.config.ts +``` + +约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。 + +## 4. 页面与优先级 + +### M0:登录与应用框架 + +- 登录页:用户名、密码、登录按钮、错误状态、加载状态; +- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出; +- 401 自动清理会话并回登录; +- 403 权限提示; +- 路由级 RBAC:`super_admin`、`operator`、`viewer`; +- 开发环境使用 Mock 登录,生产环境只调用真实 API。 + +### M1:核心演示闭环 + +1. 仪表盘:在线终端、活动会话、运行桌面、告警数量; +2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话; +3. 桌面管理:列表、状态筛选、开机、关机、重启、重置; +4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑; +5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。 + +M1 完成后已经可以给项目经理演示完整业务流程。 + +### M2:完整文档范围 + +- 桌面池:模板、容量、回收策略; +- GPU 池:节点/PCI 地址/型号/状态/绑定桌面; +- 监控:会话在线数、FPS、RTT、节点水位、告警; +- OTA 中心:版本、批次、灰度比例、进度、失败终端; +- CSV 用户/绑定批量导入向导; +- 配置模板与配置版本下发。 + +## 5. 登录与权限方案 + +### 登录接口 + +```http +POST /api/v1/auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "******" +} +``` + +建议响应: + +```json +{ + "accessToken": "jwt", + "expiresIn": 3600, + "user": { + "id": "usr_001", + "username": "admin", + "displayName": "系统管理员", + "role": "super_admin" + } +} +``` + +建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。 + +### 权限矩阵 + +| 能力 | super_admin | operator | viewer | +|---|---:|---:|---:| +| 查看所有页面 | 是 | 是 | 是 | +| 终端远程命令 | 是 | 是 | 否 | +| 桌面生命周期操作 | 是 | 是 | 否 | +| 用户/绑定修改 | 是 | 否 | 否 | +| GPU 绑定 | 是 | 否 | 否 | +| OTA 发布/灰度 | 是 | 是 | 否 | +| 只读监控/审计 | 是 | 是 | 是 | + +前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。 + +## 6. 第一版 API 契约 + +文档已明确的接口优先沿用: + +```text +POST /api/v1/auth/login +GET /api/v1/users +POST /api/v1/desktop/assign +POST /api/v1/pool/{id}/action +GET /api/v1/gpu/cards +POST /api/v1/gpu/bind +GET /api/v1/monitor/query +``` + +为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI): + +```text +GET /api/v1/auth/me +POST /api/v1/auth/logout +GET /api/v1/terminals +GET /api/v1/terminals/{id} +POST /api/v1/terminals/{id}/commands +GET /api/v1/desktops +GET /api/v1/desktops/{id} +POST /api/v1/desktops/{id}/actions +GET /api/v1/bindings +DELETE /api/v1/bindings/{id} +GET /api/v1/audit-logs +``` + +所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应: + +```json +{ + "items": [], + "page": 1, + "pageSize": 20, + "total": 0, + "requestId": "req_xxx" +} +``` + +所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。 + +## 7. Mock 先行策略 + +后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP: + +- 固定 3 个测试账号,对应三角色; +- 终端包含在线/离线/会话中/升级中; +- 桌面包含运行/停止/错误/任务中; +- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`; +- 登录失败、401、403、网络超时、空列表都要有测试场景; +- `VITE_API_MODE=mock|real` 切换数据源。 + +项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。 + +## 8. 与终端登录流程的接口边界 + +管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。 + +建议终端用户登录流程由 `auth-svc` 单独提供: + +```text +POST /api/v1/terminal/session/login +GET /api/v1/terminal/session/desktops +POST /api/v1/terminal/session/connect-ticket +``` + +`connect-ticket` 返回短期、一次性连接信息: + +```json +{ + "desktopId": "desk_001", + "protocol": "moonlight", + "endpoint": "192.168.1.70", + "app": "Desktop", + "display": { "width": 1920, "height": 1080, "fps": 60 }, + "ticket": "short-lived-token", + "expiresAt": "2026-10-28T16:30:00+08:00" +} +``` + +这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。 + +## 9. UI 稿到来前的页面设计原则 + +第一版只做稳定工作台,不做营销式页面: + +- 左侧导航 + 顶部工具栏 + 主内容区; +- 列表优先,详情使用独立页或抽屉; +- 状态使用图标/标签,不依赖单一颜色; +- 危险操作二次确认; +- 所有异步操作显示提交、执行中、成功、失败四态; +- 页面级空态、错误态、无权限态必须完整; +- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS; +- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。 + +## 10. 验收标准 + +M1 应达到: + +1. 三种角色都能登录,菜单/按钮权限正确; +2. 刷新页面仍能恢复会话,令牌过期自动回登录; +3. 终端、桌面、用户/绑定、审计五条流程可演示; +4. 命令有执行中与最终结果,不出现“点击无反馈”; +5. API 全走统一 SDK/适配层,无页面直连 Axios; +6. Vitest 覆盖 auth store、路由守卫、权限判断; +7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径; +8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。 + +## 11. 推荐实施顺序 + +```text +第 1 步:初始化 console 工程、路由、布局、设计 token +第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock +第 3 步:登录、会话恢复、RBAC、错误处理 +第 4 步:终端管理 + 桌面管理 +第 5 步:用户与绑定 + 审计日志 +第 6 步:仪表盘聚合数据 +第 7 步:Playwright E2E 与生产构建 +第 8 步:项目经理 UI 稿到后替换视觉层 +第 9 步:接真实 Go API,关闭 Mock +``` + +## 12. 当前决策建议 + +- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿; +- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态; +- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认; +- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端; +- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。 diff --git a/tools/overnight-monitor.sh b/tools/overnight-monitor.sh new file mode 100644 index 0000000..a2ec3de --- /dev/null +++ b/tools/overnight-monitor.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -u +LOG_DIR="$HOME/vdi-terminal/logs" +LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log" +mkdir -p "$LOG_DIR" +log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; } +log "monitor_start pid=$$ interval=60s" +while :; do + now=$(date '+%Y-%m-%d %H:%M:%S %z') + shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true) + monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true) + if [ -n "$shell_pid" ]; then + ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ') + else + ps_line="missing" + fi + temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown") + mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true) + heartbeat="missing" + if [ -f /tmp/vdi-kiosk.heartbeat ]; then + heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown') + fi + rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true) + starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0) + exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0) + crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0) + log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem" + sleep 60 +done diff --git a/tools/pve_term_exec.py b/tools/pve_term_exec.py new file mode 100644 index 0000000..cfa6e7c --- /dev/null +++ b/tools/pve_term_exec.py @@ -0,0 +1,89 @@ +"""Run a command on the PVE node via the same VNC term channel the web console uses. + +Usage: python3 pve_term_exec.py "" +Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired). +Prints the command output captured from the terminal stream. +""" +import base64 +import json +import ssl +import struct +import sys +import time +import urllib.parse +import urllib.request + +HOST = "https://192.168.1.51:8006" +NODE = "starhe" +CTX = ssl.create_default_context() +CTX.check_hostname = False +CTX.verify_mode = ssl.CERT_NONE + + +def http(path, data=None, ticket=None, csrf=None): + body = urllib.parse.urlencode(data).encode() if data is not None else None + req = urllib.request.Request(HOST + path, body) + if ticket: + req.add_header("Cookie", "PVEAuthCookie=" + ticket) + if csrf: + req.add_header("CSRFPreventionToken", csrf) + with urllib.request.urlopen(req, context=CTX, timeout=30) as r: + raw = r.read() + return json.loads(raw)["data"] if raw else None + + +def login(): + d = http("/api2/json/access/ticket", + {"username": "root@pam", "password": "yx66668888"}) + return d["ticket"], d["CSRFPreventionToken"] + + +def run_cmd(command, timeout=60): + ticket, csrf = login() + # 1. open a term proxy (VNC channel to node shell) + r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf) + port, ticket_vnc = r["port"], r["ticket"] + user, realm_sep, _ = "root", "@", "pam" + + import socket + sock = socket.create_connection(("192.168.1.51", port), timeout=10) + # PVE handshake: username@realm\n ticket\n binary handshake + sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n") + gh = sock.recv(16) + maj, mino = struct.unpack("BB", gh[0:2]) + said = sock.recv(16 * 4) + # client sends its own handshake + ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0 + sock.sendall(ch) + + # 2. run the command in the shell by typing it and reading output + marker = "PVEDONE%s" % int(time.time()) + payload = command + " ; echo " + marker + "\n" + time.sleep(0.5) + sock.sendall(payload.encode()) + + buf = b"" + deadline = time.time() + timeout + while time.time() < deadline: + try: + sock.settimeout(2) + chunk = sock.recv(65536) + if not chunk: + break + buf += chunk + if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""): + break + except socket.timeout: + if marker.encode() in buf: + break + continue + sock.close() + # strip terminal control chars roughly and the echoed command + text = buf.decode("utf-8", "replace") + text = text.replace(marker, "") + lines = [l for l in text.split("\r\n")] + return "\n".join(lines) + + +if __name__ == "__main__": + print(run_cmd(sys.argv[1])) diff --git a/tools/pve_vm.py b/tools/pve_vm.py new file mode 100644 index 0000000..9104256 --- /dev/null +++ b/tools/pve_vm.py @@ -0,0 +1,55 @@ +import json +import ssl +import sys +import urllib.parse +import urllib.request + +HOST = "https://192.168.1.51:8006" +CTX = ssl.create_default_context() +CTX.check_hostname = False +CTX.verify_mode = ssl.CERT_NONE + + +def api(path, data=None, ticket=None, csrf=None, json_body=None): + body = None + headers = {} + if json_body is not None: + body = json.dumps(json_body).encode() + headers["Content-Type"] = "application/json" + elif data is not None: + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(HOST + path, body, headers) + if ticket: + req.add_header("Cookie", "PVEAuthCookie=" + ticket) + if csrf: + req.add_header("CSRFPreventionToken", csrf) + with urllib.request.urlopen(req, context=CTX, timeout=40) as r: + raw = r.read() + return json.loads(raw)["data"] if raw else None + + +def agent(ticket, csrf, command, timeout=90): + r = api("/api2/json/nodes/starhe/qemu/100/agent/exec", + json_body={"command": command}, ticket=ticket, csrf=csrf) + pid = r["pid"] + for _ in range(timeout // 2): + import time + time.sleep(2) + st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid, + ticket=ticket) + if st.get("exited"): + return st.get("out-data") or "", st.get("err-data") or "" + return "TIMEOUT", "" + + +if __name__ == "__main__": + d = api("/api2/json/access/ticket", + {"username": "root@pam", "password": "yx66668888"}) + ticket, csrf = d["ticket"], d["CSRFPreventionToken"] + ps = sys.argv[1] + out, err = agent(ticket, csrf, [ + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + "-NoProfile", "-Command", ps]) + print(out) + if err: + print("ERR:", err[:300]) diff --git a/tools/xwd2png.py b/tools/xwd2png.py new file mode 100644 index 0000000..f4c3bee --- /dev/null +++ b/tools/xwd2png.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Convert an XWD dump (from xwd) into a PNG, without external image libraries. + +Usage: xwd -root -silent | python3 xwd2png.py out.png +""" +import struct +import sys +import zlib + + +def read_header(data): + fields = struct.unpack(">25I", data[:100]) + names = [ + "header_size", "file_version", "pixmap_format", "pixmap_depth", + "pixmap_width", "pixmap_height", "xoffset", "byte_order", + "bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel", + "bytes_per_line", "visual_class", "red_mask", "green_mask", + "blue_mask", "bits_per_rgb", "colormap_entries", "ncolors", + "window_width", "window_height", "window_x", "window_y", + "window_bdrwidth", + ] + return dict(zip(names, fields)) + + +def write_png(path, width, height, rgb_rows): + raw = b"".join(b"\x00" + row for row in rgb_rows) + + def chunk(tag, payload): + return (struct.pack(">I", len(payload)) + tag + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) + + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) + with open(path, "wb") as handle: + handle.write(png) + + +def main(): + out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png" + data = sys.stdin.buffer.read() + head = read_header(data) + width = head["pixmap_width"] + height = head["pixmap_height"] + bpp = head["bits_per_pixel"] + stride = head["bytes_per_line"] + offset = head["header_size"] + head["ncolors"] * 12 + pixels = data[offset:] + + if bpp == 32: + rows = [] + for y in range(height): + line = pixels[y * stride:y * stride + width * 4] + out = bytearray(width * 3) + for x in range(width): + src = x * 4 + dst = x * 3 + out[dst] = line[src + 2] + out[dst + 1] = line[src + 1] + out[dst + 2] = line[src] + rows.append(bytes(out)) + elif bpp == 24: + rows = [] + for y in range(height): + line = pixels[y * stride:y * stride + width * 3] + out = bytearray(width * 3) + for x in range(width): + src = x * 3 + dst = x * 3 + out[dst] = line[src + 2] + out[dst + 1] = line[src + 1] + out[dst + 2] = line[src] + rows.append(bytes(out)) + else: + sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp) + return 2 + + write_png(out_path, width, height, rows) + print("%s %dx%d" % (out_path, width, height)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())