feat(gpu-monitor): introduce GPU monitoring plugin with validation, build scripts, and documentation; enhance plugin development guidelines
This commit is contained in:
1 parent
bb0c24d5e5
commit
e5b7daad99
66 files changed
+4152
-434
No files matched your search
@@ -0,0 +1 @@
|
||||
assets/probes/*.sh text eol=lf
|
||||
@@ -0,0 +1,3 @@
|
||||
ui/
|
||||
bin/
|
||||
frontend/translations.json
|
||||
@@ -0,0 +1,61 @@
|
||||
# GPU Monitor 验证插件
|
||||
|
||||
`nyaterm.gpu` 使用宿主现有 SSH 连接的新 exec 通道执行固定脚本,由宿主共享调度器持续采集。
|
||||
复用主应用的完整 GPU 展示组件,提供设备、驱动/CUDA、温度、功率、风扇、显存、进程排序、搜索、刷新和空状态。
|
||||
内置 GPU 采集保留作为停用插件后的回退;本例不修改 NPU 或 Docker。
|
||||
|
||||
## 构建和安装
|
||||
|
||||
从仓库根目录运行,需要 Node/pnpm、Rust 和当前宿主平台的原生编译工具:
|
||||
|
||||
```sh
|
||||
pnpm plugin:example:gpu
|
||||
pnpm plugin:pack plugins/examples/gpu-monitor temp/plugins/gpu-monitor.nyap
|
||||
node plugins/examples/gpu-monitor/verify-ui.mjs
|
||||
```
|
||||
|
||||
构建脚本先生成四种语言的必要翻译并检查 TypeScript,再构建相对路径的经典脚本 UI 和 release Rust 后台。
|
||||
支持在 Windows、Linux、macOS 的 x86_64/aarch64 宿主上执行相同构建命令,manifest 写入当前平台的后台入口。
|
||||
交付包仅包含本次构建的平台;其他平台需在相应宿主重建,未进行跨平台桌面验证。
|
||||
宿主必须包含本次 probes/monitors 接口实现;版本号相同的旧 NyaTerm 构建不具备这些接口。
|
||||
|
||||
1. 在新构建的 NyaTerm 插件面板安装 `.nyap`。
|
||||
2. 启用时审核 `assets/probes/gpu.sh` 全文,勾选 `native` 和 `remote.probe`。
|
||||
`remote.probe` 允许自动执行本版本的固定脚本,第三方脚本的只读性不由宿主保证。
|
||||
3. 自行连接远端 Linux/NVIDIA SSH 主机,开启 GPU 开关、顶部 GPU 状态或打开插件面板。
|
||||
4. 原 GPU 工作区自动显示插件 iframe;独立插件面板也能打开。同一窗口、版本、会话和 Monitor 共享一个任务。
|
||||
|
||||
更新或切换版本会停用插件并移除自动脚本授权,需重新审核。第二个有效的 `gpu.v1` Monitor 无法同时启用。
|
||||
`native` 后台是以当前用户权限运行的可信程序,宿主 RPC 权限不构成操作系统沙箱。
|
||||
|
||||
## 行为和恢复
|
||||
|
||||
- 立即采集一次,随后按既有 GPU 间隔采集,最短 3 秒;请求不重叠,完成后安排下一轮。
|
||||
- 关闭面板后,GPU 开关或顶部 GPU 状态仍可维持需求;最后一个订阅释放后停止任务。
|
||||
- 没有 NVIDIA 工具时返回 `available=false`,停止定时采集;手动刷新成功后恢复。
|
||||
- 临时错误保留旧数据并显示错误;连续三次失败清空。错误不会自动切回内置。
|
||||
- 停用、卸载或更新后恢复内置来源,原布局 ID、GPU 设置和资产回填流程保持可用。
|
||||
- “停止后台”暂停自动采集,保留启用和授权;手动刷新或重新启用可恢复。
|
||||
活动请求期间仍遵守既有互斥规则:先结束/释放操作,再重试停止。
|
||||
- 切换会话、断开、关窗、锁屏、撤销授权或版本变化取消对应需求和在途请求。
|
||||
主窗口每 25 分钟续期且保留快照;iframe 按现有机制重建后重新订阅。
|
||||
|
||||
## 开发入口
|
||||
|
||||
- `manifest.json`:固定脚本、后台方法和展示面板的声明。
|
||||
- `assets/probes/gpu.sh`:随包审核的 NVIDIA 采集脚本。
|
||||
- `backend/src/main.rs`:单次 `monitor/collect` → `Context::host().remote_probe()` → 共享解析器。
|
||||
不保存请求 Context,不创建后台无限循环。
|
||||
- `frontend/main.tsx`:SDK 订阅并适配既有 `GpuMonitor`;运行时不依赖主应用 Context、Tauri API 或 CDN。
|
||||
- `src-tauri/crates/nyaterm-gpu`:内置实现和本插件共用的数据类型、解析器与解析测试。
|
||||
|
||||
接口和限制见 [插件开发文档](../../../docs/plugin-development.md),
|
||||
实际验证及待完成桌面验收见 [验证记录](../../../docs/gpu-plugin-validation.md)。
|
||||
|
||||
验证最终安装包的真实 Sidecar(SSH 响应使用模拟执行器):
|
||||
|
||||
```powershell
|
||||
$env:NYATERM_GPU_PACKAGE = (Resolve-Path temp/plugins/gpu-monitor.nyap).Path
|
||||
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture --test gpu_plugin installed_release_package -- --ignored
|
||||
Remove-Item Env:NYATERM_GPU_PACKAGE
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
printf "GPU_AVAILABLE\t0\n"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gpu_query="index,uuid,name,driver_version"
|
||||
gpu_query="$gpu_query,temperature.gpu,utilization.gpu,utilization.memory"
|
||||
gpu_query="$gpu_query,memory.total,memory.used,memory.free"
|
||||
gpu_query="$gpu_query,power.draw,power.limit,fan.speed,pstate"
|
||||
|
||||
cuda_version=$(nvidia-smi 2>/dev/null | sed -n 's/.*CUDA Version: *\([^ |]*\).*/\1/p' | head -n 1)
|
||||
gpu_csv=$(nvidia-smi --query-gpu="$gpu_query" --format=csv,noheader,nounits 2>/dev/null)
|
||||
status=$?
|
||||
|
||||
if [ "$status" -ne 0 ] || [ -z "$gpu_csv" ]; then
|
||||
printf "GPU_AVAILABLE\t0\n"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf "GPU_AVAILABLE\t1\n"
|
||||
printf "GPU_CUDA_VERSION\t%s\n" "$cuda_version"
|
||||
printf "GPU_CSV_BEGIN\n"
|
||||
printf "%s\n" "$gpu_csv"
|
||||
printf "GPU_CSV_END\n"
|
||||
|
||||
process_csv=$(nvidia-smi --query-compute-apps=gpu_uuid,pid,used_gpu_memory,process_name --format=csv,noheader,nounits 2>/dev/null || true)
|
||||
printf "GPU_PROCESS_CSV_BEGIN\n"
|
||||
if [ -n "$process_csv" ]; then
|
||||
printf "%s\n" "$process_csv"
|
||||
fi
|
||||
printf "GPU_PROCESS_CSV_END\n"
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nyaterm-gpu"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nyaterm-plugin-gpu"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"nyaterm-gpu",
|
||||
"nyaterm-plugin-sdk",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nyaterm-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"pin-project-lite",
|
||||
"tokio-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "nyaterm-plugin-gpu"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
|
||||
[dependencies]
|
||||
nyaterm-plugin-sdk = { path = "../../../sdk/rust/nyaterm-plugin-sdk" }
|
||||
nyaterm-gpu = { path = "../../../../src-tauri/crates/nyaterm-gpu" }
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,32 @@
|
||||
use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait};
|
||||
|
||||
struct GpuMonitor;
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for GpuMonitor {
|
||||
async fn call(&self, context: Context, method: &str, _input: Value) -> Result<Value> {
|
||||
if method != "monitor/collect" {
|
||||
return Err(RpcError::method_not_found(method));
|
||||
}
|
||||
let result = context.host().remote_probe("gpu-overview").await?;
|
||||
if result.get("exitStatus").and_then(Value::as_u64) != Some(0) {
|
||||
return Err(RpcError::new(-32000, "GPU probe did not exit successfully"));
|
||||
}
|
||||
let output = result
|
||||
.get("stdout")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| RpcError::invalid("GPU probe returned no text"))?;
|
||||
if !output
|
||||
.lines()
|
||||
.any(|line| line.starts_with("GPU_AVAILABLE\t"))
|
||||
{
|
||||
return Err(RpcError::invalid("GPU probe returned an invalid response"));
|
||||
}
|
||||
serde_json::to_value(nyaterm_gpu::parse_gpu_overview_output(output)).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
Server::from_env()?.serve(GpuMonitor).await
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const root = fileURLToPath(new URL("./", import.meta.url));
|
||||
const repository = path.resolve(root, "../../..");
|
||||
const translations = {};
|
||||
for (const language of ["en", "zh-CN", "zh-TW", "ko"]) {
|
||||
const locale = JSON.parse(
|
||||
readFileSync(
|
||||
path.join(repository, "src/i18n/locales", `${language}.json`),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
translations[language] = {
|
||||
translation: {
|
||||
gpuMonitor: locale.gpuMonitor,
|
||||
panel: { gpuMonitor: locale.panel.gpuMonitor },
|
||||
common: {
|
||||
refresh: locale.common.refresh,
|
||||
loading: locale.common.loading,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(root, "frontend/translations.json"),
|
||||
`${JSON.stringify(translations)}\n`,
|
||||
);
|
||||
const typecheck = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(repository, "node_modules/typescript/bin/tsc"),
|
||||
"-p",
|
||||
path.join(root, "frontend/tsconfig.json"),
|
||||
"--noEmit",
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
);
|
||||
if (typecheck.error) throw typecheck.error;
|
||||
if (typecheck.status !== 0) process.exit(typecheck.status ?? 1);
|
||||
await build({
|
||||
configFile: false,
|
||||
root,
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "@": path.join(repository, "src") } },
|
||||
build: {
|
||||
outDir: "ui",
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
input: path.join(root, "frontend/main.tsx"),
|
||||
output: {
|
||||
format: "iife",
|
||||
name: "NyaTermGpu",
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: "app.js",
|
||||
assetFileNames: "[name][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const css = "style.css";
|
||||
writeFileSync(
|
||||
path.join(root, "ui/index.html"),
|
||||
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="${css}"><title>GPU Monitor</title></head><body><div id="root"></div><script src="nyaterm-sdk.js"></script><script src="app.js"></script></body></html>\n`,
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(repository, "plugins/sdk/nyaterm.js"),
|
||||
path.join(root, "ui/nyaterm-sdk.js"),
|
||||
);
|
||||
const os = { win32: "windows", linux: "linux", darwin: "macos" }[
|
||||
process.platform
|
||||
];
|
||||
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
|
||||
if (!os || !arch) throw new Error("Unsupported platform");
|
||||
const result = spawnSync(
|
||||
"cargo",
|
||||
[
|
||||
"build",
|
||||
"--release",
|
||||
"--manifest-path",
|
||||
path.join(root, "backend/Cargo.toml"),
|
||||
"--target-dir",
|
||||
path.join(root, "backend/target"),
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
const executable = `nyaterm-plugin-gpu${process.platform === "win32" ? ".exe" : ""}`;
|
||||
const relative = `bin/${os}-${arch}/${executable}`;
|
||||
mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
|
||||
copyFileSync(
|
||||
path.join(root, "backend/target/release", executable),
|
||||
path.join(root, relative),
|
||||
);
|
||||
const manifestPath = path.join(root, "manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
manifest.backend.executables = { [`${os}-${arch}`]: relative };
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write("GPU plugin built; package it with pnpm plugin:pack.\n");
|
||||
@@ -0,0 +1,43 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@import "tw-animate-css";
|
||||
@source "./main.tsx";
|
||||
@source "../../../../src/components/panel/GpuMonitor.tsx";
|
||||
@source "../../../../src/components/layout/PanelHeader.tsx";
|
||||
@source "../../../../src/components/ui";
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.terminal-scroll {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import GpuMonitor from "@/components/panel/GpuMonitor";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { RemoteGpuOverviewState } from "@/hooks/useRemoteGpuOverview";
|
||||
import type { PluginMonitorSnapshot } from "@/types/plugins";
|
||||
import translations from "./translations.json";
|
||||
import "./gpu.css";
|
||||
|
||||
type Context = { language?: string; monitorIntervalSeconds?: number };
|
||||
declare const NyaTerm: {
|
||||
ready: Promise<Context>;
|
||||
onContextChange(listener: (context: Context) => void): () => void;
|
||||
monitoring: {
|
||||
subscribe(
|
||||
id: string,
|
||||
listener: (snapshot: PluginMonitorSnapshot) => void,
|
||||
): Promise<{
|
||||
refresh(): Promise<unknown>;
|
||||
unsubscribe(): Promise<unknown>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: translations,
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
function App() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [state, setState] = useState<RemoteGpuOverviewState>({
|
||||
sessionId: null,
|
||||
overview: null,
|
||||
error: false,
|
||||
isManualRefreshing: false,
|
||||
refresh: () => {},
|
||||
});
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let subscription: Awaited<
|
||||
ReturnType<typeof NyaTerm.monitoring.subscribe>
|
||||
> | null = null;
|
||||
let sequence = 0;
|
||||
let interval: number | undefined;
|
||||
const onContext = (context: Context) => {
|
||||
void i18n.changeLanguage(context.language ?? "en");
|
||||
const changed = interval !== context.monitorIntervalSeconds;
|
||||
interval = context.monitorIntervalSeconds;
|
||||
return changed;
|
||||
};
|
||||
const unsubscribeContext = NyaTerm.onContextChange((context) => {
|
||||
const changed = onContext(context);
|
||||
// Reattach on interval changes; the host still shares the same actor.
|
||||
if (subscription && changed) void attach();
|
||||
});
|
||||
const attach = async () => {
|
||||
const request = ++sequence;
|
||||
try {
|
||||
const next = await NyaTerm.monitoring.subscribe("gpu", (snapshot) => {
|
||||
if (disposed || request !== sequence) return;
|
||||
setSessionId(snapshot.sessionId);
|
||||
setState({
|
||||
sessionId: snapshot.sessionId,
|
||||
overview: snapshot.overview,
|
||||
error: snapshot.error,
|
||||
isManualRefreshing: snapshot.refreshing,
|
||||
paused: snapshot.paused,
|
||||
refresh: () => {
|
||||
void subscription
|
||||
?.refresh()
|
||||
.catch(() => setState((s) => ({ ...s, error: true })));
|
||||
},
|
||||
});
|
||||
});
|
||||
if (disposed || request !== sequence) {
|
||||
await next.unsubscribe();
|
||||
return;
|
||||
}
|
||||
const old = subscription;
|
||||
subscription = next;
|
||||
await old?.unsubscribe();
|
||||
} catch {
|
||||
if (!disposed && request === sequence)
|
||||
setState((s) => ({ ...s, error: true }));
|
||||
}
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const context = await NyaTerm.ready;
|
||||
if (disposed) return;
|
||||
onContext(context);
|
||||
await attach();
|
||||
} catch {
|
||||
if (!disposed) setState((s) => ({ ...s, error: true }));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
++sequence;
|
||||
unsubscribeContext();
|
||||
void subscription?.unsubscribe().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<GpuMonitor
|
||||
activeSessionId={sessionId}
|
||||
enabled
|
||||
gpuOverviewState={state}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": { "types": ["vite/client"] },
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"include": ["main.tsx", "../../../../src/types/global.d.ts"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "nyaterm.gpu",
|
||||
"name": "GPU Monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "NVIDIA GPU monitoring over an existing NyaTerm SSH session",
|
||||
"publisher": "NyaTerm",
|
||||
"engine": ">=1.2.12, <2.0.0",
|
||||
"permissions": [
|
||||
"native",
|
||||
"remote.probe"
|
||||
],
|
||||
"contributions": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GPU Monitor",
|
||||
"entry": "ui/index.html"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "open",
|
||||
"title": "Open GPU Monitor",
|
||||
"panel": "overview",
|
||||
"menus": [
|
||||
"terminal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"probes": [
|
||||
{
|
||||
"id": "gpu-overview",
|
||||
"title": "NVIDIA GPU overview",
|
||||
"entry": "assets/probes/gpu.sh",
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
],
|
||||
"monitors": [
|
||||
{
|
||||
"id": "gpu",
|
||||
"title": "GPU Monitor",
|
||||
"schema": "gpu.v1",
|
||||
"method": "monitor/collect",
|
||||
"panel": "overview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"transport": "stdio-jsonl",
|
||||
"executables": {
|
||||
"windows-x86_64": "bin/windows-x86_64/nyaterm-plugin-gpu.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Exercise the built classic-script entry and real GPU components without Tauri.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM, VirtualConsole } from "jsdom";
|
||||
|
||||
const root = new URL("./", import.meta.url);
|
||||
const html = readFileSync(new URL("ui/index.html", root), "utf8");
|
||||
assert(!html.includes('type="module"'));
|
||||
for (const match of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
|
||||
assert(!/^(?:[a-z]+:|\/)/i.test(match[1]), "Assets must use relative paths");
|
||||
assert(readFileSync(new URL(`ui/${match[1]}`, root)).length > 0);
|
||||
}
|
||||
const errors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", (error) => errors.push(error.message));
|
||||
const dom = new JSDOM(html.replace(/<script[^>]*><\/script>/g, ""), {
|
||||
url: "https://gpu-plugin.invalid/ui/index.html",
|
||||
runScripts: "dangerously",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
});
|
||||
const { window } = dom;
|
||||
const calls = [];
|
||||
let revision = 1;
|
||||
const snapshot = () => ({
|
||||
revision: revision++,
|
||||
sessionId: "ssh-1",
|
||||
error: false,
|
||||
refreshing: false,
|
||||
paused: false,
|
||||
overview: {
|
||||
available: true,
|
||||
driver_version: "550.54",
|
||||
cuda_version: "12.4",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-a",
|
||||
name: "NVIDIA RTX 4090",
|
||||
temperature_c: 43,
|
||||
utilization_gpu_percent: 77,
|
||||
utilization_memory_percent: 31,
|
||||
memory_total_mb: 24564,
|
||||
memory_used_mb: 12345,
|
||||
memory_free_mb: 12219,
|
||||
power_draw_w: 180.5,
|
||||
power_limit_w: 450,
|
||||
fan_speed_percent: 46,
|
||||
pstate: "P2",
|
||||
},
|
||||
],
|
||||
processes: [
|
||||
{
|
||||
gpu_uuid: "GPU-a",
|
||||
gpu_index: 0,
|
||||
pid: 4242,
|
||||
process_name: "python",
|
||||
used_memory_mb: 2048,
|
||||
},
|
||||
{
|
||||
gpu_uuid: "GPU-a",
|
||||
gpu_index: 0,
|
||||
pid: 4243,
|
||||
process_name: "node",
|
||||
used_memory_mb: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const context = {
|
||||
pluginId: "nyaterm.gpu",
|
||||
version: "1.0.0",
|
||||
language: "en",
|
||||
monitorIntervalSeconds: 3,
|
||||
theme: {
|
||||
"--background": "#111111",
|
||||
"--foreground": "#eeeeee",
|
||||
"--df-bg-panel": "#111111",
|
||||
},
|
||||
};
|
||||
let parent;
|
||||
const send = (data) =>
|
||||
window.dispatchEvent(
|
||||
new window.MessageEvent("message", { source: parent, data }),
|
||||
);
|
||||
parent = {
|
||||
postMessage(message) {
|
||||
queueMicrotask(() => {
|
||||
if (message.type === "nyaterm-plugin-ready")
|
||||
return send({ type: "nyaterm-plugin-context", context });
|
||||
if (message.type !== "nyaterm-plugin-request") return;
|
||||
calls.push(message.method);
|
||||
const result =
|
||||
message.method === "host/session"
|
||||
? { id: "ssh-1" }
|
||||
: message.method === "host/monitoring/subscribe"
|
||||
? { subscriptionId: "gpu-sub", snapshot: snapshot() }
|
||||
: null;
|
||||
send({ type: "nyaterm-plugin-response", id: message.id, result });
|
||||
});
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "parent", { value: parent });
|
||||
window.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
window.eval(readFileSync(new URL("ui/nyaterm-sdk.js", root), "utf8"));
|
||||
window.eval(readFileSync(new URL("ui/app.js", root), "utf8"));
|
||||
const until = async (predicate) => {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.fail(`UI did not settle: ${window.document.body.textContent}`);
|
||||
};
|
||||
await until(() => window.document.body.textContent.includes("NVIDIA RTX 4090"));
|
||||
const text = () => window.document.body.textContent;
|
||||
assert(text().includes("550.54") && text().includes("12.4"));
|
||||
assert(text().includes("43 C") && text().includes("77%"));
|
||||
await until(() => text().includes("python") && text().includes("node"));
|
||||
assert(
|
||||
text().indexOf("python") < text().indexOf("node"),
|
||||
"Processes are ordered by memory",
|
||||
);
|
||||
const input = window.document.querySelector("input");
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
).set;
|
||||
setValue.call(input, "no-such-process");
|
||||
input.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
const searchLocale = JSON.parse(
|
||||
readFileSync(new URL("../../../src/i18n/locales/en.json", root), "utf8"),
|
||||
);
|
||||
await until(() => text().includes(searchLocale.gpuMonitor.noMatches));
|
||||
setValue.call(input, "");
|
||||
input.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await until(() => text().includes("python"));
|
||||
const button = (label) =>
|
||||
[...window.document.querySelectorAll("button")].find(
|
||||
(element) => element.getAttribute("aria-label") === label,
|
||||
);
|
||||
const english = JSON.parse(
|
||||
readFileSync(new URL("../../../src/i18n/locales/en.json", root), "utf8"),
|
||||
);
|
||||
button(english.gpuMonitor.details).click();
|
||||
await until(() => text().includes("46%"));
|
||||
button(english.common.refresh).click();
|
||||
await until(() => calls.includes("host/monitoring/refresh"));
|
||||
const subscriptions = calls.filter(
|
||||
(method) => method === "host/monitoring/subscribe",
|
||||
).length;
|
||||
for (const language of ["zh-CN", "zh-TW", "ko", "en"]) {
|
||||
context.language = language;
|
||||
send({ type: "nyaterm-plugin-context", context });
|
||||
const locale = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(`../../../src/i18n/locales/${language}.json`, root),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
await until(() => text().includes(locale.panel.gpuMonitor));
|
||||
}
|
||||
assert.equal(
|
||||
calls.filter((method) => method === "host/monitoring/subscribe").length,
|
||||
subscriptions,
|
||||
"Theme/language updates must preserve the subscription",
|
||||
);
|
||||
assert.equal(
|
||||
window.document.documentElement.style.getPropertyValue("--background"),
|
||||
"#111111",
|
||||
);
|
||||
send({
|
||||
type: "nyaterm-plugin-monitor",
|
||||
subscriptionId: "gpu-sub",
|
||||
snapshot: { ...snapshot(), error: true, paused: true },
|
||||
});
|
||||
await until(
|
||||
() =>
|
||||
text().includes(english.gpuMonitor.paused) &&
|
||||
text().includes(english.gpuMonitor.error),
|
||||
);
|
||||
assert(
|
||||
text().includes("NVIDIA RTX 4090"),
|
||||
"A temporary failure preserves data",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
window.dispatchEvent(new window.Event("pagehide"));
|
||||
window.close();
|
||||
process.stdout.write(
|
||||
"Built GPU UI passed: relative classic assets, full metrics/processes, refresh, four languages, theme and retained error/paused state.\n",
|
||||
);
|
||||
Vendored
+15
@@ -10,6 +10,8 @@ interface NyaTermPluginContext {
|
||||
pluginId: string;
|
||||
version: string;
|
||||
theme: Record<string, string>;
|
||||
language?: string;
|
||||
monitorIntervalSeconds?: number;
|
||||
}
|
||||
interface NyaTermPluginSdk {
|
||||
readonly ready: Promise<NyaTermPluginContext>;
|
||||
@@ -43,6 +45,19 @@ interface NyaTermPluginSdk {
|
||||
},
|
||||
): Promise<{ status: number; contentType: string; body: string }>;
|
||||
};
|
||||
monitoring: {
|
||||
subscribe(
|
||||
monitorId: string,
|
||||
listener: (snapshot: {
|
||||
revision: number;
|
||||
sessionId: string;
|
||||
overview: NyaTermJson;
|
||||
error: boolean;
|
||||
refreshing: boolean;
|
||||
paused: boolean;
|
||||
}) => void,
|
||||
): Promise<{ refresh(): Promise<null>; unsubscribe(): Promise<null> }>;
|
||||
};
|
||||
backend<T = NyaTermJson>(method: string, input?: NyaTermJson): Promise<T>;
|
||||
}
|
||||
declare const NyaTerm: NyaTermPluginSdk;
|
||||
@@ -3,6 +3,8 @@
|
||||
"use strict";
|
||||
const pending = new Map();
|
||||
const contextListeners = new Set();
|
||||
const monitors = new Map();
|
||||
const earlyMonitors = new Map();
|
||||
let context = null;
|
||||
let nextId = 0;
|
||||
let resolveReady;
|
||||
@@ -36,6 +38,18 @@
|
||||
}
|
||||
resolveReady(context);
|
||||
for (const listener of contextListeners) listener(context);
|
||||
} else if (message.type === "nyaterm-plugin-monitor") {
|
||||
const monitor = monitors.get(message.subscriptionId);
|
||||
if (monitor) {
|
||||
if (message.snapshot.revision > monitor.revision) {
|
||||
monitor.revision = message.snapshot.revision;
|
||||
monitor.listener(message.snapshot);
|
||||
}
|
||||
} else if (earlyMonitors.size < 32) {
|
||||
const old = earlyMonitors.get(message.subscriptionId);
|
||||
if (!old || message.snapshot.revision > old.revision)
|
||||
earlyMonitors.set(message.subscriptionId, message.snapshot);
|
||||
}
|
||||
} else if (message.type === "nyaterm-plugin-response") {
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
@@ -51,6 +65,8 @@
|
||||
request.reject(new Error("Plugin view closed"));
|
||||
}
|
||||
pending.clear();
|
||||
monitors.clear();
|
||||
earlyMonitors.clear();
|
||||
});
|
||||
window.NyaTerm = Object.freeze({
|
||||
ready,
|
||||
@@ -79,6 +95,35 @@
|
||||
request: (url, options = {}) =>
|
||||
call("host/network/request", { ...options, url }),
|
||||
}),
|
||||
monitoring: Object.freeze({
|
||||
async subscribe(monitorId, listener) {
|
||||
const result = await call("host/monitoring/subscribe", { monitorId });
|
||||
const state = { listener, revision: result.snapshot.revision };
|
||||
monitors.set(result.subscriptionId, state);
|
||||
listener(result.snapshot);
|
||||
const early = earlyMonitors.get(result.subscriptionId);
|
||||
earlyMonitors.delete(result.subscriptionId);
|
||||
if (early && early.revision > state.revision) {
|
||||
state.revision = early.revision;
|
||||
listener(early);
|
||||
}
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
refresh: () =>
|
||||
call("host/monitoring/refresh", {
|
||||
subscriptionId: result.subscriptionId,
|
||||
}),
|
||||
unsubscribe: () => {
|
||||
if (disposed) return Promise.resolve(null);
|
||||
disposed = true;
|
||||
monitors.delete(result.subscriptionId);
|
||||
return call("host/monitoring/unsubscribe", {
|
||||
subscriptionId: result.subscriptionId,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
backend: (method, input = null) => call(method, input),
|
||||
});
|
||||
window.parent.postMessage({ type: "nyaterm-plugin-ready" }, "*");
|
||||
|
||||
@@ -184,6 +184,10 @@ impl HostClient {
|
||||
result=tokio::time::timeout(timeout,operation)=>result.unwrap_or_else(|_|Err(RpcError::new(-32000,"Host call timed out"))),
|
||||
}
|
||||
}
|
||||
pub async fn remote_probe(&self, probe_id: &str) -> Result<Value> {
|
||||
self.call("host/remote/probe", json!({"probeId": probe_id}))
|
||||
.await
|
||||
}
|
||||
pub async fn session(&self) -> Result<Value> {
|
||||
self.call("host/session", Value::Null).await
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user