feat(gpu-monitor): introduce GPU monitoring plugin with validation, build scripts, and documentation; enhance plugin development guidelines

This commit is contained in:
Kang committed 2026-10-03 18:57:34 +08:00
1 parent bb0c24d5e5
commit e5b7daad99
66 files changed
+4152 -434

No files matched your search

@@ -0,0 +1 @@
assets/probes/*.sh text eol=lf
+3
View File
@@ -0,0 +1,3 @@
ui/
bin/
frontend/translations.json
+61
View File
@@ -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
View File
@@ -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
}
+107
View File
@@ -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"
}
}
}
+194
View File
@@ -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",
);
+15
View File
@@ -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;
+45
View File
@@ -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
}