工业网关和边缘控制器经常部署在没有桌面环境的现场:SSH 连上设备、进到容器或宿主机,第一件事往往是看设备在线状态、协议采集质量、北向连接、资源水位和最近错误。Web 控制台适合日常远程管理,但在网络受限、带宽很低、服务异常或只能进入运维通道时,一个稳定的终端界面反而更可靠。
Rust 生态里,ratatui 是当前主流的 TUI 框架之一,配合 crossterm 可以在 Linux 终端、SSH 会话和容器 TTY 中构建表格、图表、日志、菜单和交互式状态页。本文以工业网关本地运维界面为例,梳理 ratatui 0.28 的基础骨架、布局、表格、图表、事件循环、状态管理、后台数据解耦和上线检查项。
版本说明:示例基于
ratatui 0.28与crossterm 0.28。ratatui 的 API 在近几个版本变化较多,升级时应结合对应版本的文档和变更记录调整代码。
一、工业边缘为什么需要 TUI
先明确定位:TUI 不是替代 Web 控制台,而是提供"最后一公里"的本地诊断界面。
| 场景 | Web 控制台的问题 | TUI 的价值 |
|---|---|---|
| 远程站点带宽受限 | 前端资源和接口响应慢 | 只传输终端文本与控制序列 |
| 北向服务异常 | 页面依赖的后端不可用 | 直接读取本地进程或状态文件 |
| SSH 应急运维 | 需要记忆大量命令 | 提供结构化菜单和状态表格 |
| 容器 / 主机排查 | 进入容器后缺少图形环境 | TTY 即可运行 |
| 现场干扰排查 | 打开浏览器和多个标签效率低 | 一屏展示关键指标 |
TUI 的边界也很清楚:它不适合承载复杂表单、富媒体、权限管理和完整工作流。工程上更务实的做法是把 TUI 做成只读诊断面、轻量控制面和应急操作入口,复杂操作仍交给服务端 API 或管理平台。
二、依赖与整体结构
创建项目并添加依赖:
bash
cargo new edge-tui
cd edge-tui
cargo add ratatui@0.28
cargo add crossterm@0.28
Cargo.toml 关键依赖如下:
toml
[dependencies]
ratatui = "0.28"
crossterm = "0.28"
一个可维护的 ratatui 程序建议分成四层:
text
Device source / business state
|
| snapshot / event
v
App state -------------------+
| |
| state transition | render model
v v
Event loop -----------> Terminal draw
- 数据层:设备发现、协议采集、连接状态、历史采样。
- 状态层:当前页面、选中行、过滤条件、图表窗口。
- 事件层:键盘、终端 Resize、定时 Tick、后台快照。
- 渲染层:只根据当前状态绘制,不在绘制函数里做 IO。
最容易失控的写法是在 terminal.draw 闭包里读设备、发请求、写日志。渲染函数应保持纯展示逻辑,否则界面抖动、阻塞和副作用会很难排查。
三、最小可运行骨架
下面是一个完整的骨架,重点不是界面多复杂,而是把终端生命周期做对:
rust
use std::{
io::{self, stdout},
panic,
time::Duration,
};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
widgets::Paragraph,
Terminal,
};
fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
Terminal::new(CrosstermBackend::new(stdout()))
}
fn restore_terminal() -> io::Result<()> {
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = restore_terminal();
original_hook(info);
}));
let mut terminal = init_terminal()?;
let result = run(&mut terminal);
restore_terminal()?;
result
}
fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
terminal.draw(|frame| {
frame.render_widget(
Paragraph::new("Edge Gateway TUI\nPress q to quit"),
frame.area(),
);
})?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
break;
}
}
}
Ok(())
}
这段骨架包含四个工程细节:
enable_raw_mode()让程序可以接收按键而不等待回车。EnterAlternateScreen避免污染用户原始终端历史。- panic hook 先恢复终端,再交给原始 panic 处理,防止异常后终端停留在 raw mode。
event::poll(timeout)给事件循环留出空闲窗口,避免无限阻塞。
KeyEventKind::Press 不能省略。部分终端和平台会同时上报 Press、Repeat 或 Release;如果不区分事件类型,一次按键可能被处理多次。
四、布局:先把屏幕分区
工业运维界面通常是"头部状态 + 中部内容 + 底部操作提示"。ratatui 0.28 推荐使用 Layout::vertical / Layout::horizontal 直接声明约束:
rust
use ratatui::layout::{Constraint, Layout};
fn main_layout(area: ratatui::layout::Rect) -> std::rc::Rc<[ratatui::layout::Rect]> {
Layout::vertical([
Constraint::Length(3),
Constraint::Min(6),
Constraint::Length(3),
])
.split(area)
}
使用方式:
rust
terminal.draw(|frame| {
let chunks = main_layout(frame.area());
frame.render_widget(header_widget(), chunks[0]);
frame.render_widget(content_widget(), chunks[1]);
frame.render_widget(footer_widget(), chunks[2]);
})?;
Length 适合固定高度的标题栏、表头和快捷键提示;Min 保证内容区获得剩余空间;Percentage 适合稳定的宽屏视图,但在窄终端下更容易挤压,应配合 Min 或换页策略使用。
对于设备详情页,可以继续拆分:
rust
let body = Layout::vertical([
Constraint::Percentage(55),
Constraint::Percentage(45),
])
.split(chunks[1]);
let table_area = body[0];
let chart_area = body[1];
不要假设终端永远足够宽。至少验证 80x24、120x40 和 SSH 窗口拖拽后的极端尺寸。内容放不下时应截断、滚动或切换页面,而不是让表格和图表互相覆盖。
五、设备表格与状态展示
先定义业务数据:
rust
#[derive(Clone, Debug)]
struct Device {
id: String,
name: String,
voltage: f64,
online: bool,
last_error: Option<String>,
}
再定义 App 状态。设备列表建议使用 TableState 管理选中行,而不是在渲染时临时记录索引:
rust
use ratatui::widgets::TableState;
#[derive(Default)]
struct App {
devices: Vec<Device>,
table_state: TableState,
}
impl App {
fn select_next(&mut self) {
if self.devices.is_empty() {
self.table_state.select(None);
return;
}
let next = match self.table_state.selected() {
Some(index) => (index + 1) % self.devices.len(),
None => 0,
};
self.table_state.select(Some(next));
}
fn select_previous(&mut self) {
if self.devices.is_empty() {
self.table_state.select(None);
return;
}
let previous = match self.table_state.selected() {
Some(index) => {
if index == 0 {
self.devices.len() - 1
} else {
index - 1
}
}
None => 0,
};
self.table_state.select(Some(previous));
}
}
空列表处理是必要代码。现场设备发现失败时列表可能为空,如果直接 len - 1,无符号整数下溢会导致 panic。
渲染设备表:
rust
use ratatui::{
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Row, Table},
};
fn render_devices(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) {
let rows = app.devices.iter().map(|device| {
let status = if device.online { "online" } else { "offline" };
let status_style = if device.online {
Style::new().fg(Color::Green)
} else {
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD)
};
Row::new(vec![
Cell::from(device.id.clone()),
Cell::from(device.name.clone()),
Cell::from(format!("{:.1}", device.voltage)),
Cell::from(status).style(status_style),
])
});
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Length(12),
ratatui::layout::Constraint::Min(16),
ratatui::layout::Constraint::Length(10),
ratatui::layout::Constraint::Length(10),
],
)
.header(
Row::new(vec!["ID", "Name", "Voltage", "Status"])
.style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
)
.block(Block::new().borders(Borders::ALL).title("Devices"));
frame.render_stateful_widget(table, area, &mut app.table_state);
}
状态展示不要只依赖颜色。终端可能被裁剪、配色被重映射,或用户存在色觉差异;online / offline 文本必须保留,颜色只是增强。
六、实时曲线与数据窗口
图表适合展示电压、频率、CPU、队列长度等短窗口趋势。历史数据要设置上限,否则长期运行会把内存耗尽:
rust
use std::collections::VecDeque;
const MAX_HISTORY: usize = 120;
#[derive(Default)]
struct History {
points: VecDeque<(f64, f64)>,
tick: usize,
}
impl History {
fn push(&mut self, value: f64) {
self.tick += 1;
self.points.push_back((self.tick as f64, value));
while self.points.len() > MAX_HISTORY {
self.points.pop_front();
}
}
}
渲染曲线:
rust
use ratatui::{
symbols,
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType},
};
fn render_history(
frame: &mut ratatui::Frame,
history: &History,
area: ratatui::layout::Rect,
) {
let datasets = vec![
Dataset::default()
.name("voltage")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(ratatui::style::Style::new().fg(Color::Cyan))
.data(&history.points),
];
let first_x = history.points.front().map(|point| point.0).unwrap_or(0.0);
let last_x = history.tick as f64;
let chart = Chart::new(datasets)
.block(Block::new().borders(Borders::ALL).title("Voltage"))
.x_axis(
Axis::default()
.title("tick")
.bounds([first_x, last_x.max(first_x + 1.0)]),
)
.y_axis(Axis::default().title("V").bounds([180.0, 250.0]));
frame.render_widget(chart, area);
}
注意 Y 轴范围不要机械照抄示例。设备量程可能是 0 到 5 V、220 V 到 240 V,也可能是温度或百分比。固定错误量程会让曲线贴边或看起来是一条直线。可以让 Y 轴按业务安全范围固定,或使用带平滑机制的自动范围,避免每次采样导致坐标剧烈跳动。
七、事件循环:把按键、Tick 和数据更新分开
一个基础但可靠的事件循环可以按 100ms 轮询终端事件,同时非阻塞地接收后台快照:
rust
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
enum BackendEvent {
DeviceSnapshot(Vec<Device>),
}
fn spawn_device_source() -> mpsc::Receiver<BackendEvent> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
loop {
let devices = collect_devices_from_runtime();
if tx.send(BackendEvent::DeviceSnapshot(devices)).is_err() {
break;
}
thread::sleep(Duration::from_secs(1));
}
});
rx
}
fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
backend_rx: &mpsc::Receiver<BackendEvent>,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
terminal.draw(|frame| {
let chunks = main_layout(frame.area());
render_header(frame, chunks[0]);
render_devices(frame, app, chunks[1]);
render_footer(frame, chunks[2]);
})?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
_ => {}
},
_ => {}
}
}
while let Ok(event) = backend_rx.try_recv() {
match event {
BackendEvent::DeviceSnapshot(devices) => {
app.devices = devices;
}
}
}
}
Ok(())
}
collect_devices_from_runtime() 在真实项目中可以替换为:
- 读取本地 runtime 状态文件;
- 调用 Unix socket 或 localhost API;
- 查询进程内共享状态;
- 从采集模块订阅快照。
更严谨的生产实现应使用有界 channel,并在 UI 侧只保留最新快照。设备采集频率高、UI 短暂卡顿时,不能让历史快照无限排队。
八、状态管理:让状态迁移可测试
不要把业务逻辑写在按键分支和渲染闭包里。按键只触发状态迁移,迁移函数保持可单测:
rust
enum Page {
Devices,
Connections,
Logs,
}
struct RuntimeState {
page: Page,
devices: Vec<Device>,
table_state: TableState,
log_offset: usize,
}
impl RuntimeState {
fn move_down(&mut self) {
if self.devices.is_empty() {
self.table_state.select(None);
return;
}
let next = match self.table_state.selected() {
Some(index) => (index + 1) % self.devices.len(),
None => 0,
};
self.table_state.select(Some(next));
}
fn refresh_snapshot(&mut self, devices: Vec<Device>) {
let selected_id = self
.table_state
.selected()
.and_then(|index| self.devices.get(index))
.map(|device| device.id.clone());
self.devices = devices;
let next_index = if self.devices.is_empty() {
self.table_state.select(None);
None
} else {
selected_id
.and_then(|id| self.devices.iter().position(|device| device.id == id))
.or(Some(self.devices.len() - 1))
};
if let Some(index) = next_index {
self.table_state.select(Some(index));
}
}
}
至少覆盖这些单元测试:
- 空设备列表时上下移动不 panic;
- 列表从 3 台变为 0 台后,选中状态清空;
- 列表缩短后,原选中索引越界时能回落到有效行;
- 快照刷新不会无条件丢失用户当前选中项;
- 历史采样超过上限后会淘汰最旧点。
ratatui 还提供 TestBackend,可以把组件渲染到内存缓冲后断言文本、颜色和布局位置,适合覆盖关键界面的回归场景。
九、边缘现场的工程实践
1. 界面频率与数据频率解耦
设备采样可能是 100ms 一次,但界面 1 秒刷新一次已经足够。高频重绘消耗 CPU,也会让 SSH 会话传输大量 diff。按信息变化设置刷新频率:状态页 1s,日志页 250ms 到 500ms,异常高亮可以即时更新。
2. 只读优先,控制动作二次确认
本地 TUI 可以提供重启采集、重连北向、导出诊断包等动作,但执行前必须展示影响范围并要求确认。危险操作应记录审计日志,并保留服务端权限校验,不能因为"本地工具"就绕过授权。
3. 处理窄终端与 Resize
终端窗口变化会触发 Event::Resize。布局每次根据 frame.area() 重算是基础要求;同时要处理列宽不足、日志长行、Unicode 中英文宽度混排和图表最小绘制面积。
4. 后台数据失败时展示原因
不要只显示"无数据"。界面应区分:
text
runtime unreachable
device discovery failed
protocol timeout
permission denied
no devices configured
这五种情况对应的处置动作完全不同。错误原因可以放在 header 或详情页,而不是只留下空白区域。
5. 保留诊断出口
TUI 中应提供查看最近错误、打开诊断日志路径、导出状态快照的入口。界面本身不能变成新的黑盒。
十、常见坑与应对
| 常见坑 | 典型现象 | 应对 |
|---|---|---|
| 忘记恢复 raw mode | 程序退出后终端按键异常、命令不可见 | cleanup + panic hook + 集中错误返回路径 |
| 不区分按键事件类型 | 一次按键触发多次动作 | 判断 KeyEventKind::Press |
| 事件循环阻塞 | 界面不刷新、Ctrl+C 响应异常 | event::poll 或异步事件循环 |
| 渲染函数做 IO | 界面卡顿、状态难以测试 | 渲染纯展示,数据进事件循环 |
| 表格状态越界 | 列表刷新后 panic 或选中错误 | 快照刷新时校验选中索引 |
| 图表历史无限增长 | 长期运行内存上涨 | VecDeque 固定窗口 |
| 只用颜色表达状态 | 色觉差异或终端配色下不可辨 | 文本 + 颜色 + 符号 |
| 忽略窄终端 | 表格互相挤压、日志错位 | 最小宽高、滚动、截断和页面切换 |
十一、上线前检查清单
q、Esc、Ctrl+C 的退出行为符合预期;- 正常退出、错误退出和 panic 后终端都能恢复;
- SSH 断开、窗口 Resize、80x24 窄终端下界面不崩溃;
- 空设备列表、设备消失、快照延迟和后台异常都有明确提示;
- 表格选中状态在刷新后保持或回落,不越界;
- 图表数据有上限,长期运行内存稳定;
- 高频数据不会导致高频重绘;
- 本地控制动作有确认、审计和权限校验;
- 核心状态迁移和关键界面有自动化测试;
- 发布包固定 ratatui / crossterm 版本,并在目标终端环境做验收。
十二、TL;DR
- ratatui 适合做工业边缘的本地诊断与应急运维界面,不是完整 Web 控制台替代品。
- 终端生命周期要优先做对:raw mode、Alternate Screen、panic hook 和统一恢复路径。
- 布局使用
Layout::vertical/Layout::horizontal,并验证窄终端。 - 表格用
TableState管理选中行,空列表和列表缩短必须处理。 - 图表数据使用固定长度窗口,避免长期运行内存增长。
- 事件必须区分
KeyEventKind::Press,并用event::poll防止循环阻塞。 - 渲染函数只做展示,设备采集和业务状态更新放进后台事件流。
- 可用单元测试覆盖状态迁移,用
TestBackend覆盖关键界面回归。
下一步建议
- 先把最小骨架和终端恢复逻辑跑通。
- 按页面拆分布局与渲染函数。
- 为设备列表、日志和图表建立独立 App 状态。
- 引入后台快照通道,解耦数据源和 UI。
- 补齐窄终端、异常数据和状态迁移测试。