feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。
- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
"code has no resources but signature indicates they must be present" 是 adhoc
链接签名的固有噪声,实测无害(勿被误导去「修签名」)。
⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
**没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。
配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。
tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "0.1.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "nodeagent_console_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nodeagent-client = { path = "../../../crates/nodeagent-client" }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,151 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! command 层把 `nodeagent-client`(Rust 控制端)接到前端:
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//! - client 的日志/状态/被控端事件经 Tauri `emit` 推给前端(`na-log`/`na-state`/`na-event`)。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nodeagent_client::{
|
||||
CapabilityDescriptor, Client, ClientEvent, ClientOptions, ConnState, InvokeResult,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
/// 全局状态:持有当前连接(`None` = 未连接)。
|
||||
struct AppState {
|
||||
client: Mutex<Option<Client>>,
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息。
|
||||
#[derive(Serialize)]
|
||||
struct ConnectInfo {
|
||||
capabilities: Vec<CapabilityDescriptor>,
|
||||
agent_version: Option<String>,
|
||||
authorized: Option<Vec<String>>,
|
||||
peer_cert_fp: Option<String>,
|
||||
auth_mode: String,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
fn conn_state_str(s: ConnState) -> &'static str {
|
||||
match s {
|
||||
ConnState::Connected => "connected",
|
||||
ConnState::Reconnecting => "reconnecting",
|
||||
ConnState::Closed => "closed",
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
key: String,
|
||||
client_id: Option<String>,
|
||||
insecure: Option<bool>,
|
||||
) -> Result<ConnectInfo, String> {
|
||||
let client_id = client_id.unwrap_or_else(|| "console".to_string());
|
||||
let client = Client::new(ClientOptions {
|
||||
url,
|
||||
key,
|
||||
client_id: client_id.clone(),
|
||||
insecure: insecure.unwrap_or(true),
|
||||
auto_reconnect: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// 把 client 的事件桥接成 Tauri 事件,推给前端。
|
||||
let app_events = app.clone();
|
||||
client.set_handler(Arc::new(move |evt| match evt {
|
||||
ClientEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", conn_state_str(s));
|
||||
}
|
||||
ClientEvent::Log(msg) => {
|
||||
let _ = app_events.emit("na-log", msg);
|
||||
}
|
||||
ClientEvent::Event(params) => {
|
||||
let _ = app_events.emit("na-event", params);
|
||||
}
|
||||
}));
|
||||
|
||||
let caps = client.connect().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let info = ConnectInfo {
|
||||
agent_version: client.agent_version(),
|
||||
authorized: client.list_authorized(),
|
||||
peer_cert_fp: client.peer_cert_fingerprint(),
|
||||
auth_mode: "psk".to_string(),
|
||||
capabilities: caps,
|
||||
client_id,
|
||||
};
|
||||
|
||||
*state.client.lock().unwrap() = Some(client);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
if let Some(client) = state.client.lock().unwrap().take() {
|
||||
client.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
|
||||
#[tauri::command]
|
||||
async fn invoke_capability(
|
||||
state: State<'_, AppState>,
|
||||
capability: String,
|
||||
args: Option<Value>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<InvokeResult, String> {
|
||||
let client = {
|
||||
let guard = state.client.lock().unwrap();
|
||||
guard.as_ref().cloned().ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
let args = args.unwrap_or(Value::Object(Default::default()));
|
||||
match timeout_ms {
|
||||
Some(ms) if ms > 0 => client.invoke_with_timeout(&capability, args, ms).await,
|
||||
_ => client.invoke(&capability, args).await,
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 当前连接的能力清单。
|
||||
#[tauri::command]
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Vec<CapabilityDescriptor>, String> {
|
||||
let guard = state.client.lock().unwrap();
|
||||
let client = guard.as_ref().ok_or_else(|| "未连接".to_string())?;
|
||||
Ok(client.list_capabilities())
|
||||
}
|
||||
|
||||
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
|
||||
#[tauri::command]
|
||||
fn get_state(state: State<'_, AppState>) -> String {
|
||||
match state.client.lock().unwrap().as_ref() {
|
||||
Some(client) => conn_state_str(client.state()).to_string(),
|
||||
None => "未连接".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
client: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
nodeagent_console_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "0.1.0",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "nodeagent 控制台",
|
||||
"width": 1100,
|
||||
"height": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
darkTheme,
|
||||
zhCN,
|
||||
dateZhCN,
|
||||
} from "naive-ui";
|
||||
import AppShell from "./components/AppShell.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<app-shell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → nodeagent-client)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
|
||||
|
||||
/** 建立连接并握手。 */
|
||||
export function connect(url: string, key: string, clientId = "console", insecure = true) {
|
||||
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
|
||||
}
|
||||
|
||||
/** 断开连接(停自动重连)。 */
|
||||
export function disconnect() {
|
||||
return invoke<void>("disconnect");
|
||||
}
|
||||
|
||||
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
|
||||
export function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
|
||||
}
|
||||
|
||||
/** 当前连接的能力清单。 */
|
||||
export function getCapabilities() {
|
||||
return invoke<CapabilityDescriptor[]>("get_capabilities");
|
||||
}
|
||||
|
||||
/** 当前连接状态字符串。 */
|
||||
export function getState() {
|
||||
return invoke<string>("get_state");
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力调用失败(业务层失败,非传输层失败)。
|
||||
*
|
||||
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
|
||||
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
|
||||
*/
|
||||
export class CapabilityFailure extends Error {
|
||||
readonly capability: string;
|
||||
readonly errorName: string;
|
||||
readonly data?: unknown;
|
||||
|
||||
constructor(capability: string, errorName: string, message: string, data?: unknown) {
|
||||
super(message);
|
||||
this.name = "CapabilityFailure";
|
||||
this.capability = capability;
|
||||
this.errorName = errorName;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
|
||||
export function isUnsupportedPlatform(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
|
||||
}
|
||||
|
||||
/** 能力被 ACL 拒绝。 */
|
||||
export function isAclDenied(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
|
||||
}
|
||||
|
||||
/** 能力被调用限速拦截。 */
|
||||
export function isRateLimited(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
|
||||
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
|
||||
*/
|
||||
export async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutSider,
|
||||
NLayoutHeader,
|
||||
NLayoutContent,
|
||||
NMenu,
|
||||
NTag,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
type MenuOption,
|
||||
} from "naive-ui";
|
||||
import { navRoutes } from "../router";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const conn = useConnStore();
|
||||
|
||||
onMounted(() => {
|
||||
conn.bind();
|
||||
});
|
||||
|
||||
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
|
||||
key: r.name as string,
|
||||
label: () => h("span", `${(r.meta as { icon: string }).icon} ${(r.meta as { title: string }).title}`),
|
||||
}));
|
||||
|
||||
const activeKey = computed(() => (route.name as string) ?? "nodes");
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
|
||||
const stateType = computed(() => {
|
||||
if (conn.state === "connected") return "success" as const;
|
||||
if (conn.state === "reconnecting") return "warning" as const;
|
||||
return "error" as const;
|
||||
});
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (conn.state === "connected") return "已连接";
|
||||
if (conn.state === "reconnecting") return "重连中";
|
||||
return "未连接";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100vh">
|
||||
<n-layout-sider bordered :width="200" :native-scrollbar="false">
|
||||
<div class="brand">
|
||||
<div class="brand-title">nodeagent</div>
|
||||
<div class="brand-sub">桌面控制台</div>
|
||||
</div>
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar">
|
||||
<n-space align="center">
|
||||
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
|
||||
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
|
||||
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.brand {
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #63e2b7;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,60 @@
|
||||
/** 路由表(7 页)。hash history 便于 Tauri 打包后稳定工作。 */
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
export interface NavMeta {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/nodes" },
|
||||
{
|
||||
path: "/nodes",
|
||||
name: "nodes",
|
||||
component: () => import("../views/NodesView.vue"),
|
||||
meta: { title: "设备管理", icon: "🖥" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/overview",
|
||||
name: "overview",
|
||||
component: () => import("../views/OverviewView.vue"),
|
||||
meta: { title: "状态总览", icon: "📊" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/terminal",
|
||||
name: "terminal",
|
||||
component: () => import("../views/TerminalView.vue"),
|
||||
meta: { title: "命令执行", icon: "⌨" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/processes",
|
||||
name: "processes",
|
||||
component: () => import("../views/ProcessesView.vue"),
|
||||
meta: { title: "进程与服务", icon: "⚙" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/software",
|
||||
name: "software",
|
||||
component: () => import("../views/SoftwareView.vue"),
|
||||
meta: { title: "软件管理", icon: "📦" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "files",
|
||||
component: () => import("../views/FilesView.vue"),
|
||||
meta: { title: "文件管理", icon: "📁" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
name: "audit",
|
||||
component: () => import("../views/AuditView.vue"),
|
||||
meta: { title: "审计日志", icon: "📜" } satisfies NavMeta,
|
||||
},
|
||||
];
|
||||
|
||||
export const navRoutes = routes.filter((r) => "name" in r);
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** 连接状态 store:连接/断开、能力清单、元信息、日志与被控端事件。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "../api";
|
||||
import type { ConnectInfo, InvokeResult, SystemInfo } from "../types";
|
||||
|
||||
export interface AgentEvent {
|
||||
ts: number;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
|
||||
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
|
||||
*/
|
||||
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
|
||||
|
||||
export const useConnStore = defineStore("conn", () => {
|
||||
const state = ref<string>("closed");
|
||||
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
|
||||
const key = ref(localStorage.getItem("na.key") || "");
|
||||
const info = ref<ConnectInfo | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const events = ref<AgentEvent[]>([]);
|
||||
const connecting = ref(false);
|
||||
const bound = ref(false);
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' | ... (连接后自动探测) */
|
||||
const nodeOs = ref<string | null>(null);
|
||||
|
||||
const connected = computed(() => state.value === "connected" && !!info.value);
|
||||
const capabilities = computed(() => info.value?.capabilities ?? []);
|
||||
/** 被控端能力名集合,便于按名判存在性。 */
|
||||
const capabilityNames = computed(() => new Set(capabilities.value.map((c) => c.name)));
|
||||
const isWindows = computed(() => nodeOs.value === "Windows");
|
||||
/** 平台门控能力当前是否可用(未探测到平台时按「乐观可用」处理)。 */
|
||||
const platformReady = computed(() => nodeOs.value === null || isWindows.value);
|
||||
|
||||
function pushLog(msg: string) {
|
||||
logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
||||
if (logs.value.length > 500) logs.value.shift();
|
||||
}
|
||||
|
||||
/** 绑定后端事件(应用启动时调用一次)。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<string>("na-log", (e) => pushLog(e.payload));
|
||||
await listen<string>("na-state", (e) => {
|
||||
state.value = e.payload;
|
||||
pushLog(`状态 → ${e.payload}`);
|
||||
});
|
||||
await listen<unknown>("na-event", (e) => {
|
||||
events.value.unshift({ ts: Date.now(), payload: e.payload });
|
||||
if (events.value.length > 200) events.value.pop();
|
||||
pushLog(`被控端事件:${JSON.stringify(e.payload)}`);
|
||||
});
|
||||
state.value = await api.getState();
|
||||
}
|
||||
|
||||
/** 探测被控端平台(`system.info.os`)——决定 Windows 专属能力是否可用。 */
|
||||
async function loadPlatform() {
|
||||
try {
|
||||
const i = await api.ok<SystemInfo>("system.info", { fields: ["os"] });
|
||||
nodeOs.value = i.os ?? null;
|
||||
pushLog(`被控端平台:${nodeOs.value ?? "未知"}`);
|
||||
return nodeOs.value;
|
||||
} catch (e) {
|
||||
pushLog(`平台探测失败:${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(u = url.value, k = key.value) {
|
||||
connecting.value = true;
|
||||
url.value = u;
|
||||
key.value = k;
|
||||
pushLog(`正在连接 ${u} ...`);
|
||||
try {
|
||||
const i = await api.connect(u, k, "console");
|
||||
info.value = i;
|
||||
localStorage.setItem("na.url", u);
|
||||
localStorage.setItem("na.key", k);
|
||||
pushLog(`握手成功:${i.capabilities.length} 项能力,agent ${i.agent_version ?? "?"}`);
|
||||
// 握手后立刻探测平台,供各页面事前门控。
|
||||
await loadPlatform();
|
||||
return i;
|
||||
} catch (e) {
|
||||
pushLog(`连接失败:${e}`);
|
||||
throw e;
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await api.disconnect();
|
||||
info.value = null;
|
||||
nodeOs.value = null;
|
||||
pushLog("已断开");
|
||||
}
|
||||
|
||||
/** 泛型调用;连接断开时抛错。 */
|
||||
async function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<InvokeResult<T>> {
|
||||
if (!connected.value) throw new api.CapabilityFailure(capability, "E_NODE_OFFLINE", "未连接");
|
||||
const t0 = performance.now();
|
||||
const res = await api.call<T>(capability, args, timeoutMs);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (res.status === "ok") {
|
||||
pushLog(`${capability} → ok (${ms}ms)`);
|
||||
} else {
|
||||
pushLog(`${capability} → 失败:[${res.error?.name}] ${res.error?.message ?? "未知错误"}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功,直接返回 data。
|
||||
* 失败抛 `CapabilityFailure`(携带 `errorName`),便于页面按错误名分支
|
||||
* ——如 `isUnsupportedPlatform(e)` 判平台不支持。
|
||||
*/
|
||||
async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new api.CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
url,
|
||||
key,
|
||||
info,
|
||||
logs,
|
||||
events,
|
||||
connecting,
|
||||
connected,
|
||||
capabilities,
|
||||
capabilityNames,
|
||||
nodeOs,
|
||||
isWindows,
|
||||
platformReady,
|
||||
bind,
|
||||
loadPlatform,
|
||||
connect,
|
||||
disconnect,
|
||||
call,
|
||||
ok,
|
||||
pushLog,
|
||||
clearLogs,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 设备列表 store:本地保存的被控端配置(名称 / 地址 / PSK),CRUD + 持久化。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export interface NodeProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useNodeStore = defineStore("node", () => {
|
||||
const nodes = ref<NodeProfile[]>(JSON.parse(localStorage.getItem("na.nodes") || "[]"));
|
||||
const currentId = ref<string | null>(localStorage.getItem("na.current"));
|
||||
|
||||
const current = computed(() => nodes.value.find((n) => n.id === currentId.value) ?? null);
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem("na.nodes", JSON.stringify(nodes.value));
|
||||
localStorage.setItem("na.current", currentId.value ?? "");
|
||||
}
|
||||
|
||||
function add(profile: Omit<NodeProfile, "id">): NodeProfile {
|
||||
const node: NodeProfile = { id: crypto.randomUUID(), ...profile };
|
||||
nodes.value.push(node);
|
||||
persist();
|
||||
return node;
|
||||
}
|
||||
|
||||
function update(id: string, patch: Partial<NodeProfile>) {
|
||||
const idx = nodes.value.findIndex((n) => n.id === id);
|
||||
if (idx >= 0) {
|
||||
nodes.value[idx] = { ...nodes.value[idx], ...patch };
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
nodes.value = nodes.value.filter((n) => n.id !== id);
|
||||
if (currentId.value === id) currentId.value = null;
|
||||
persist();
|
||||
}
|
||||
|
||||
function setCurrent(id: string | null) {
|
||||
currentId.value = id;
|
||||
persist();
|
||||
}
|
||||
|
||||
return { nodes, currentId, current, add, update, remove, setCurrent };
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/** 协议类型镜像(对齐 Rust 侧 `crates/nodeagent-client` 与 Tauri command 的序列化形状)。 */
|
||||
|
||||
/** 能力描述(被控端声明的能力清单条目)。 */
|
||||
export interface CapabilityDescriptor {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "high" | string;
|
||||
params_schema: JsonSchema;
|
||||
returns_schema?: JsonSchema;
|
||||
}
|
||||
|
||||
/** JSON Schema 子集。 */
|
||||
export interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
items?: JsonSchema;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** connect command 返回的连接元信息。 */
|
||||
export interface ConnectInfo {
|
||||
capabilities: CapabilityDescriptor[];
|
||||
agent_version: string | null;
|
||||
authorized: string[] | null;
|
||||
peer_cert_fp: string | null;
|
||||
auth_mode: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
/** 能力执行失败的错误信息。 */
|
||||
export interface InvokeError {
|
||||
name: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/** 能力执行结果(业务层)。 */
|
||||
export interface InvokeResult<T = unknown> {
|
||||
status: "ok" | "failed";
|
||||
data?: T;
|
||||
error?: InvokeError;
|
||||
}
|
||||
|
||||
/** 连接状态。 */
|
||||
export type ConnState = "connected" | "reconnecting" | "closed";
|
||||
|
||||
// ---------- 各能力的返回结构 ----------
|
||||
|
||||
/** 被控端构建信息(v18 构建指纹 + 运行状态)。 */
|
||||
export interface BuildInfo {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
commit?: string;
|
||||
built_at?: string;
|
||||
/** 运行中 agent 脚本的 sha256(v21 证书/脚本交叉核对) */
|
||||
cert_sha256?: string;
|
||||
bytes?: number;
|
||||
mtime_ms?: number;
|
||||
node?: string;
|
||||
started_at?: number;
|
||||
uptime_ms?: number;
|
||||
}
|
||||
|
||||
/** PowerShell 常驻助手状态(v15)。 */
|
||||
export interface PsHelperInfo {
|
||||
spawned?: boolean;
|
||||
hits?: number;
|
||||
failures?: number;
|
||||
consecutiveFailures?: number;
|
||||
avg_ms?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** v21 来源网段访问控制。 */
|
||||
export interface NetworkInfo {
|
||||
/** null = **未配置 = 放行全部**(应尽快收敛) */
|
||||
allow_from?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
hostname?: string;
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' —— **平台门控能力的判断依据** */
|
||||
os?: string;
|
||||
os_version?: string;
|
||||
arch?: string;
|
||||
cpu_model?: string;
|
||||
cpu_cores?: number;
|
||||
memory_total?: number;
|
||||
uptime_sec?: number;
|
||||
is_admin?: boolean;
|
||||
pid?: number;
|
||||
agent_home?: string;
|
||||
/** 被控端入口脚本路径,供一键升级定位 */
|
||||
agent_script?: string;
|
||||
/** 被控端 Node 可执行文件路径(字段名是 `node_path`,没有独立的 `node`) */
|
||||
node_path?: string;
|
||||
ps_helper?: PsHelperInfo;
|
||||
network?: NetworkInfo;
|
||||
build?: BuildInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 磁盘(对齐 manifest system.status.disks 契约)。
|
||||
* 注意:**没有 `used` 字段**,已用量需自行算 `total - free`。
|
||||
*/
|
||||
export interface DiskInfo {
|
||||
drive?: string;
|
||||
total?: number;
|
||||
free?: number;
|
||||
used_pct?: number;
|
||||
}
|
||||
|
||||
/** 网络适配器(对齐 manifest system.status.net 契约)。 */
|
||||
export interface NetInfo {
|
||||
adapter?: string;
|
||||
ip?: string;
|
||||
up?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu_pct?: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
memory_pct?: number;
|
||||
disks?: DiskInfo[];
|
||||
net?: NetInfo[];
|
||||
}
|
||||
|
||||
/** 进程项(对齐 manifest system.process.list 契约)。 */
|
||||
export interface ProcessEntry {
|
||||
pid: number;
|
||||
name: string;
|
||||
/** CPU 占用百分比(非 `cpu`) */
|
||||
cpu_pct?: number;
|
||||
/** 常驻内存字节数(非 `memory`) */
|
||||
memory_bytes?: number;
|
||||
started_at?: number;
|
||||
}
|
||||
|
||||
/** 服务项(对齐 manifest system.service.list 契约)。 */
|
||||
export interface ServiceEntry {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
/** 运行状态字段名是 `state`,不是 `status` */
|
||||
state?: string;
|
||||
start_type?: string;
|
||||
}
|
||||
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir" | "other";
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface FsListResult {
|
||||
entries: FsEntry[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface FsReadResult {
|
||||
data: string;
|
||||
encoding: string;
|
||||
offset: number;
|
||||
bytes: number;
|
||||
total_bytes: number;
|
||||
eof: boolean;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
/** 已装软件项(对齐 manifest app.list 契约)。 */
|
||||
export interface AppEntry {
|
||||
name: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
/** 'registry' | 'uwp' | 'winget' 等来源标记 */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** 审计事件类别(对齐 apps/agent/src/audit.ts 的 `AuditType`)。 */
|
||||
export type AuditType =
|
||||
| "agent.start"
|
||||
| "agent.stop"
|
||||
| "auth.success"
|
||||
| "auth.failure"
|
||||
| "invoke"
|
||||
| "acl.denied"
|
||||
| "capability.disabled"
|
||||
| "rate.limited"
|
||||
| "net.change"
|
||||
| "net.deny"
|
||||
| "agent.update";
|
||||
|
||||
export interface AuditEntry {
|
||||
ts?: number;
|
||||
type?: AuditType | string;
|
||||
client_id?: string;
|
||||
remote?: string;
|
||||
capability?: string;
|
||||
status?: "ok" | "failed" | "denied";
|
||||
duration_ms?: number;
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
|
||||
args_digest?: string;
|
||||
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
/** v11 防篡改:上一条哈希(首条为 "genesis") */
|
||||
prev?: string;
|
||||
/** v11 防篡改:本条哈希 */
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export interface AuditListResult {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/** 审计链首个异常位置(`ok:false` 时存在)。 */
|
||||
export interface AuditBrokenAt {
|
||||
file?: string;
|
||||
line?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AuditVerifyResult {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
/** 无链字段的历史条目数(跳过校验) */
|
||||
legacy?: number;
|
||||
/** 是**对象**而非数字 */
|
||||
broken_at?: AuditBrokenAt | null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 通用格式化工具。 */
|
||||
|
||||
export function formatBytes(n?: number | null): string {
|
||||
if (n == null || Number.isNaN(n)) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(sec?: number | null): string {
|
||||
if (sec == null || Number.isNaN(sec)) return "-";
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d) return `${d} 天 ${h} 小时`;
|
||||
if (h) return `${h} 小时 ${m} 分`;
|
||||
return `${m} 分`;
|
||||
}
|
||||
|
||||
export function formatTime(ms?: number | null): string {
|
||||
if (!ms) return "-";
|
||||
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatPct(v?: number | null): string {
|
||||
if (v == null || Number.isNaN(v)) return "-";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NText,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NAlert,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
|
||||
import { formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const entries = ref<AuditEntry[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(100);
|
||||
const typeFilter = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const verify = ref<AuditVerifyResult | null>(null);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "invoke", value: "invoke" },
|
||||
{ label: "auth.success", value: "auth.success" },
|
||||
{ label: "auth.failure", value: "auth.failure" },
|
||||
{ label: "acl.denied", value: "acl.denied" },
|
||||
{ label: "capability.disabled", value: "capability.disabled" },
|
||||
{ label: "rate.limited", value: "rate.limited" },
|
||||
{ label: "net.deny", value: "net.deny" },
|
||||
{ label: "net.change", value: "net.change" },
|
||||
{ label: "agent.update", value: "agent.update" },
|
||||
{ label: "agent.start", value: "agent.start" },
|
||||
{ label: "agent.stop", value: "agent.stop" },
|
||||
];
|
||||
|
||||
const cols: DataTableColumns<AuditEntry> = [
|
||||
{ title: "时间", key: "ts", width: 180, render: (r) => formatTime(r.ts) },
|
||||
{
|
||||
title: "类型",
|
||||
key: "type",
|
||||
width: 150,
|
||||
render: (r) => {
|
||||
const t = r.type ?? "-";
|
||||
const kind = t.includes("denied") || t.includes("failure") || t.includes("deny") ? "error" : "default";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => t });
|
||||
},
|
||||
},
|
||||
{ title: "调用方", key: "client_id", width: 110 },
|
||||
{ title: "来源", key: "remote", width: 120 },
|
||||
{ title: "能力", key: "capability", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
render: (r) => {
|
||||
if (!r.status) return "-";
|
||||
const kind = r.status === "ok" ? "success" : "error";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => r.status });
|
||||
},
|
||||
},
|
||||
{ title: "耗时", key: "duration_ms", width: 90, render: (r) => (r.duration_ms != null ? `${r.duration_ms}ms` : "-") },
|
||||
{
|
||||
title: "参数摘要",
|
||||
key: "args_digest",
|
||||
width: 170,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.args_digest ?? "-",
|
||||
},
|
||||
{
|
||||
title: "说明",
|
||||
key: "reason",
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.reason || r.error || "-",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const args: Record<string, unknown> = { limit: limit.value };
|
||||
if (typeFilter.value) args.type = typeFilter.value;
|
||||
const r = await conn.ok<AuditListResult>("system.audit.list", args);
|
||||
entries.value = r.entries ?? [];
|
||||
total.value = r.total ?? entries.value.length;
|
||||
} catch (e) {
|
||||
msg.error(`加载审计失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runVerify() {
|
||||
try {
|
||||
verify.value = await conn.ok<AuditVerifyResult>("system.audit.verify", {});
|
||||
} catch (e) {
|
||||
msg.error(`校验失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJsonl() {
|
||||
const text = entries.value.map((e) => JSON.stringify(e)).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
msg.success(`已复制 ${entries.value.length} 条到剪贴板`);
|
||||
} catch {
|
||||
msg.warning("剪贴板不可用");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<span>审计日志</span>
|
||||
<n-text depth="3" style="font-size: 12px">共 {{ total }} 条</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-select
|
||||
v-model:value="typeFilter"
|
||||
:options="typeOptions"
|
||||
size="small"
|
||||
style="width: 140px"
|
||||
@update:value="load"
|
||||
/>
|
||||
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
|
||||
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
|
||||
<n-button size="small" @click="runVerify">完整性校验</n-button>
|
||||
<n-button size="small" @click="exportJsonl">导出</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert
|
||||
v-if="verify"
|
||||
:type="verify.ok ? 'success' : 'error'"
|
||||
:show-icon="true"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
|
||||
<template v-if="verify.legacy"> · 跳过 {{ verify.legacy }} 条历史条目</template>
|
||||
<template v-if="verify.broken_at">
|
||||
· 首个断点:{{ verify.broken_at.file }} 第 {{ verify.broken_at.line }} 行
|
||||
<template v-if="verify.broken_at.reason">({{ verify.broken_at.reason }})</template>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!entries.length" description="暂无审计记录" size="small" />
|
||||
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NModal,
|
||||
NTag,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
|
||||
import { formatBytes, formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const cwd = ref("");
|
||||
const entries = ref<FsEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const truncated = ref(false);
|
||||
|
||||
const previewOpen = ref(false);
|
||||
const previewName = ref("");
|
||||
const previewText = ref("");
|
||||
const previewMeta = ref("");
|
||||
|
||||
const writeOpen = ref(false);
|
||||
const wName = ref("");
|
||||
const wContent = ref("");
|
||||
const wAppend = ref(false);
|
||||
|
||||
const cols: DataTableColumns<FsEntry> = [
|
||||
{
|
||||
title: "名称",
|
||||
key: "name",
|
||||
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
|
||||
},
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
|
||||
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
|
||||
];
|
||||
|
||||
async function list(path: string) {
|
||||
if (!path) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await conn.ok<FsListResult>("fs.list", { path });
|
||||
entries.value = (r.entries ?? []).slice().sort((a, b) => {
|
||||
if (a.type === "dir" && b.type !== "dir") return -1;
|
||||
if (a.type !== "dir" && b.type === "dir") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
truncated.value = !!r.truncated;
|
||||
cwd.value = path;
|
||||
} catch (e) {
|
||||
msg.error(`列目录失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parentOf(p: string): string {
|
||||
const s = p.replace(/[\\/]+$/, "");
|
||||
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
if (idx < 0) return s;
|
||||
if (idx === 0) return s.slice(0, 1); // POSIX 根
|
||||
const r = s.slice(0, idx);
|
||||
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
|
||||
}
|
||||
|
||||
function onRowClick(row: FsEntry) {
|
||||
if (row.type === "dir") list(row.path);
|
||||
else preview(row);
|
||||
}
|
||||
|
||||
async function preview(row: FsEntry) {
|
||||
try {
|
||||
const r = await conn.ok<FsReadResult>("fs.read", {
|
||||
path: row.path,
|
||||
encoding: "utf8",
|
||||
max_bytes: 262144,
|
||||
});
|
||||
previewName.value = row.name;
|
||||
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
|
||||
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
|
||||
previewOpen.value = true;
|
||||
} catch (e) {
|
||||
msg.error(`读取失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openWrite() {
|
||||
wName.value = "";
|
||||
wContent.value = "";
|
||||
wAppend.value = false;
|
||||
writeOpen.value = true;
|
||||
}
|
||||
|
||||
function doWrite() {
|
||||
if (!wName.value) return;
|
||||
const sep = cwd.value.includes("\\") ? "\\" : "/";
|
||||
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
|
||||
dialog.warning({
|
||||
title: "确认写入被控端",
|
||||
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
|
||||
positiveText: "写入",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => writeTo(path),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTo(path: string) {
|
||||
try {
|
||||
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
|
||||
msg.success(`已写入 ${path}`);
|
||||
writeOpen.value = false;
|
||||
list(cwd.value);
|
||||
} catch (e) {
|
||||
msg.error(`写入失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureStart() {
|
||||
try {
|
||||
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
|
||||
let start = info.agent_home ?? "";
|
||||
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
|
||||
list(start);
|
||||
} catch {
|
||||
list(conn.info ? "/" : "");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) ensureStart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<n-space align="center" :wrap="false">
|
||||
<n-input
|
||||
v-model:value="cwd"
|
||||
size="small"
|
||||
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
|
||||
style="flex: 1"
|
||||
@keyup.enter="list(cwd)"
|
||||
/>
|
||||
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
|
||||
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
|
||||
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
|
||||
</n-space>
|
||||
<div class="path">
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
当前:{{ cwd || "-" }} · {{ entries.length }} 项
|
||||
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" style="margin-top: 16px">
|
||||
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="cols"
|
||||
:data="entries"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="520"
|
||||
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
|
||||
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
|
||||
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
|
||||
</n-modal>
|
||||
|
||||
<!-- 新建/写入 -->
|
||||
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
|
||||
<n-input
|
||||
v-model:value="wContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 16 }"
|
||||
placeholder="文件内容(utf8)"
|
||||
/>
|
||||
<n-space align="center">
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="wAppend ? 'warning' : 'default'"
|
||||
checkable
|
||||
:checked="wAppend"
|
||||
@update:checked="(v: boolean) => (wAppend = v)"
|
||||
>
|
||||
追加模式
|
||||
</n-tag>
|
||||
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.path {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preview {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NText,
|
||||
NInputGroup,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NPopconfirm,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import { useNodeStore } from "../stores/node";
|
||||
|
||||
const conn = useConnStore();
|
||||
const nodeStore = useNodeStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const newName = ref("");
|
||||
const newUrl = ref("ws://127.0.0.1:8765");
|
||||
const newKey = ref("");
|
||||
|
||||
async function doConnect() {
|
||||
try {
|
||||
await conn.connect();
|
||||
msg.success("连接成功");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectTo(id: string) {
|
||||
const n = nodeStore.nodes.find((x) => x.id === id);
|
||||
if (!n) return;
|
||||
nodeStore.setCurrent(id);
|
||||
try {
|
||||
await conn.connect(n.url, n.key);
|
||||
msg.success(`已连接 ${n.name}`);
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
if (!newName.value || !newUrl.value) {
|
||||
msg.warning("请填写名称与地址");
|
||||
return;
|
||||
}
|
||||
nodeStore.add({ name: newName.value, url: newUrl.value, key: newKey.value });
|
||||
newName.value = "";
|
||||
newKey.value = "";
|
||||
msg.success("已保存设备");
|
||||
}
|
||||
|
||||
/** 能力按前缀分组,便于核对。 */
|
||||
const capGroups = computed(() => {
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const c of conn.capabilities) {
|
||||
const prefix = c.name.split(".")[0] ?? "other";
|
||||
(groups[prefix] ??= []).push(c.name);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<n-card title="连接被控端" size="small">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
|
||||
<n-input
|
||||
v-model:value="conn.key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="PSK 预共享密钥"
|
||||
style="flex: 2"
|
||||
/>
|
||||
<n-button type="primary" :loading="conn.connecting" @click="doConnect">连接</n-button>
|
||||
</n-input-group>
|
||||
<div v-if="conn.info" class="meta">
|
||||
<n-tag type="success" size="small" round>已连接</n-tag>
|
||||
<n-text depth="3">agent {{ conn.info.agent_version ?? "?" }}</n-text>
|
||||
<n-text depth="3">
|
||||
授权 {{ conn.info.authorized ? `${conn.info.authorized.length} 项` : "全部" }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="mono">
|
||||
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
|
||||
</n-text>
|
||||
</div>
|
||||
<n-empty v-else description="尚未连接" size="small" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="已保存设备" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ nodeStore.nodes.length }} 台</n-text>
|
||||
</template>
|
||||
<div v-if="nodeStore.nodes.length" class="nodes">
|
||||
<div
|
||||
v-for="n in nodeStore.nodes"
|
||||
:key="n.id"
|
||||
class="node"
|
||||
:class="{ active: nodeStore.currentId === n.id }"
|
||||
>
|
||||
<div class="node-main">
|
||||
<div class="node-name">{{ n.name }}</div>
|
||||
<n-text depth="3" class="mono" style="font-size: 12px">{{ n.url }}</n-text>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
|
||||
<n-popconfirm @positive-click="nodeStore.remove(n.id)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary>删除</n-button>
|
||||
</template>
|
||||
确认删除该设备?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无保存的设备" size="small" />
|
||||
|
||||
<div class="add-row">
|
||||
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
|
||||
<n-input v-model:value="newUrl" placeholder="地址" size="small" style="flex: 2" />
|
||||
<n-input v-model:value="newKey" placeholder="PSK" size="small" style="flex: 1" />
|
||||
<n-button size="small" @click="addNode">保存</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card title="能力清单" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.capabilities.length }} 项</n-text>
|
||||
</template>
|
||||
<n-collapse v-if="conn.capabilities.length" :default-expanded-names="Object.keys(capGroups)">
|
||||
<n-collapse-item
|
||||
v-for="(caps, prefix) in capGroups"
|
||||
:key="prefix"
|
||||
:name="prefix"
|
||||
:title="`${prefix}.* (${caps.length})`"
|
||||
>
|
||||
<div class="cap-list">
|
||||
<code v-for="c in caps" :key="c">{{ c }}</code>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty v-else description="连接后展示" size="small" />
|
||||
</n-card>
|
||||
|
||||
<n-card title="日志" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" quaternary @click="conn.clearLogs()">清空</n-button>
|
||||
</template>
|
||||
<div class="logs">
|
||||
<div v-for="(l, i) in conn.logs.slice().reverse()" :key="i">{{ l }}</div>
|
||||
<div v-if="conn.logs.length === 0" class="logs-empty">暂无日志</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.grid > :nth-child(3),
|
||||
.grid > :nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.node.active {
|
||||
border-color: #63e2b7;
|
||||
}
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cap-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.cap-list code {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.logs {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
opacity: 0.85;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.logs-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NProgress,
|
||||
NStatistic,
|
||||
NButton,
|
||||
NSpace,
|
||||
NEmpty,
|
||||
NText,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { DiskInfo, SystemInfo, SystemStatus } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const info = ref<SystemInfo | null>(null);
|
||||
const status = ref<SystemStatus | null>(null);
|
||||
const auto = ref(true);
|
||||
let timer: number | undefined;
|
||||
|
||||
/** 契约只给 total/free,已用量需自算。 */
|
||||
function usedOf(d: DiskInfo): number | undefined {
|
||||
if (d.total == null || d.free == null) return undefined;
|
||||
return Math.max(0, d.total - d.free);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!conn.connected) return;
|
||||
try {
|
||||
const [i, s] = await Promise.all([
|
||||
conn.ok<SystemInfo>("system.info"),
|
||||
conn.ok<SystemStatus>("system.status"),
|
||||
]);
|
||||
info.value = i;
|
||||
status.value = s;
|
||||
} catch (e) {
|
||||
msg.error(`刷新失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAuto(v: boolean) {
|
||||
auto.value = v;
|
||||
if (v) start(); else stop();
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
timer = window.setInterval(refresh, 3000);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) refresh();
|
||||
if (auto.value) start();
|
||||
});
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 14px">
|
||||
<n-space align="center">
|
||||
<n-text depth="3">自动刷新(3s)</n-text>
|
||||
<n-switch :value="auto" size="small" @update:value="toggleAuto" />
|
||||
</n-space>
|
||||
<n-button size="small" @click="refresh">手动刷新</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="主机">
|
||||
<n-statistic label="主机名" :value="info?.hostname ?? '-'" />
|
||||
<div class="kv"><span>系统</span><b>{{ info?.os }} {{ info?.os_version }}</b></div>
|
||||
<div class="kv"><span>架构</span><b>{{ info?.arch }}</b></div>
|
||||
<div class="kv"><span>CPU</span><b>{{ info?.cpu_model }}</b></div>
|
||||
<div class="kv"><span>核心</span><b>{{ info?.cpu_cores }}</b></div>
|
||||
<div class="kv"><span>管理员</span><b>{{ info?.is_admin ? "是" : "否" }}</b></div>
|
||||
<div class="kv"><span>开机</span><b>{{ formatDuration(info?.uptime_sec) }}</b></div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="资源">
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">CPU 使用率</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.cpu_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.cpu_pct ?? 0) > 80 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">
|
||||
内存 {{ formatBytes(status?.memory_used) }} / {{ formatBytes(status?.memory_total) }}
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.memory_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.memory_pct ?? 0) > 85 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
<n-statistic
|
||||
label="内存总量"
|
||||
:value="formatBytes(info?.memory_total ?? status?.memory_total)"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="磁盘">
|
||||
<div v-if="status?.disks?.length" class="disks">
|
||||
<div v-for="(d, i) in status.disks" :key="i" class="disk">
|
||||
<div class="gauge-label">
|
||||
{{ d.drive ?? "-" }}
|
||||
<span class="dim">
|
||||
{{ formatBytes(usedOf(d)) }} / {{ formatBytes(d.total) }} · 剩余
|
||||
{{ formatBytes(d.free) }}
|
||||
</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(d.used_pct ?? 0)"
|
||||
:height="8"
|
||||
:color="(d.used_pct ?? 0) > 90 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无磁盘数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="网络适配器">
|
||||
<div v-if="status?.net?.length" class="net">
|
||||
<div v-for="(a, i) in status.net" :key="i" class="kv">
|
||||
<span>{{ a.adapter ?? "-" }}</span>
|
||||
<b>
|
||||
{{ a.ip || "-" }}
|
||||
<n-tag :type="a.up ? 'success' : 'default'" size="tiny" style="margin-left: 6px">
|
||||
{{ a.up ? "已连接" : "未连接" }}
|
||||
</n-tag>
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无网络数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.kv span {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.gauge {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.gauge-label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dim {
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.disks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInput,
|
||||
NEmpty,
|
||||
NTag,
|
||||
NAlert,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { ProcessEntry, ServiceEntry } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
|
||||
const procs = ref<ProcessEntry[]>([]);
|
||||
const services = ref<ServiceEntry[]>([]);
|
||||
const procFilter = ref("");
|
||||
const svcFilter = ref("");
|
||||
const loading = ref(false);
|
||||
const listError = ref("");
|
||||
|
||||
/** `system.service.list` 仅 Windows 被控端可用——事前门控。 */
|
||||
const servicesAvailable = computed(() => conn.platformReady);
|
||||
|
||||
const procCols: DataTableColumns<ProcessEntry> = [
|
||||
{ title: "PID", key: "pid", width: 90, sorter: (a, b) => (a.pid ?? 0) - (b.pid ?? 0) },
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "CPU",
|
||||
key: "cpu_pct",
|
||||
width: 100,
|
||||
sorter: (a, b) => (a.cpu_pct ?? 0) - (b.cpu_pct ?? 0),
|
||||
render: (r) => (r.cpu_pct != null ? `${r.cpu_pct.toFixed(1)}%` : "-"),
|
||||
},
|
||||
{
|
||||
title: "内存",
|
||||
key: "memory_bytes",
|
||||
width: 110,
|
||||
sorter: (a, b) => (a.memory_bytes ?? 0) - (b.memory_bytes ?? 0),
|
||||
render: (r) => (r.memory_bytes ? formatBytes(r.memory_bytes) : "-"),
|
||||
},
|
||||
{
|
||||
title: "已运行",
|
||||
key: "started_at",
|
||||
width: 120,
|
||||
render: (r) =>
|
||||
r.started_at ? formatDuration(Math.floor((Date.now() - r.started_at) / 1000)) : "-",
|
||||
},
|
||||
];
|
||||
|
||||
const svcCols: DataTableColumns<ServiceEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "显示名", key: "display_name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "state",
|
||||
width: 130,
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: /run/i.test(r.state ?? "") ? "success" : "default" },
|
||||
{ default: () => r.state ?? "-" },
|
||||
),
|
||||
},
|
||||
{ title: "启动类型", key: "start_type", width: 130 },
|
||||
];
|
||||
|
||||
function pick<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||
return (raw as Record<string, T[]>)[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadProcs() {
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.process.list", { limit: 200, sort_by: "cpu" });
|
||||
procs.value = pick<ProcessEntry>(raw, "processes");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
if (!servicesAvailable.value) return;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.service.list", {
|
||||
limit: 200,
|
||||
filter: svcFilter.value ? { name_pattern: svcFilter.value } : undefined,
|
||||
});
|
||||
services.value = pick<ServiceEntry>(raw, "services");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProcs = ref<ProcessEntry[]>([]);
|
||||
function applyProcFilter() {
|
||||
const f = procFilter.value.trim().toLowerCase();
|
||||
filteredProcs.value = f
|
||||
? procs.value.filter((p) => p.name?.toLowerCase().includes(f))
|
||||
: procs.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) {
|
||||
loadProcs().then(applyProcFilter);
|
||||
loadServices();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<n-card v-else size="small">
|
||||
<n-alert v-if="listError" type="error" :show-icon="true" style="margin-bottom: 12px">
|
||||
{{ listError }}
|
||||
</n-alert>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="proc" tab="进程">
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input
|
||||
v-model:value="procFilter"
|
||||
placeholder="按名称筛选"
|
||||
size="small"
|
||||
style="width: 220px"
|
||||
@input="applyProcFilter"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" @click="loadProcs().then(applyProcFilter)">
|
||||
刷新
|
||||
</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filteredProcs.length }} / {{ procs.length }}</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="procCols"
|
||||
:data="filteredProcs"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="svc" tab="服务">
|
||||
<n-alert v-if="!servicesAvailable" type="warning" :show-icon="true">
|
||||
`system.service.list` 仅在被控端为 <b>Windows</b> 时可用;当前被控端为
|
||||
<b>{{ conn.nodeOs ?? "未知" }}</b>。
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input v-model:value="svcFilter" placeholder="筛选服务名" size="small" style="width: 220px" />
|
||||
<n-button size="small" :loading="loading" @click="loadServices">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ services.length }} 项</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!services.length" description="暂无服务数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="svcCols"
|
||||
:data="services"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NAlert,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AppEntry } from "../types";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const apps = ref<AppEntry[]>([]);
|
||||
const filter = ref("");
|
||||
const pkg = ref("");
|
||||
const loading = ref(false);
|
||||
const installing = ref(false);
|
||||
const installResult = ref("");
|
||||
const error = ref("");
|
||||
|
||||
/** `app.list` / `app.install` 仅 Windows 被控端可用——事前门控。 */
|
||||
const available = computed(() => conn.platformReady);
|
||||
|
||||
const cols: DataTableColumns<AppEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "版本", key: "version", width: 150 },
|
||||
{ title: "发布者", key: "publisher", ellipsis: { tooltip: true } },
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
];
|
||||
|
||||
function pickApps(raw: unknown): AppEntry[] {
|
||||
if (Array.isArray(raw)) return raw as AppEntry[];
|
||||
if (raw && typeof raw === "object") {
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.apps)) return o.apps as AppEntry[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = filter.value.trim().toLowerCase();
|
||||
if (!f) return apps.value;
|
||||
return apps.value.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(f) ||
|
||||
a.publisher?.toLowerCase().includes(f) ||
|
||||
a.source?.toLowerCase().includes(f),
|
||||
);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!available.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
apps.value = pickApps(await conn.ok("app.list", {}, 120000));
|
||||
} catch (e) {
|
||||
error.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doInstall() {
|
||||
const p = pkg.value.trim();
|
||||
if (!p) return;
|
||||
dialog.warning({
|
||||
title: "确认安装",
|
||||
content: `将在被控端静默安装:${p}`,
|
||||
positiveText: "安装",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => install(p),
|
||||
});
|
||||
}
|
||||
|
||||
async function install(p: string) {
|
||||
installing.value = true;
|
||||
installResult.value = "";
|
||||
try {
|
||||
// winget 安装可能耗时数分钟,控制端 RPC 超时放大到 10 分钟。
|
||||
const r = await conn.ok<Record<string, unknown>>(
|
||||
"app.install",
|
||||
{ package: p, silent: true },
|
||||
600_000,
|
||||
);
|
||||
installResult.value = JSON.stringify(r, null, 2);
|
||||
msg.success("安装完成");
|
||||
load();
|
||||
} catch (e) {
|
||||
installResult.value = String(e);
|
||||
msg.error(`安装失败:${e}`);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-alert v-if="!available" type="warning" :show-icon="true" style="margin-bottom: 14px">
|
||||
软件能力(<code>app.list</code> / <code>app.install</code>)依赖被控端 <code>winget</code>,
|
||||
<b>仅在被控端为 Windows 时可用</b>;当前被控端为 <b>{{ conn.nodeOs ?? "未知" }}</b>,
|
||||
本页已禁用。
|
||||
</n-alert>
|
||||
<n-alert v-else type="info" :show-icon="true" style="margin-bottom: 14px">
|
||||
软件安装依赖被控端 <code>winget</code>。安装可能耗时数分钟,已把调用超时放大到 10 分钟。
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 14px">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-card size="small" title="静默安装(app.install)">
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="pkg"
|
||||
:disabled="!available"
|
||||
placeholder="winget 包名或 ID,例如 Git.Git / 7zip.7zip"
|
||||
style="width: 420px"
|
||||
/>
|
||||
<n-button type="primary" :loading="installing" :disabled="!available" @click="doInstall">
|
||||
安装
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="installResult" class="result">{{ installResult }}</pre>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" title="已装软件(app.list)" style="margin-top: 16px">
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="filter"
|
||||
:disabled="!available"
|
||||
placeholder="筛选"
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" :disabled="!available" @click="load">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filtered.length }}</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!apps.length && !loading"
|
||||
:description="available ? '暂无数据(点刷新)' : '当前被控端非 Windows,不支持'"
|
||||
size="small"
|
||||
/>
|
||||
<n-data-table v-else :columns="cols" :data="filtered" :bordered="false" size="small" :max-height="480" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.result {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NText,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NList,
|
||||
NListItem,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
|
||||
interface ExecResult {
|
||||
exit_code: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
duration_ms?: number;
|
||||
truncated?: boolean;
|
||||
killed?: boolean;
|
||||
}
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const cmd = ref("");
|
||||
const timeoutMs = ref(30000);
|
||||
const running = ref(false);
|
||||
const result = ref<ExecResult | null>(null);
|
||||
const history = ref<string[]>([]);
|
||||
|
||||
const output = computed(() => {
|
||||
if (!result.value) return "";
|
||||
const r = result.value;
|
||||
let out = "";
|
||||
if (r.stdout) out += r.stdout;
|
||||
if (r.stderr) out += (out ? "\n" : "") + r.stderr;
|
||||
return out || "(无输出)";
|
||||
});
|
||||
|
||||
async function exec() {
|
||||
const command = cmd.value.trim();
|
||||
if (!command) return;
|
||||
if (!conn.connected) {
|
||||
msg.warning("请先连接被控端");
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: "确认执行远程命令",
|
||||
content: `将在被控端执行:\n${command}`,
|
||||
positiveText: "执行",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => doExec(command),
|
||||
});
|
||||
}
|
||||
|
||||
async function doExec(command: string) {
|
||||
running.value = true;
|
||||
result.value = null;
|
||||
try {
|
||||
const r = await conn.ok<ExecResult>("system.shell.exec", {
|
||||
command,
|
||||
timeout_ms: timeoutMs.value,
|
||||
});
|
||||
result.value = r;
|
||||
history.value = [command, ...history.value.filter((c) => c !== command)].slice(0, 20);
|
||||
if (r.exit_code !== 0) msg.warning(`退出码 ${r.exit_code}`);
|
||||
} catch (e) {
|
||||
msg.error(`执行失败:${e}`);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reuse(c: string) {
|
||||
cmd.value = c;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small" title="命令执行(system.shell.exec)">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">⚠ 高权限操作,请谨慎</n-text>
|
||||
</template>
|
||||
<n-input
|
||||
v-model:value="cmd"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="输入命令,例如:Get-Process | Select-Object -First 5"
|
||||
/>
|
||||
<n-space align="center" style="margin-top: 12px">
|
||||
<span class="label">超时(ms)</span>
|
||||
<n-input-number v-model:value="timeoutMs" :min="1000" :step="1000" size="small" style="width: 140px" />
|
||||
<n-button type="primary" :loading="running" @click="exec">执行</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="result" size="small" title="输出">
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-tag :type="result.exit_code === 0 ? 'success' : 'error'" size="small">
|
||||
退出码 {{ result.exit_code }}
|
||||
</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ result.duration_ms }}ms</n-text>
|
||||
<n-tag v-if="result.truncated" size="small" type="warning">已截断</n-tag>
|
||||
<n-tag v-if="result.killed" size="small" type="error">超时终止</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<pre class="out">{{ output }}</pre>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="history.length" size="small" title="历史">
|
||||
<n-list>
|
||||
<n-list-item v-for="(h, i) in history" :key="i">
|
||||
<div class="hist" @click="reuse(h)">
|
||||
<code>{{ h }}</code>
|
||||
</div>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.label {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.out {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
.hist {
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist code {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hist:hover code {
|
||||
color: #63e2b7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
// @ts-expect-error type error without @types/node package
|
||||
import process from "node:process";
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 控制台契约守卫:`apps/console/src/types/index.ts` 里声明的字段名,
|
||||
* **必须**能在协议 manifest 的 `returns_schema` 里找到。
|
||||
*
|
||||
* ⚠️ 背景(2026-10-07 实测才发现):M3 七个页面写完后,用 `examples/probe.rs`
|
||||
* 对真实被控端跑了一遍字段契约探针,抓到 4 处**界面恒空**的漂移:
|
||||
* - 进程表读 `r.cpu` / `r.memory`,真源是 `cpu_pct` / `memory_bytes` → CPU、内存两列恒为 "-"
|
||||
* - 磁盘读 `d.mount || d.name` / `d.used`,真源是 `drive`,且**没有 used**(只有 total/free)→ 盘名与已用量恒空
|
||||
* - 服务表读 `r.status`,真源是 `state` → 状态列恒空
|
||||
* - 软件表读 `a.id`,真源是 `publisher`(`app.list` 结果**没有 id**)→ 整列恒空
|
||||
* 这些都不是运行时异常,是**静默空列**——类型系统抓不到(TS 只是声明,没人跟契约比对),
|
||||
* 页面上也只是显示 "-"。所以必须由本用例把「声明 ⊆ 契约」这条铁律钉死。
|
||||
*
|
||||
* 反向也查(契约 ⊆ 声明):只对 `returns_schema` **完整枚举**了属性的能力生效,
|
||||
* 避免"契约加了字段、前端没跟上"这种漏检。
|
||||
*
|
||||
* 数据源一律取 `packages/protocol/dist`(已构建),与 capabilities.test.mjs 保持一致。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { findCapability } from '../../packages/protocol/dist/index.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(here, '..', '..');
|
||||
const CONSOLE_TYPES = join(repoRoot, 'apps', 'console', 'src', 'types', 'index.ts');
|
||||
const CONN_STORE = join(repoRoot, 'apps', 'console', 'src', 'stores', 'conn.ts');
|
||||
|
||||
const source = readFileSync(CONSOLE_TYPES, 'utf8');
|
||||
|
||||
/**
|
||||
* 极简 TS interface 解析:只取**顶层**字段名(大括号深度 = 1),
|
||||
* 忽略注释、字符串与嵌套对象字面量。够用于本项目这些扁平接口。
|
||||
*/
|
||||
function fieldsOf(interfaceName) {
|
||||
const start = source.indexOf(`export interface ${interfaceName} {`);
|
||||
if (start < 0) throw new Error(`未找到 interface ${interfaceName}`);
|
||||
const bodyStart = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let body = '';
|
||||
for (let i = bodyStart; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') {
|
||||
depth += 1;
|
||||
if (depth === 1) continue;
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
body += ch;
|
||||
}
|
||||
|
||||
const names = new Set();
|
||||
let nest = 0;
|
||||
for (const raw of body.split('\n')) {
|
||||
const line = raw.replace(/\/\/.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
const opens = (line.match(/\{/g) ?? []).length;
|
||||
const closes = (line.match(/\}/g) ?? []).length;
|
||||
// 只在最外层(nest === 0)收集 `key?: type` / `key: type`
|
||||
if (nest === 0) {
|
||||
const m = /^([A-Za-z_$][\w$]*)\s*\??\s*:/.exec(line);
|
||||
if (m) names.add(m[1]);
|
||||
// 索引签名 `[k: string]: unknown` 跳过
|
||||
}
|
||||
nest += opens - closes;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 取某能力 `returns_schema.properties` 的键集合。 */
|
||||
function contractTop(capName) {
|
||||
const cap = findCapability(capName);
|
||||
assert.ok(cap, `能力 ${capName} 不存在于 manifest`);
|
||||
return new Set(Object.keys(cap.returns_schema?.properties ?? {}));
|
||||
}
|
||||
|
||||
/** 取某能力某个数组字段 `items.properties` 的键集合。 */
|
||||
function contractItems(capName, arrayField) {
|
||||
const cap = findCapability(capName);
|
||||
const arr = cap?.returns_schema?.properties?.[arrayField];
|
||||
assert.ok(arr, `${capName}.returns_schema.${arrayField} 不存在`);
|
||||
return new Set(Object.keys(arr.items?.properties ?? {}));
|
||||
}
|
||||
|
||||
/** 取某能力某对象字段(如 system.status.disks)的 items/自属性键集合。 */
|
||||
function contractNested(capName, objField) {
|
||||
const cap = findCapability(capName);
|
||||
const node = cap?.returns_schema?.properties?.[objField];
|
||||
assert.ok(node, `${capName}.returns_schema.${objField} 不存在`);
|
||||
return new Set(Object.keys(node.items?.properties ?? node.properties ?? {}));
|
||||
}
|
||||
|
||||
// ---------- 声明 ⊆ 契约(抓「声明了不存在的字段」) ----------
|
||||
|
||||
/** [ 控制台 interface, 能力名, 契约字段来源 ] */
|
||||
const FORWARD_CASES = [
|
||||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||||
['DiskInfo', 'system.status', () => contractNested('system.status', 'disks')],
|
||||
['NetInfo', 'system.status', () => contractNested('system.status', 'net')],
|
||||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||||
['FsListResult', 'fs.list', () => contractTop('fs.list')],
|
||||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||||
['AuditVerifyResult', 'system.audit.verify', () => contractTop('system.audit.verify')],
|
||||
];
|
||||
|
||||
for (const [iface, capName, contract] of FORWARD_CASES) {
|
||||
test(`契约:${iface} 的字段都存在于 ${capName} 的 returns_schema`, () => {
|
||||
const declared = fieldsOf(iface);
|
||||
const allowed = contract();
|
||||
const missing = [...declared].filter((f) => !allowed.has(f));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${iface} 声明了 ${capName} 契约中不存在的字段:${missing.join(', ')}\n` +
|
||||
`(契约字段:${[...allowed].join(', ')})\n` +
|
||||
`👉 这类漂移不会报错,只会让界面出现**静默空列**。`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 契约 ⊆ 声明(抓「契约加了字段、前端没跟上」) ----------
|
||||
|
||||
/**
|
||||
* 只对 returns_schema **完整枚举**属性的能力做反向校验,
|
||||
* 否则 `type: 'object'` 的空壳(如 system.audit.list 的 entries item)会误报。
|
||||
*/
|
||||
const REVERSE_CASES = [
|
||||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||||
];
|
||||
|
||||
for (const [iface, capName, contract] of REVERSE_CASES) {
|
||||
test(`契约:${capName} 的字段都被 ${iface} 覆盖`, () => {
|
||||
const declared = fieldsOf(iface);
|
||||
const required = contract();
|
||||
const missed = [...required].filter((f) => !declared.has(f));
|
||||
assert.deepEqual(
|
||||
missed,
|
||||
[],
|
||||
`${capName} 契约里有 ${missed.join(', ')},但 ${iface} 未声明(前端拿不到这些字段)。`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 平台门控清单与 agent 实现一致 ----------
|
||||
|
||||
test('平台门控:控制台的 WINDOWS_ONLY_CAPS 与被控端抛 UNSUPPORTED_PLATFORM 的能力一致', () => {
|
||||
const storeSrc = readFileSync(CONN_STORE, 'utf8');
|
||||
const m = /WINDOWS_ONLY_CAPS\s*=\s*\[([^\]]*)\]/.exec(storeSrc);
|
||||
assert.ok(m, '未在 conn.ts 找到 WINDOWS_ONLY_CAPS');
|
||||
const declared = new Set(
|
||||
m[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// 从被控端实现里找平台门控点:`UNSUPPORTED_PLATFORM` 出现处附近的能力名。
|
||||
const gated = new Set();
|
||||
for (const rel of [
|
||||
['apps', 'agent', 'src', 'capabilities', 'app.ts'],
|
||||
['apps', 'agent', 'src', 'capabilities', 'system.ts'],
|
||||
]) {
|
||||
const src = readFileSync(join(repoRoot, ...rel), 'utf8');
|
||||
// requireWindows('app.xxx') 与 `${capability} 仅在被控端为 Windows 时可用` 两种写法
|
||||
for (const mm of src.matchAll(/requireWindows\(\s*['"]([\w.]+)['"]/g)) gated.add(mm[1]);
|
||||
for (const mm of src.matchAll(/['"]([\w.]+) 仅在被控端为 Windows 时可用['"]/g)) {
|
||||
// 该文件里 system.service.list 是以字面量写的;app.ts 是模板串(已由上面捕获)
|
||||
if (mm[1].includes('.')) gated.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const declaredArr = [...declared].sort();
|
||||
const gatedArr = [...gated].sort();
|
||||
assert.deepEqual(
|
||||
declaredArr,
|
||||
gatedArr,
|
||||
'控制台的平台门控清单与被控端实际门控的能力不一致:\n' +
|
||||
` 控制台声明:${declaredArr.join(', ')}\n` +
|
||||
` 被控端实际:${gatedArr.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 审计事件类别与真源一致 ----------
|
||||
|
||||
test('审计:控制台的 AuditType 覆盖 audit.ts 的全部 AuditType 取值', () => {
|
||||
const auditSrc = readFileSync(
|
||||
join(repoRoot, 'apps', 'agent', 'src', 'audit.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const union = /export type AuditType\s*=([\s\S]*?);/.exec(auditSrc);
|
||||
assert.ok(union, '未在 audit.ts 找到 AuditType 联合类型');
|
||||
const real = new Set(
|
||||
[...union[1].matchAll(/'([\w.]+)'/g)].map((mm) => mm[1]),
|
||||
);
|
||||
assert.ok(real.size >= 8, `AuditType 解析异常,仅取到 ${real.size} 个`);
|
||||
|
||||
const declared = fieldsOf('AuditEntry');
|
||||
assert.ok(declared.size > 0, 'AuditEntry 解析失败');
|
||||
// union 类型不是 interface —— 直接正则取类型字面量
|
||||
const m = /export type AuditType\s*=([\s\S]*?);/.exec(
|
||||
readFileSync(CONSOLE_TYPES, 'utf8'),
|
||||
);
|
||||
assert.ok(m, '未在控制台 types/index.ts 找到 AuditType');
|
||||
const consoleTypes = new Set([...m[1].matchAll(/"([\w.]+)"/g)].map((mm) => mm[1]));
|
||||
|
||||
const missed = [...real].filter((t) => !consoleTypes.has(t));
|
||||
assert.deepEqual(
|
||||
missed,
|
||||
[],
|
||||
`控制台 AuditType 缺少:${missed.join(', ')}(真源:${[...real].join(', ')})`,
|
||||
);
|
||||
});
|
||||