Merge remote-tracking branch 'origin/master'

This commit is contained in:
kdletters
2026-06-11 22:51:26 +08:00
15 changed files with 3455 additions and 19 deletions

View File

@@ -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 公开作品互动能力进入后台全局配置 ## 2026-06-10 公开作品互动能力进入后台全局配置
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 - 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。

View File

@@ -97,6 +97,15 @@ npm run dev:web
npm run dev:admin-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: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`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。

View File

@@ -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)。 微信小程序虚拟支付接入、`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)。 生产部署切换到 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)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。

View 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` 输出和现有部署文档为准。

View File

@@ -9,6 +9,7 @@
"dev:api-server": "node scripts/dev.mjs api-server", "dev:api-server": "node scripts/dev.mjs api-server",
"dev:web": "node scripts/dev.mjs web", "dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-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", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"otel:debug": "node scripts/run-otelcol.mjs debug", "otel:debug": "node scripts/run-otelcol.mjs debug",
"otel:rider": "node scripts/run-otelcol.mjs rider", "otel:rider": "node scripts/run-otelcol.mjs rider",

1779
server-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ members = [
"crates/platform-wechat", "crates/platform-wechat",
"crates/platform-speech", "crates/platform-speech",
"crates/platform-agent", "crates/platform-agent",
"crates/server-manager-panel",
"crates/shared-contracts", "crates/shared-contracts",
"crates/shared-kernel", "crates/shared-kernel",
"crates/shared-logging", "crates/shared-logging",

View 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",
] }

View 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)
}

View 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"
);
}
}

View 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(&sections, "checked_at").unwrap_or_default(),
host: parse_host(&sections),
hardware: parse_hardware(&sections),
services: parse_services(&sections),
probes: parse_probes(&sections),
health_patrol: parse_health_patrol(&sections),
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);
}
}

View File

@@ -0,0 +1,5 @@
pub mod app;
pub mod fonts;
pub mod health;
pub mod remote;
pub mod ssh_config;

View 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()))
}),
)
}

View 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(""));
}
}

View 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");
}
}