新增本地服务器管理面板
新增 egui 服务器管理面板并支持 SSH alias 多服务器巡检 接入硬件状态、服务状态、HTTP 探测和生产巡检状态展示 增加受控 systemd 启动关闭重启操作和中文字体注入 补充本地服务器面板技术方案与团队共享记忆
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
|
||||
|
||||
- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
|
||||
- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh <alias> sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。
|
||||
- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。
|
||||
- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-10 公开作品互动能力进入后台全局配置
|
||||
|
||||
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
|
||||
|
||||
@@ -95,6 +95,15 @@ npm run dev:web
|
||||
npm run dev:admin-web
|
||||
```
|
||||
|
||||
本地 SSH 服务器管理面板:
|
||||
|
||||
```bash
|
||||
npm run server-manager:panel
|
||||
```
|
||||
|
||||
该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart <unit>` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。
|
||||
面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。
|
||||
|
||||
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||
|
||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
|
||||
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 本地 SSH 服务器管理面板技术方案
|
||||
|
||||
日期:`2026-06-11`
|
||||
|
||||
## 背景
|
||||
|
||||
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer`、`/readyz`、`/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
|
||||
|
||||
## 范围
|
||||
|
||||
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
|
||||
- 从本机 `~/.ssh/config` 的 `Host` alias 发现服务器;只展示不含通配符的 alias。
|
||||
- 支持多个服务器,左侧服务器侧边栏可收起。
|
||||
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
|
||||
- 支持对允许的 systemd unit 执行启动、关闭、重启。
|
||||
|
||||
## 命令入口
|
||||
|
||||
```bash
|
||||
npm run server-manager:panel
|
||||
```
|
||||
|
||||
等价于:
|
||||
|
||||
```bash
|
||||
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊,可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`。
|
||||
|
||||
## SSH 约定
|
||||
|
||||
本地 `~/.ssh/config` 中需要存在类似:
|
||||
|
||||
```sshconfig
|
||||
Host dev
|
||||
HostName 10.2.0.10
|
||||
User genarrative
|
||||
|
||||
Host release
|
||||
HostName genarrative.world
|
||||
User genarrative
|
||||
```
|
||||
|
||||
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
|
||||
|
||||
```bash
|
||||
sudo -n systemctl <start|stop|restart> <unit>
|
||||
```
|
||||
|
||||
若 SSH 用户是 root,则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo;否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
|
||||
|
||||
## 健康检查内容
|
||||
|
||||
只读巡检覆盖:
|
||||
|
||||
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
|
||||
- 内存 / swap 使用情况。
|
||||
- `/`、`/var`、`/opt`、`/stdb`、`/data` 中存在路径的磁盘使用率。
|
||||
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service`、`genarrative-health-patrol.timer`、`genarrative-database-backup.timer` 的 systemd 状态。
|
||||
- `http://127.0.0.1:8082/healthz`、`/readyz`、`http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
|
||||
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
|
||||
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
|
||||
|
||||
## 服务操作安全边界
|
||||
|
||||
面板只允许 `start`、`stop`、`restart` 三种动作,并且 unit 名必须匹配安全字符集:
|
||||
|
||||
```text
|
||||
A-Z a-z 0-9 . _ - @ :
|
||||
```
|
||||
|
||||
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit,并提供“其他 unit”输入框;该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
|
||||
|
||||
## 状态判定
|
||||
|
||||
- service / HTTP 探测失败:`CRITICAL`。
|
||||
- 磁盘使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||
- 内存使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`。
|
||||
|
||||
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||
"dev:web": "node scripts/dev.mjs web",
|
||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||
|
||||
1779
server-rs/Cargo.lock
generated
1779
server-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ members = [
|
||||
"crates/platform-wechat",
|
||||
"crates/platform-speech",
|
||||
"crates/platform-agent",
|
||||
"crates/server-manager-panel",
|
||||
"crates/shared-contracts",
|
||||
"crates/shared-kernel",
|
||||
"crates/shared-logging",
|
||||
|
||||
13
server-rs/crates/server-manager-panel/Cargo.toml
Normal file
13
server-rs/crates/server-manager-panel/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "server-manager-panel"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.33", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"wayland",
|
||||
"x11",
|
||||
] }
|
||||
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::health::{
|
||||
DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot,
|
||||
};
|
||||
use crate::remote::{
|
||||
RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check,
|
||||
spawn_service_action,
|
||||
};
|
||||
use crate::ssh_config::{SshAlias, discover_ssh_aliases};
|
||||
|
||||
const DEFAULT_MANAGED_SERVICES: &[&str] = &[
|
||||
"genarrative-api.service",
|
||||
"spacetimedb.service",
|
||||
"nginx.service",
|
||||
"genarrative-health-patrol.timer",
|
||||
"genarrative-database-backup.timer",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerManagerApp {
|
||||
servers: Vec<ServerState>,
|
||||
selected_alias: Option<String>,
|
||||
sidebar_collapsed: bool,
|
||||
tx: RemoteSender,
|
||||
rx: RemoteReceiver,
|
||||
pending_confirmation: Option<ServiceConfirmation>,
|
||||
custom_service_name: String,
|
||||
}
|
||||
|
||||
impl Default for ServerManagerApp {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let aliases = discover_ssh_aliases();
|
||||
let selected_alias = aliases.first().map(|alias| alias.name.clone());
|
||||
Self {
|
||||
servers: aliases.into_iter().map(ServerState::new).collect(),
|
||||
selected_alias,
|
||||
sidebar_collapsed: false,
|
||||
tx,
|
||||
rx,
|
||||
pending_confirmation: None,
|
||||
custom_service_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for ServerManagerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.drain_remote_events(ctx);
|
||||
self.render_confirm_dialog(ctx);
|
||||
|
||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button(if self.sidebar_collapsed {
|
||||
"展开侧栏"
|
||||
} else {
|
||||
"收起侧栏"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_collapsed = !self.sidebar_collapsed;
|
||||
}
|
||||
if ui.button("重新读取 SSH alias").clicked() {
|
||||
self.reload_aliases();
|
||||
}
|
||||
if let Some(alias) = self.selected_alias.clone() {
|
||||
if ui.button("刷新当前服务器").clicked() {
|
||||
self.refresh_server(&alias);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("本地 SSH alias 管理");
|
||||
});
|
||||
});
|
||||
|
||||
if !self.sidebar_collapsed {
|
||||
egui::SidePanel::left("server_sidebar")
|
||||
.resizable(true)
|
||||
.default_width(260.0)
|
||||
.width_range(180.0..=360.0)
|
||||
.show(ctx, |ui| self.render_sidebar(ui));
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if self.servers.is_empty() {
|
||||
self.render_empty_state(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(alias) = self.selected_alias.clone() else {
|
||||
self.render_empty_state(ui);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(index) = self.server_index(&alias) {
|
||||
self.render_server_detail(ui, index);
|
||||
} else {
|
||||
ui.label("请选择服务器");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerManagerApp {
|
||||
fn drain_remote_events(&mut self, ctx: &egui::Context) {
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
match event {
|
||||
RemoteEvent::Health { alias, result } => {
|
||||
if let Some(server) = self.server_mut(&alias) {
|
||||
server.loading = false;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
server.error = None;
|
||||
server.report = Some(report);
|
||||
}
|
||||
Err(error) => {
|
||||
server.error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoteEvent::ServiceAction {
|
||||
alias,
|
||||
service,
|
||||
action,
|
||||
result,
|
||||
} => {
|
||||
if let Some(server) = self.server_mut(&alias) {
|
||||
server.action_in_progress = None;
|
||||
server.action_log = Some(format!(
|
||||
"{} {}: {}\n{}{}",
|
||||
action.label(),
|
||||
service,
|
||||
result.summary,
|
||||
result.stdout,
|
||||
result.stderr
|
||||
));
|
||||
server.loading = true;
|
||||
spawn_health_check(alias, self.tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("服务器");
|
||||
ui.add_space(8.0);
|
||||
let mut refresh_alias: Option<String> = None;
|
||||
|
||||
for server in &mut self.servers {
|
||||
let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str());
|
||||
let response = ui.selectable_label(selected, server_label(server));
|
||||
if response.clicked() {
|
||||
self.selected_alias = Some(server.alias.name.clone());
|
||||
}
|
||||
response.on_hover_text(server.alias.source.display().to_string());
|
||||
ui.horizontal(|ui| {
|
||||
let status = server.status();
|
||||
ui.colored_label(level_color(status), status.label());
|
||||
if server.loading {
|
||||
ui.spinner();
|
||||
}
|
||||
if ui.small_button("刷新").clicked() {
|
||||
refresh_alias = Some(server.alias.name.clone());
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
if let Some(alias) = refresh_alias {
|
||||
self.refresh_server(&alias);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty_state(&mut self, ui: &mut egui::Ui) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.heading("未发现 SSH alias");
|
||||
ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。");
|
||||
if ui.button("重新读取").clicked() {
|
||||
self.reload_aliases();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) {
|
||||
let alias = self.servers[index].alias.name.clone();
|
||||
let status = self.servers[index].status();
|
||||
let loading = self.servers[index].loading;
|
||||
let report = self.servers[index].report.clone();
|
||||
let error = self.servers[index].error.clone();
|
||||
let action_log = self.servers[index].action_log.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&alias);
|
||||
ui.colored_label(level_color(status), status.label());
|
||||
if loading {
|
||||
ui.spinner();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
if let Some(error) = error {
|
||||
ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
if let Some(report) = report {
|
||||
self.render_report(ui, &alias, &report);
|
||||
} else {
|
||||
ui.label("尚未执行巡检。");
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
self.render_service_controls(ui, &alias, index);
|
||||
|
||||
if let Some(log) = action_log {
|
||||
ui.add_space(12.0);
|
||||
egui::CollapsingHeader::new("最近一次服务操作输出")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut log.clone())
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_rows(8)
|
||||
.interactive(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
info_chip(ui, "主机", value_or_dash(&report.host.hostname));
|
||||
info_chip(ui, "内核", value_or_dash(&report.host.kernel));
|
||||
info_chip(ui, "运行时间", value_or_dash(&report.host.uptime));
|
||||
info_chip(ui, "检查时间", value_or_dash(&report.checked_at));
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
egui::CollapsingHeader::new("硬件状态")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model));
|
||||
info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores));
|
||||
info_chip(ui, "负载", value_or_dash(&report.hardware.load_average));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
memory_row(ui, "内存", &report.hardware.memory);
|
||||
memory_row(ui, "Swap", &report.hardware.swap);
|
||||
ui.add_space(6.0);
|
||||
for disk in &report.hardware.disks {
|
||||
disk_row(ui, disk);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for sensor in &report.hardware.sensors {
|
||||
ui.label(sensor);
|
||||
}
|
||||
});
|
||||
|
||||
egui::CollapsingHeader::new("服务状态")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(format!("{alias}_services"))
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("服务");
|
||||
ui.strong("状态");
|
||||
ui.strong("子状态");
|
||||
ui.strong("Unit");
|
||||
ui.end_row();
|
||||
for service in &report.services {
|
||||
service_row(ui, service);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
egui::CollapsingHeader::new("HTTP 探测")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(format!("{alias}_probes"))
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("探测");
|
||||
ui.strong("状态码");
|
||||
ui.strong("耗时");
|
||||
ui.strong("目标");
|
||||
ui.end_row();
|
||||
for probe in &report.probes {
|
||||
probe_row(ui, probe);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(patrol) = &report.health_patrol {
|
||||
egui::CollapsingHeader::new("生产健康巡检")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(level_color(patrol.level), &patrol.status);
|
||||
ui.label(value_or_dash(&patrol.checked_at));
|
||||
ui.label(value_or_dash(&patrol.summary));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut report.raw_output.clone())
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_rows(12)
|
||||
.interactive(false),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) {
|
||||
ui.heading("服务控制");
|
||||
ui.add_space(4.0);
|
||||
|
||||
let action_in_progress = self.servers[index].action_in_progress.clone();
|
||||
for service in DEFAULT_MANAGED_SERVICES {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(*service);
|
||||
for action in [
|
||||
ServiceAction::Start,
|
||||
ServiceAction::Stop,
|
||||
ServiceAction::Restart,
|
||||
] {
|
||||
let disabled = action_in_progress.is_some();
|
||||
if ui
|
||||
.add_enabled(!disabled, egui::Button::new(action.label()))
|
||||
.clicked()
|
||||
{
|
||||
self.pending_confirmation = Some(ServiceConfirmation {
|
||||
alias: alias.to_owned(),
|
||||
service: (*service).to_owned(),
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("其他 unit");
|
||||
ui.text_edit_singleline(&mut self.custom_service_name);
|
||||
if ui.button("启动").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Start);
|
||||
}
|
||||
if ui.button("关闭").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Stop);
|
||||
}
|
||||
if ui.button("重启").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Restart);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(action) = action_in_progress {
|
||||
ui.label(format!("正在执行:{action}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_confirm_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(confirmation) = self.pending_confirmation.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
egui::Window::new("确认服务操作")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!(
|
||||
"确认在 {} 上{} {}?",
|
||||
confirmation.alias,
|
||||
confirmation.action.label(),
|
||||
confirmation.service
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("确认").clicked() {
|
||||
self.execute_service_action(&confirmation);
|
||||
self.pending_confirmation = None;
|
||||
}
|
||||
if ui.button("取消").clicked() {
|
||||
self.pending_confirmation = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_aliases(&mut self) {
|
||||
let aliases = discover_ssh_aliases();
|
||||
self.servers = aliases.into_iter().map(ServerState::new).collect();
|
||||
self.selected_alias = self.servers.first().map(|server| server.alias.name.clone());
|
||||
}
|
||||
|
||||
fn refresh_server(&mut self, alias: &str) {
|
||||
if let Some(server) = self.server_mut(alias) {
|
||||
server.loading = true;
|
||||
server.error = None;
|
||||
}
|
||||
spawn_health_check(alias.to_owned(), self.tx.clone());
|
||||
}
|
||||
|
||||
fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) {
|
||||
let service = self.custom_service_name.trim();
|
||||
if service.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.pending_confirmation = Some(ServiceConfirmation {
|
||||
alias: alias.to_owned(),
|
||||
service: service.to_owned(),
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) {
|
||||
if let Some(server) = self.server_mut(&confirmation.alias) {
|
||||
server.action_in_progress = Some(format!(
|
||||
"{} {}",
|
||||
confirmation.action.label(),
|
||||
confirmation.service
|
||||
));
|
||||
server.action_log = None;
|
||||
}
|
||||
spawn_service_action(
|
||||
confirmation.alias.clone(),
|
||||
confirmation.service.clone(),
|
||||
confirmation.action,
|
||||
self.tx.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn server_index(&self, alias: &str) -> Option<usize> {
|
||||
self.servers
|
||||
.iter()
|
||||
.position(|server| server.alias.name == alias)
|
||||
}
|
||||
|
||||
fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> {
|
||||
self.servers
|
||||
.iter_mut()
|
||||
.find(|server| server.alias.name == alias)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServiceConfirmation {
|
||||
alias: String,
|
||||
service: String,
|
||||
action: ServiceAction,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ServerState {
|
||||
alias: SshAlias,
|
||||
report: Option<ServerHealthReport>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
action_in_progress: Option<String>,
|
||||
action_log: Option<String>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
fn new(alias: SshAlias) -> Self {
|
||||
Self {
|
||||
alias,
|
||||
report: None,
|
||||
loading: false,
|
||||
error: None,
|
||||
action_in_progress: None,
|
||||
action_log: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> HealthLevel {
|
||||
if self.error.is_some() {
|
||||
HealthLevel::Critical
|
||||
} else {
|
||||
self.report
|
||||
.as_ref()
|
||||
.map(|report| report.status)
|
||||
.unwrap_or(HealthLevel::Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_label(server: &ServerState) -> String {
|
||||
let prefix = match server.status() {
|
||||
HealthLevel::Ok => "[OK]",
|
||||
HealthLevel::Warning => "[!]",
|
||||
HealthLevel::Critical => "[X]",
|
||||
HealthLevel::Unknown => "[?]",
|
||||
};
|
||||
format!("{prefix} {}", server.alias.name)
|
||||
}
|
||||
|
||||
fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) {
|
||||
ui.label(&service.name);
|
||||
ui.colored_label(level_color(service.level), &service.active);
|
||||
ui.label(&service.sub);
|
||||
ui.label(&service.unit_file);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) {
|
||||
ui.label(&probe.name);
|
||||
ui.colored_label(level_color(probe.level), &probe.http_code);
|
||||
ui.label(
|
||||
probe
|
||||
.elapsed_ms
|
||||
.map(|elapsed| format!("{elapsed}ms"))
|
||||
.unwrap_or_else(|| "-".to_owned()),
|
||||
);
|
||||
ui.label(&probe.target);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) {
|
||||
let percent = memory.used_percent.unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||
ui.label(format!(
|
||||
"已用 {} / 总计 {},可用 {}",
|
||||
value_or_dash(&memory.used),
|
||||
value_or_dash(&memory.total),
|
||||
value_or_dash(&memory.available)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) {
|
||||
let percent = disk.used_percent.unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&disk.mount);
|
||||
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||
ui.label(format!(
|
||||
"{} 已用 {} / {},可用 {}",
|
||||
disk.filesystem, disk.used, disk.size, disk.available
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.small(label);
|
||||
ui.label(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn value_or_dash(value: &str) -> &str {
|
||||
if value.trim().is_empty() { "-" } else { value }
|
||||
}
|
||||
|
||||
fn level_color(level: HealthLevel) -> egui::Color32 {
|
||||
match level {
|
||||
HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91),
|
||||
HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16),
|
||||
HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70),
|
||||
HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136),
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_color() -> egui::Color32 {
|
||||
egui::Color32::from_rgb(205, 66, 70)
|
||||
}
|
||||
128
server-rs/crates/server-manager-panel/src/fonts.rs
Normal file
128
server-rs/crates/server-manager-panel/src/fonts.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use eframe::egui::{FontData, FontDefinitions, FontFamily};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CjkFontCandidate {
|
||||
pub path: PathBuf,
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
pub fn install_cjk_font(ctx: &eframe::egui::Context) -> Option<CjkFontCandidate> {
|
||||
let candidate = find_cjk_font_candidate()?;
|
||||
let bytes = std::fs::read(&candidate.path).ok()?;
|
||||
let mut font_data = FontData::from_owned(bytes);
|
||||
font_data.index = candidate.index;
|
||||
|
||||
let mut definitions = FontDefinitions::default();
|
||||
definitions
|
||||
.font_data
|
||||
.insert("genarrative-cjk".to_owned(), Arc::new(font_data));
|
||||
|
||||
// 中文注释:作为 fallback 注入,保留 egui 默认拉丁/图标字体,同时补齐中文 glyph。
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
definitions
|
||||
.families
|
||||
.entry(family)
|
||||
.or_default()
|
||||
.push("genarrative-cjk".to_owned());
|
||||
}
|
||||
|
||||
ctx.set_fonts(definitions);
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
pub fn find_cjk_font_candidate() -> Option<CjkFontCandidate> {
|
||||
if let Ok(path) = std::env::var("GENARRATIVE_SERVER_PANEL_CJK_FONT") {
|
||||
if let Some(candidate) = parse_font_spec(&path) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_PATHS: &[(&str, u32)] = &[
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 2),
|
||||
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0),
|
||||
("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),
|
||||
(
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"/home/dsk/.local/share/fonts/genarrative-cjk/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
for (path, index) in KNOWN_PATHS {
|
||||
if Path::new(path).is_file() {
|
||||
return Some(CjkFontCandidate {
|
||||
path: PathBuf::from(path),
|
||||
index: *index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for family in [
|
||||
"Noto Sans CJK SC",
|
||||
"WenQuanYi Zen Hei",
|
||||
"Droid Sans Fallback",
|
||||
] {
|
||||
if let Some(candidate) = find_with_fc_match(family) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_font_spec(raw: &str) -> Option<CjkFontCandidate> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (path, index) = trimmed
|
||||
.rsplit_once('|')
|
||||
.and_then(|(path, index)| Some((path, index.parse().ok()?)))
|
||||
.unwrap_or((trimmed, 0));
|
||||
let path = PathBuf::from(path);
|
||||
path.is_file().then_some(CjkFontCandidate { path, index })
|
||||
}
|
||||
|
||||
fn find_with_fc_match(family: &str) -> Option<CjkFontCandidate> {
|
||||
let output = Command::new("fc-match")
|
||||
.arg("-f")
|
||||
.arg("%{file}|%{index}\n")
|
||||
.arg(family)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.lines().find_map(parse_font_spec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_font_path_with_index() {
|
||||
let candidate = parse_font_spec("/tmp/missing-font.ttc|2");
|
||||
assert_eq!(candidate, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_existing_system_cjk_font() {
|
||||
let candidate = find_cjk_font_candidate();
|
||||
assert!(
|
||||
candidate
|
||||
.as_ref()
|
||||
.is_some_and(|candidate| candidate.path.is_file()),
|
||||
"expected at least one CJK font on this development host"
|
||||
);
|
||||
}
|
||||
}
|
||||
474
server-rs/crates/server-manager-panel/src/health.rs
Normal file
474
server-rs/crates/server-manager-panel/src/health.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum HealthLevel {
|
||||
Unknown,
|
||||
Ok,
|
||||
Warning,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl HealthLevel {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
HealthLevel::Unknown => "未知",
|
||||
HealthLevel::Ok => "正常",
|
||||
HealthLevel::Warning => "警告",
|
||||
HealthLevel::Critical => "异常",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rank(self) -> u8 {
|
||||
match self {
|
||||
HealthLevel::Unknown => 1,
|
||||
HealthLevel::Ok => 0,
|
||||
HealthLevel::Warning => 2,
|
||||
HealthLevel::Critical => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerHealthReport {
|
||||
pub status: HealthLevel,
|
||||
pub checked_at: String,
|
||||
pub host: HostSnapshot,
|
||||
pub hardware: HardwareSnapshot,
|
||||
pub services: Vec<ServiceSnapshot>,
|
||||
pub probes: Vec<ProbeSnapshot>,
|
||||
pub health_patrol: Option<HealthPatrolSnapshot>,
|
||||
pub raw_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostSnapshot {
|
||||
pub hostname: String,
|
||||
pub kernel: String,
|
||||
pub uptime: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HardwareSnapshot {
|
||||
pub cpu_model: String,
|
||||
pub cpu_cores: String,
|
||||
pub load_average: String,
|
||||
pub memory: MemorySnapshot,
|
||||
pub swap: MemorySnapshot,
|
||||
pub disks: Vec<DiskSnapshot>,
|
||||
pub sensors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemorySnapshot {
|
||||
pub total: String,
|
||||
pub used: String,
|
||||
pub free: String,
|
||||
pub available: String,
|
||||
pub used_percent: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSnapshot {
|
||||
pub mount: String,
|
||||
pub filesystem: String,
|
||||
pub size: String,
|
||||
pub used: String,
|
||||
pub available: String,
|
||||
pub used_percent: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceSnapshot {
|
||||
pub name: String,
|
||||
pub active: String,
|
||||
pub sub: String,
|
||||
pub unit_file: String,
|
||||
pub level: HealthLevel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProbeSnapshot {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub http_code: String,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub level: HealthLevel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealthPatrolSnapshot {
|
||||
pub status: String,
|
||||
pub checked_at: String,
|
||||
pub summary: String,
|
||||
pub level: HealthLevel,
|
||||
}
|
||||
|
||||
pub fn parse_health_report(raw_output: &str) -> ServerHealthReport {
|
||||
let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for line in raw_output.lines() {
|
||||
if let Some(name) = parse_section_marker(line) {
|
||||
current = name.to_owned();
|
||||
sections.entry(current.clone()).or_default();
|
||||
} else if !current.is_empty() {
|
||||
sections
|
||||
.entry(current.clone())
|
||||
.or_default()
|
||||
.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let mut report = ServerHealthReport {
|
||||
status: HealthLevel::Unknown,
|
||||
checked_at: section_value(§ions, "checked_at").unwrap_or_default(),
|
||||
host: parse_host(§ions),
|
||||
hardware: parse_hardware(§ions),
|
||||
services: parse_services(§ions),
|
||||
probes: parse_probes(§ions),
|
||||
health_patrol: parse_health_patrol(§ions),
|
||||
raw_output: raw_output.to_owned(),
|
||||
};
|
||||
report.status = summarize_report(&report);
|
||||
report
|
||||
}
|
||||
|
||||
pub fn summarize_report(report: &ServerHealthReport) -> HealthLevel {
|
||||
let mut status = HealthLevel::Ok;
|
||||
for level in report
|
||||
.services
|
||||
.iter()
|
||||
.map(|service| service.level)
|
||||
.chain(report.probes.iter().map(|probe| probe.level))
|
||||
.chain(report.health_patrol.iter().map(|patrol| patrol.level))
|
||||
{
|
||||
if level.rank() > status.rank() {
|
||||
status = level;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(used_percent) = report.hardware.memory.used_percent {
|
||||
let memory_level = if used_percent >= 95 {
|
||||
HealthLevel::Critical
|
||||
} else if used_percent >= 85 {
|
||||
HealthLevel::Warning
|
||||
} else {
|
||||
HealthLevel::Ok
|
||||
};
|
||||
if memory_level.rank() > status.rank() {
|
||||
status = memory_level;
|
||||
}
|
||||
}
|
||||
|
||||
for disk in &report.hardware.disks {
|
||||
let disk_level = match disk.used_percent {
|
||||
Some(percent) if percent >= 95 => HealthLevel::Critical,
|
||||
Some(percent) if percent >= 85 => HealthLevel::Warning,
|
||||
_ => HealthLevel::Ok,
|
||||
};
|
||||
if disk_level.rank() > status.rank() {
|
||||
status = disk_level;
|
||||
}
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
fn parse_section_marker(line: &str) -> Option<&str> {
|
||||
line.strip_prefix("==GENARRATIVE_PANEL:")
|
||||
.and_then(|rest| rest.strip_suffix("=="))
|
||||
}
|
||||
|
||||
fn section_value(sections: &BTreeMap<String, Vec<String>>, name: &str) -> Option<String> {
|
||||
sections.get(name).and_then(|lines| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.trim())
|
||||
.find(|line| !line.is_empty())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_host(sections: &BTreeMap<String, Vec<String>>) -> HostSnapshot {
|
||||
HostSnapshot {
|
||||
hostname: section_value(sections, "hostname").unwrap_or_default(),
|
||||
kernel: section_value(sections, "kernel").unwrap_or_default(),
|
||||
uptime: section_value(sections, "uptime").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hardware(sections: &BTreeMap<String, Vec<String>>) -> HardwareSnapshot {
|
||||
HardwareSnapshot {
|
||||
cpu_model: section_value(sections, "cpu_model").unwrap_or_default(),
|
||||
cpu_cores: section_value(sections, "cpu_cores").unwrap_or_default(),
|
||||
load_average: section_value(sections, "load_average").unwrap_or_default(),
|
||||
memory: parse_memory(section_value(sections, "memory").as_deref()),
|
||||
swap: parse_memory(section_value(sections, "swap").as_deref()),
|
||||
disks: parse_disks(sections),
|
||||
sensors: sections.get("sensors").cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(value: Option<&str>) -> MemorySnapshot {
|
||||
let Some(value) = value else {
|
||||
return MemorySnapshot::default();
|
||||
};
|
||||
let parts: Vec<&str> = value.split('|').collect();
|
||||
MemorySnapshot {
|
||||
total: parts.first().copied().unwrap_or_default().to_owned(),
|
||||
used: parts.get(1).copied().unwrap_or_default().to_owned(),
|
||||
free: parts.get(2).copied().unwrap_or_default().to_owned(),
|
||||
available: parts.get(3).copied().unwrap_or_default().to_owned(),
|
||||
used_percent: parts.get(4).and_then(|value| parse_percent(value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_disks(sections: &BTreeMap<String, Vec<String>>) -> Vec<DiskSnapshot> {
|
||||
sections
|
||||
.get("disks")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
(parts.len() >= 6).then(|| DiskSnapshot {
|
||||
filesystem: parts[0].to_owned(),
|
||||
size: parts[1].to_owned(),
|
||||
used: parts[2].to_owned(),
|
||||
available: parts[3].to_owned(),
|
||||
used_percent: parse_percent(parts[4]),
|
||||
mount: parts[5].to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_services(sections: &BTreeMap<String, Vec<String>>) -> Vec<ServiceSnapshot> {
|
||||
sections
|
||||
.get("services")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
(parts.len() >= 4).then(|| {
|
||||
let active = parts[1].to_owned();
|
||||
let sub = parts[2].to_owned();
|
||||
let level = if active == "active" {
|
||||
HealthLevel::Ok
|
||||
} else if active == "unknown" || active == "inactive" {
|
||||
HealthLevel::Warning
|
||||
} else {
|
||||
HealthLevel::Critical
|
||||
};
|
||||
ServiceSnapshot {
|
||||
name: parts[0].to_owned(),
|
||||
active,
|
||||
sub,
|
||||
unit_file: parts[3].to_owned(),
|
||||
level,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_probes(sections: &BTreeMap<String, Vec<String>>) -> Vec<ProbeSnapshot> {
|
||||
sections
|
||||
.get("probes")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
(parts.len() >= 4).then(|| {
|
||||
let http_code = parts[2].to_owned();
|
||||
let elapsed_ms = parts[3].parse().ok();
|
||||
let level = if http_code.starts_with('2') {
|
||||
HealthLevel::Ok
|
||||
} else if http_code == "000" {
|
||||
HealthLevel::Critical
|
||||
} else {
|
||||
HealthLevel::Critical
|
||||
};
|
||||
ProbeSnapshot {
|
||||
name: parts[0].to_owned(),
|
||||
target: parts[1].to_owned(),
|
||||
http_code,
|
||||
elapsed_ms,
|
||||
level,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_health_patrol(sections: &BTreeMap<String, Vec<String>>) -> Option<HealthPatrolSnapshot> {
|
||||
let line = section_value(sections, "health_patrol")?;
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
let status = parts.first().copied().unwrap_or_default().to_owned();
|
||||
let level = match status.as_str() {
|
||||
"OK" => HealthLevel::Ok,
|
||||
"WARNING" => HealthLevel::Warning,
|
||||
"CRITICAL" => HealthLevel::Critical,
|
||||
_ => HealthLevel::Unknown,
|
||||
};
|
||||
Some(HealthPatrolSnapshot {
|
||||
status,
|
||||
checked_at: parts.get(1).copied().unwrap_or_default().to_owned(),
|
||||
summary: parts.get(2).copied().unwrap_or_default().to_owned(),
|
||||
level,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_percent(value: &str) -> Option<u8> {
|
||||
value.trim_end_matches('%').parse().ok()
|
||||
}
|
||||
|
||||
pub const HEALTH_SCRIPT: &str = r#"set -eu
|
||||
|
||||
print_section() {
|
||||
printf '==GENARRATIVE_PANEL:%s==\n' "$1"
|
||||
}
|
||||
|
||||
print_section checked_at
|
||||
date -Is 2>/dev/null || date
|
||||
|
||||
print_section hostname
|
||||
hostname 2>/dev/null || true
|
||||
|
||||
print_section kernel
|
||||
uname -srmo 2>/dev/null || uname -a 2>/dev/null || true
|
||||
|
||||
print_section uptime
|
||||
uptime -p 2>/dev/null || uptime 2>/dev/null || true
|
||||
|
||||
print_section cpu_model
|
||||
awk -F: '/model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true
|
||||
|
||||
print_section cpu_cores
|
||||
nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || true
|
||||
|
||||
print_section load_average
|
||||
cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}' || true
|
||||
|
||||
print_section memory
|
||||
awk '
|
||||
/^MemTotal:/ {total=$2}
|
||||
/^MemFree:/ {free=$2}
|
||||
/^MemAvailable:/ {available=$2}
|
||||
END {
|
||||
if (total > 0) {
|
||||
used = total - free
|
||||
percent = int((used * 100 + total / 2) / total)
|
||||
printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, available/1048576, percent
|
||||
}
|
||||
}
|
||||
' /proc/meminfo 2>/dev/null || true
|
||||
|
||||
print_section swap
|
||||
awk '
|
||||
/^SwapTotal:/ {total=$2}
|
||||
/^SwapFree:/ {free=$2}
|
||||
END {
|
||||
if (total > 0) {
|
||||
used = total - free
|
||||
percent = int((used * 100 + total / 2) / total)
|
||||
printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, free/1048576, percent
|
||||
} else {
|
||||
print "0 GiB|0 GiB|0 GiB|0 GiB|0%"
|
||||
}
|
||||
}
|
||||
' /proc/meminfo 2>/dev/null || true
|
||||
|
||||
print_section disks
|
||||
for mount in / /var /opt /stdb /data; do
|
||||
if [ -e "$mount" ]; then
|
||||
df -hP "$mount" 2>/dev/null | awk 'NR == 2 {print $1"|"$2"|"$3"|"$4"|"$5"|"$6}'
|
||||
fi
|
||||
done | awk '!seen[$6]++'
|
||||
|
||||
print_section sensors
|
||||
if command -v sensors >/dev/null 2>&1; then
|
||||
sensors 2>/dev/null | sed -n '1,20p'
|
||||
else
|
||||
echo "sensors 未安装"
|
||||
fi
|
||||
|
||||
print_section services
|
||||
for service in genarrative-api.service spacetimedb.service nginx.service genarrative-health-patrol.timer genarrative-database-backup.timer; do
|
||||
active=$(systemctl is-active "$service" 2>/dev/null || true)
|
||||
sub=$(systemctl show "$service" -p SubState --value 2>/dev/null || true)
|
||||
unit_file=$(systemctl show "$service" -p UnitFileState --value 2>/dev/null || true)
|
||||
[ -n "$active" ] || active="unknown"
|
||||
[ -n "$sub" ] || sub="unknown"
|
||||
[ -n "$unit_file" ] || unit_file="unknown"
|
||||
printf '%s|%s|%s|%s\n' "$service" "$active" "$sub" "$unit_file"
|
||||
done
|
||||
|
||||
print_section probes
|
||||
probe() {
|
||||
name="$1"
|
||||
url="$2"
|
||||
tmp=$(mktemp)
|
||||
code=$(curl -fsS -m 5 -o /dev/null -w '%{http_code}|%{time_total}' "$url" 2>"$tmp" || true)
|
||||
if [ -z "$code" ]; then
|
||||
code="000|0"
|
||||
fi
|
||||
http_code=${code%%|*}
|
||||
time_total=${code#*|}
|
||||
elapsed_ms=$(awk "BEGIN {printf \"%d\", $time_total * 1000}")
|
||||
printf '%s|%s|%s|%s\n' "$name" "$url" "$http_code" "$elapsed_ms"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
probe "api:/healthz" "http://127.0.0.1:8082/healthz"
|
||||
probe "api:/readyz" "http://127.0.0.1:8082/readyz"
|
||||
probe "spacetimedb:/v1/ping" "http://127.0.0.1:3101/v1/ping"
|
||||
probe "public:/api/creation-entry/config" "http://127.0.0.1:8082/api/creation-entry/config"
|
||||
probe "public:/api/runtime/puzzle/gallery" "http://127.0.0.1:8082/api/runtime/puzzle/gallery"
|
||||
|
||||
print_section health_patrol
|
||||
if [ -r /var/lib/genarrative/health-patrol/status.json ]; then
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const payload = JSON.parse(fs.readFileSync("/var/lib/genarrative/health-patrol/status.json", "utf8"));
|
||||
const status = payload.status || "UNKNOWN";
|
||||
const checkedAt = payload.checkedAt || "";
|
||||
const checks = Array.isArray(payload.checks) ? payload.checks : [];
|
||||
const summary = checks.filter((check) => check.status && check.status !== "OK").slice(0, 3).map((check) => `${check.name}:${check.status}`).join(",");
|
||||
console.log(`${status}|${checkedAt}|${summary}`);
|
||||
' 2>/dev/null || echo "UNKNOWN||状态文件解析失败"
|
||||
else
|
||||
echo "UNKNOWN||未找到 /var/lib/genarrative/health-patrol/status.json"
|
||||
fi
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_report_sections() {
|
||||
let report = parse_health_report(
|
||||
r#"==GENARRATIVE_PANEL:checked_at==
|
||||
2026-06-11T12:00:00+08:00
|
||||
==GENARRATIVE_PANEL:hostname==
|
||||
release
|
||||
==GENARRATIVE_PANEL:memory==
|
||||
2.0 GiB|1.0 GiB|1.0 GiB|1.0 GiB|50%
|
||||
==GENARRATIVE_PANEL:disks==
|
||||
/dev/sda1|40G|20G|20G|50%|/
|
||||
==GENARRATIVE_PANEL:services==
|
||||
genarrative-api.service|active|running|enabled
|
||||
spacetimedb.service|failed|failed|enabled
|
||||
==GENARRATIVE_PANEL:probes==
|
||||
api:/readyz|http://127.0.0.1:8082/readyz|200|18
|
||||
==GENARRATIVE_PANEL:health_patrol==
|
||||
WARNING|2026-06-11T11:59:00Z|journal:WARNING
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(report.host.hostname, "release");
|
||||
assert_eq!(report.hardware.memory.used_percent, Some(50));
|
||||
assert_eq!(report.services.len(), 2);
|
||||
assert_eq!(report.probes[0].http_code, "200");
|
||||
assert_eq!(report.status, HealthLevel::Critical);
|
||||
}
|
||||
}
|
||||
5
server-rs/crates/server-manager-panel/src/lib.rs
Normal file
5
server-rs/crates/server-manager-panel/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod fonts;
|
||||
pub mod health;
|
||||
pub mod remote;
|
||||
pub mod ssh_config;
|
||||
21
server-rs/crates/server-manager-panel/src/main.rs
Normal file
21
server-rs/crates/server-manager-panel/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use eframe::egui;
|
||||
use server_manager_panel::app::ServerManagerApp;
|
||||
use server_manager_panel::fonts::install_cjk_font;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1180.0, 760.0])
|
||||
.with_min_inner_size([920.0, 620.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Genarrative 服务器管理面板",
|
||||
native_options,
|
||||
Box::new(|cc| {
|
||||
install_cjk_font(&cc.egui_ctx);
|
||||
Ok(Box::new(ServerManagerApp::default()))
|
||||
}),
|
||||
)
|
||||
}
|
||||
231
server-rs/crates/server-manager-panel/src/remote.rs
Normal file
231
server-rs/crates/server-manager-panel/src/remote.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::health::{HEALTH_SCRIPT, ServerHealthReport, parse_health_report};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl ServiceAction {
|
||||
pub fn as_systemctl_arg(self) -> &'static str {
|
||||
match self {
|
||||
ServiceAction::Start => "start",
|
||||
ServiceAction::Stop => "stop",
|
||||
ServiceAction::Restart => "restart",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ServiceAction::Start => "启动",
|
||||
ServiceAction::Stop => "关闭",
|
||||
ServiceAction::Restart => "重启",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteCommandResult {
|
||||
pub success: bool,
|
||||
pub summary: String,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteEvent {
|
||||
Health {
|
||||
alias: String,
|
||||
result: Result<ServerHealthReport, String>,
|
||||
},
|
||||
ServiceAction {
|
||||
alias: String,
|
||||
service: String,
|
||||
action: ServiceAction,
|
||||
result: RemoteCommandResult,
|
||||
},
|
||||
}
|
||||
|
||||
pub type RemoteSender = mpsc::Sender<RemoteEvent>;
|
||||
pub type RemoteReceiver = mpsc::Receiver<RemoteEvent>;
|
||||
|
||||
pub fn channel() -> (RemoteSender, RemoteReceiver) {
|
||||
mpsc::channel()
|
||||
}
|
||||
|
||||
pub fn spawn_health_check(alias: String, tx: RemoteSender) {
|
||||
thread::spawn(move || {
|
||||
let result =
|
||||
run_ssh_script(&alias, HEALTH_SCRIPT, Duration::from_secs(20)).and_then(|output| {
|
||||
if output.success {
|
||||
Ok(parse_health_report(&output.stdout))
|
||||
} else {
|
||||
Err(format_remote_error(&output))
|
||||
}
|
||||
});
|
||||
let _ = tx.send(RemoteEvent::Health { alias, result });
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_service_action(
|
||||
alias: String,
|
||||
service: String,
|
||||
action: ServiceAction,
|
||||
tx: RemoteSender,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let result = if is_safe_service_name(&service) {
|
||||
run_ssh_script(
|
||||
&alias,
|
||||
&build_service_action_script(&service, action),
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.unwrap_or_else(|error| RemoteCommandResult {
|
||||
success: false,
|
||||
summary: error,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
})
|
||||
} else {
|
||||
RemoteCommandResult {
|
||||
success: false,
|
||||
summary: "服务名包含不允许的字符".to_owned(),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
}
|
||||
};
|
||||
let _ = tx.send(RemoteEvent::ServiceAction {
|
||||
alias,
|
||||
service,
|
||||
action,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_safe_service_name(service: &str) -> bool {
|
||||
!service.is_empty()
|
||||
&& service.len() <= 128
|
||||
&& service.bytes().all(|byte| {
|
||||
matches!(
|
||||
byte,
|
||||
b'a'..=b'z'
|
||||
| b'A'..=b'Z'
|
||||
| b'0'..=b'9'
|
||||
| b'.'
|
||||
| b'_'
|
||||
| b'-'
|
||||
| b'@'
|
||||
| b':'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_service_action_script(service: &str, action: ServiceAction) -> String {
|
||||
format!(
|
||||
r#"set -eu
|
||||
service='{service}'
|
||||
action='{action}'
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
systemctl "$action" "$service"
|
||||
else
|
||||
sudo -n systemctl "$action" "$service"
|
||||
fi
|
||||
systemctl is-active "$service" || true
|
||||
systemctl status "$service" --no-pager -l -n 12 || true
|
||||
"#,
|
||||
service = service,
|
||||
action = action.as_systemctl_arg()
|
||||
)
|
||||
}
|
||||
|
||||
fn run_ssh_script(
|
||||
alias: &str,
|
||||
script: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<RemoteCommandResult, String> {
|
||||
let started = Instant::now();
|
||||
let mut child = Command::new("ssh")
|
||||
.arg("-o")
|
||||
.arg("BatchMode=yes")
|
||||
.arg("-o")
|
||||
.arg("ConnectTimeout=8")
|
||||
.arg(alias)
|
||||
.arg("sh")
|
||||
.arg("-s")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| format!("无法启动 ssh: {error}"))?;
|
||||
|
||||
{
|
||||
// 中文注释:写完脚本后必须关闭 stdin,让远端 `sh -s` 收到 EOF 并开始退出。
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
return Err("无法写入 ssh stdin".to_owned());
|
||||
};
|
||||
stdin
|
||||
.write_all(script.as_bytes())
|
||||
.map_err(|error| format!("写入远端脚本失败: {error}"))?;
|
||||
}
|
||||
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => {
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|error| format!("读取 ssh 输出失败: {error}"))?;
|
||||
let success = output.status.success();
|
||||
return Ok(RemoteCommandResult {
|
||||
success,
|
||||
summary: if success {
|
||||
"执行成功".to_owned()
|
||||
} else {
|
||||
format!("ssh 退出码 {:?}", output.status.code())
|
||||
},
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(None) if started.elapsed() >= timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("ssh 执行超过 {} 秒", timeout.as_secs()));
|
||||
}
|
||||
Ok(None) => thread::sleep(Duration::from_millis(80)),
|
||||
Err(error) => return Err(format!("等待 ssh 进程失败: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_remote_error(result: &RemoteCommandResult) -> String {
|
||||
let stderr = result.stderr.trim();
|
||||
let stdout = result.stdout.trim();
|
||||
if !stderr.is_empty() {
|
||||
format!("{}: {}", result.summary, stderr)
|
||||
} else if !stdout.is_empty() {
|
||||
format!("{}: {}", result.summary, stdout)
|
||||
} else {
|
||||
result.summary.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allows_systemd_unit_names_only() {
|
||||
assert!(is_safe_service_name("genarrative-api.service"));
|
||||
assert!(is_safe_service_name("worker@1.service"));
|
||||
assert!(!is_safe_service_name("api.service;rm -rf /"));
|
||||
assert!(!is_safe_service_name(""));
|
||||
}
|
||||
}
|
||||
143
server-rs/crates/server-manager-panel/src/ssh_config.rs
Normal file
143
server-rs/crates/server-manager-panel/src/ssh_config.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SshAlias {
|
||||
pub name: String,
|
||||
pub source: PathBuf,
|
||||
}
|
||||
|
||||
pub fn discover_ssh_aliases() -> Vec<SshAlias> {
|
||||
let Some(home) = std::env::var_os("HOME") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let config_path = PathBuf::from(home).join(".ssh/config");
|
||||
discover_from_file(&config_path)
|
||||
}
|
||||
|
||||
pub fn discover_from_file(path: &Path) -> Vec<SshAlias> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut aliases = Vec::new();
|
||||
discover_inner(path, &mut visited, &mut aliases);
|
||||
dedupe_aliases(aliases)
|
||||
}
|
||||
|
||||
fn discover_inner(path: &Path, visited: &mut HashSet<PathBuf>, aliases: &mut Vec<SshAlias>) {
|
||||
let Ok(canonical) = path.canonicalize() else {
|
||||
return;
|
||||
};
|
||||
if !visited.insert(canonical.clone()) {
|
||||
return;
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(&canonical) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = trim_comment(line);
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let Some(keyword) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
if keyword.eq_ignore_ascii_case("host") {
|
||||
aliases.extend(parts.filter_map(|name| {
|
||||
is_concrete_alias(name).then(|| SshAlias {
|
||||
name: name.to_owned(),
|
||||
source: canonical.clone(),
|
||||
})
|
||||
}));
|
||||
} else if keyword.eq_ignore_ascii_case("include") {
|
||||
for include in parts {
|
||||
for include_path in expand_include_path(include, canonical.parent()) {
|
||||
discover_inner(&include_path, visited, aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_aliases(aliases: Vec<SshAlias>) -> Vec<SshAlias> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut deduped = Vec::new();
|
||||
for alias in aliases {
|
||||
if seen.insert(alias.name.clone()) {
|
||||
deduped.push(alias);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn trim_comment(line: &str) -> &str {
|
||||
line.split('#').next().unwrap_or("").trim()
|
||||
}
|
||||
|
||||
fn is_concrete_alias(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.starts_with('-')
|
||||
&& !value.starts_with('!')
|
||||
&& !value.contains('*')
|
||||
&& !value.contains('?')
|
||||
&& !value.contains('%')
|
||||
&& !value.contains('/')
|
||||
}
|
||||
|
||||
fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec<PathBuf> {
|
||||
if raw.contains('*') || raw.contains('?') {
|
||||
// 中文注释:SSH Include 支持复杂 glob;面板只解析普通文件,避免误扫过大目录。
|
||||
return Vec::new();
|
||||
}
|
||||
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join(rest))
|
||||
} else {
|
||||
let path = PathBuf::from(raw);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
parent.map(|base| base.join(path))
|
||||
}
|
||||
};
|
||||
expanded.into_iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_parser_ignores_wildcards_and_negations() {
|
||||
let mut aliases = Vec::new();
|
||||
let source = PathBuf::from("/tmp/config");
|
||||
for line in [
|
||||
"Host dev release *.internal !blocked",
|
||||
"Host github.com",
|
||||
"Host ?pattern",
|
||||
"Host -bad",
|
||||
] {
|
||||
let trimmed = trim_comment(line);
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let keyword = parts.next().unwrap();
|
||||
if keyword.eq_ignore_ascii_case("host") {
|
||||
aliases.extend(parts.filter_map(|name| {
|
||||
is_concrete_alias(name).then(|| SshAlias {
|
||||
name: name.to_owned(),
|
||||
source: source.clone(),
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let names: Vec<_> = dedupe_aliases(aliases)
|
||||
.into_iter()
|
||||
.map(|alias| alias.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["dev", "release", "github.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_trimming_keeps_plain_aliases() {
|
||||
assert_eq!(trim_comment(" Host dev # release host "), "Host dev");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user