1. 为什么放着现成的不用,非要自己写一个Dashboard
先说说背景。我平时的工作流是这样的:早上到工位,先打开IDE看一眼昨天的CI跑没跑过,然后开浏览器戳几个面板确认线上服务还活着,再顺手刷新一下GitHub看有没有新的Issue,中间还要切到终端敲命令看磁盘还剩多少、内存吃了多少。等把这些信息都过一遍,半小时过去了,咖啡都凉了。
这其实是开发者的普遍痛点——信息源太多,但状态是离散的。代码仓库一个平台,监控系统一个平台,CI/CD一个平台,本地机器状态又在系统监控里。每个工具都做好了自己那一块,但没人帮我把它们汇总到同一个界面上。市面上不是没有现成的方案,Grafana这种专业监控工具确实能帮我搞定一堆数据源,反面是配置成本和学习曲线都不低,为一个个人工作台去维护一套Prometheus加Grafana的组合,多少有点杀鸡用牛刀。浏览器插件和纯Dashboard页我也试过,要么只能展示静态内容,要么数据来源根本没法自定义。
所以就有了这个念头:干脆自己写一个。
Status Deck的定位从一开始就很明确——一个住在我桌面角落的小仪表盘,把那些日常需要反复查看的状态信息聚到一处。它跟Grafana最大的区别是,我不关心历史趋势,也不关心告警规则,只要现在这一刻,我的GitHub仓库有没有新消息、测试环境通不通、这台机器CPU是不是又跑满了、今天天气适不适合出去透口气。一句话概括:它是一个给开发者自己用的、以“此刻状态”为中心的轻量桌面仪表盘。
这个系列我会把它拆成几篇来写,第一篇先讲整体的设计思路、技术选型,以及核心骨架怎么搭起来。如果你也是那种喜欢什么工具都自己造一个、顺便练手全栈的开发者,这篇文章应该对你有用。
2. 技术选型的权衡,以及为什么是这套栈
动手之前,最纠结的就是技术选型。其实仪表盘这类应用,技术栈的选择范围非常宽,从纯前端HTML页面到重型框架都有,但我这次的选择有几个硬性约束:
- 必须能跑在桌面环境里,还要占尽可能小的资源
- 最好能持续迭代,所以代码结构要清晰,不要一次性的脚本
- 需要有后端参与,因为很多状态需要主动探测,不能只靠前端直接请求
基于这几点,我先做了细分选型,然后把组合方案定下来。这套组合经过实测,开发体验和资源占用都达到了预期。
2.1 前端框架:React 加 Vite,别在这个环节浪费时间
前端选型我几乎没有犹豫,直接用了React加Vite。
React的生态太成熟了,仪表盘本质上是一个状态密集型的应用——每个卡片有自己的加载状态、错误状态、刷新状态,React的声明式开发模式对这种场景很友好。你要是用原生JS写,卡片少了还无所谓,卡片一多,光维护DOM更新和状态同步就能把人逼疯。
Vite作为构建工具,最大的价值就一个字:快。开发服务器启动几乎不用等,热更新是毫秒级的,这对频繁调样式、调布局的仪表盘开发来说体验提升太大了。而且Vite对ESM的支持非常自然,不需要再折腾Webpack那套配置。用Tailwind来写样式,主要是图它的CSS类名语义化好,在代码里直接看到flex items-center gap-2就大概知道布局长什么样,不用来回切文件找对应的样式规则。
2.2 后端服务:Node.js,因为JS全场通用
后端我选了Node.js,配合Express起HTTP接口。这倒不是因为Node性能最好——事实上这种低频状态查询场景压根不需要高性能,而是因为全栈统一语言的收益太大了。
前端用React,后端用Node,意味着我可以共享判断逻辑。比如状态文案的判断(UP、DOWN、DEGRADED这类的阈值规则),前端和后端用同一套代码实现,只在前端展示层做差异化渲染。数据格式校验也只需要写一次。如果一个技能树能覆盖整个项目,维护成本就能降下来,这也是“全栈自造”这个系列的核心用意。
2.3 桌面壳:Tauri而不是Electron,省内存是最大理由
这是整套选型里最值得展开说的一项,因为Electron和Tauri的取舍,直接影响最终产品的资源占用和安装包体积。
先说结论:我选了Tauri。Tauri的桌面端是个Rust写的小壳,由它调用系统自带的WebView组件来渲染前端页面,后端逻辑则作为一个本地服务在系统里运行。对比Electron那种把整个Chromium浏览器打包进来的方式,Tauri的安装包能小一个数量级,内存占用也轻得多。
实际跑起来之后,我的Status Deck在空闲状态下内存占用大约是80MB左右,如果是Electron做同样的页面,这个数字会到250MB起步。对一个“常驻桌面角落”的仪表盘来说,省下来的这170MB内存,对开发机体验的影响是实打实的。当然Tauri也不是没有代价,它的Rust部分需要编译,头一回构建会比较慢,而且有些CSS兼容性问题在WebView里会出现——这些我都会在后面的实操部分展开讲。
2.4 数据层:本地优先,API聚合加主动探测
数据层的设计思路是“本地优先”。仪表盘要展示的信息可以分成三类:
- 本地环境状态,包括CPU占用、内存使用率、磁盘剩余空间、网络延迟
- 外部服务状态,包括GitHub仓库活动、CI构建结果、线上API健康度、自建服务的连通性
- 第三方信息,包括天气、突发新闻里的关键词等
前两类数据,后端服务直接采集和探测,然后通过统一的HTTP接口提供给前端。第三类需要调第三方API,但为了避免前端跨域,也统一走后端代理。这套设计的核心是:前端永远不知道数据的原始来源,只跟后端交互,这样后续哪怕要换数据源,也只改后端一个文件,前端完全不受影响。
技术栈确定之后,我对整套项目的结构预期是这样的:
status-deck/ ├── src/ # 前端React源码 ├── src-tauri/ # Tauri桌面壳,含Rust代码 ├── server/ # Node.js后端服务 ├── config/ # 仪表盘配置文件,卡片清单等 └── data/ # 缓存与本地持久化目录这个结构不大不小,正好适合一个人维护。
3. 前端仪表盘核心实现,卡片化布局是关键
仪表盘的前端实现,核心不是一个高级的视觉效果,而是卡片化的信息架构。我先把前端的数据流和组件结构定下来,再一步步实现。
3.1 卡片作为信息单元,布局用CSS Grid
我决定整个界面完全由卡片组成。每张卡片承载一种类型的状态信息,例如GitHub仓库卡片、本地资源监控卡片、HTTP服务健康检查卡片。这样有几个好处:每种信息有自己的空间,互不干扰;卡片与卡片之间天然形成了主次关系;后续新增数据源只需要“再写一张卡片”,不需要动已有页面。
布局方式我选了CSS Grid,仪表盘整体是一个grid grid-cols-3 gap-4的容器,每张卡片再根据内容的重要程度决定是否跨列。比如说GitHub卡片我需要看到仓库名、Star数量、今天的新增Issue,这种信息密度高的就占两列;本地资源那种只显示三四个数字的,就占一列。
卡片组件的接口设计得干净一点。每张卡片只需要三个部分:
title,显示在卡片左上角的名称status,给一个UP/DOWN/LOADING的枚举,卡片外壳据此渲染绿色、红色或灰色的状态指示点children,实际的展示内容
代码大概长这样,为了保持内容紧凑我做了简化:
type CardProps = { title: string; status: 'UP' | 'DOWN' | 'LOADING' | 'ERROR'; children: React.ReactNode; } function Card({ title, status, children }: CardProps) { const statusColor = { UP: 'bg-emerald-500', DOWN: 'bg-rose-500', LOADING: 'bg-amber-400', ERROR: 'bg-stone-400' }[status]; return ( <div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm p-4 border border-slate-200 dark:border-slate-700"> <div className="flex items-center justify-between mb-3"> <span className="text-sm font-medium text-slate-600 dark:text-slate-300">{title}</span> <span className={`w-2.5 h-2.5 rounded-full ${statusColor}`} /> </div> {children} </div> ); }状态指示点用一个小圆点来表现,这是仪表盘最直观的视觉反馈。用户一瞥就能判断,绿色代表一切正常,红色代表服务挂了,不需要读任何文字。
3.2 数据获取的轮询机制,以及如何做局部刷新
仪表盘的时效性要求是“分钟级别的”,所以数据不需要秒级刷新,5秒太频繁,10秒都有点浪费。我最后把轮询间隔设成了30秒。
轮询机制需要处理几个问题:不能所有卡片同时请求,否则瞬时并发会顶到后端;组件卸载后请求必须中断,否则会报内存泄漏;请求失败时要有重试和状态展示。
我写了一个usePolling的Hook来解决这件事:
function usePolling(fetcher: () => Promise<void>, interval = 30000) { const [state, setState] = useState<'LOADING' | 'UP' | 'DOWN' | 'ERROR'>('LOADING'); useEffect(() => { let disposed = false; const run = async () => { try { await fetcher(); if (!disposed) setState('UP'); } catch { if (!disposed) setState('ERROR'); } }; run(); const timer = setInterval(run, interval); return () => { disposed = true; clearInterval(timer); }; }, [fetcher, interval]); return state; }这里关键的一个点是disposed变量。React在组件卸载时useEffect的清理函数会被执行,但如果定时器已经触发了异步请求,在请求返回时组件可能已经卸载了,这时再调用setState就会触发Vite开发模式下的内存泄漏警告。disposed标记能在清理函数中阻止后续的状态更新。
局部刷新是这个设计里另一个重要的细节。每张卡片只请求自己的接口,互不串扰。比如我手动点击GitHub卡片的刷新按钮,本地资源卡片不会跟着重新加载,它们各自的轮询器独立运行,互不干扰。
3.3 暗色模式,开发者的第一需求
写这个项目给我最大的感想是,开发者对这种桌面工具的真实需求就是“暗色模式必须是第一时间支持的,而不是后续迭代才补”。
我用Tailwind的dark:类名实现了暗色模式切换,并跟随系统的配色方案自动切换。实现需要往HTML根节点挂一个class,我会在Tauri配置启动时就检测,平时系统切到暗色主题后仪表盘跟着切换。
切换逻辑本身很简单,但它对整个页面的设计影响很大。颜色不能是纯黑纯白,而是在白色背景和深色背景上都保持可读性的中间层级。我最后定义了一套灰色阶作为中性色,状态色用饱和度更高的绿和红,保证暗色背景下状态色仍然醒目。这和很多监控工具的做法一致。
4. 后端数据服务:健康检查、探测与API设计
后端是Status Deck能“反映真实状态”的关键。它要做的不是被动返回静态数据,而是主动执行探测动作,再把结果汇总成前端可以直接展示的JSON。
4.1 服务探测的三个层次,以及具体实现
我设计的后端健康检查分成三个层次,从轻到重,分别是ICMP/Ping探测、TCP端口探测和HTTP业务探测。
第一层是Ping探测,用来判断目标主机是否在线。实现时我用系统自带的ping命令,通过Node的child_process执行并解析结果。需要注意Ping的ICMP报文在有些环境下会被防火墙屏蔽,而且在线路不稳定的情况下会丢包,所以这层的结果只能作为一个参考信号。
第二层是TCP端口探测。比Ping更可靠一些,因为即使主机在线,服务没监听对应端口,连接也是失败的。我用Node内置的net模块实现:
const net = require('net'); function checkTcpPort(host, port, timeout = 3000) { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; socket.setTimeout(timeout); socket.on('connect', () => { if (!settled) { settled = true; socket.destroy(); resolve({ reachable: true }); } }); socket.on('timeout', () => { if (!settled) { settled = true; socket.destroy(); resolve({ reachable: false }); } }); socket.on('error', () => { if (!settled) { settled = true; socket.destroy(); resolve({ reachable: false }); } }); socket.connect(port, host); }); }超时时间设了3秒。一个典型的探测流程是先Ping,不通再尝试TCP,最后根据TCP的结果决定是否继续深入。这种分层的意义在于,能判断出故障卡在哪个环节:主机挂了、端口没开、还是应用层返回了错误码。
第三层是HTTP业务探测。这是最有价值的一层,因为它能验证服务是否真的在正常工作。对HTTP服务,我发一个GET请求,检查返回状态码是否在预期范围内,同时记录响应时间。
响应时间是一个特别重要的指标,我把它单独拎出来计算。请求的整个耗时包括DNS解析、TCP握手、TLS握手、响应体传输几个部分,Node 18以上的内置fetch通过performance.now()前后相减就能得到总耗时。这里我埋了一个缓存点:当响应时间超过1秒,前端会在状态点上显示一个迟缓的黄色标记,而不是直接判断为DOWN。开发环境确实会遇到某个接口本来就要几秒才返回的情况,直接判DOWN会让仪表盘一直闪红灯,失去参考意义。
4.2 本地环境信息采集,用内置模块就够了
本地资源这块,Node标准库的os模块已经能覆盖80%的需求。os.cpus()能拿到每个CPU核心的使用信息,os.totalmem()和os.freemem()能算出内存占用率,磁盘容量则用fs.statfs来获取,比调用系统命令跨平台兼容性好得多。
CPU使用率的计算需要一点技巧。os.cpus()返回的是一个包含times对象的结果集,times记录了空闲时间和各类忙碌时间。第一次采样的结果不能当作用率,因为那是自启动以来的累计值。需要用两次采样之间的差值来计算:取两次采样中CPU累计忙碌时间的差值,除以总时间差值,才得到这段时间内的真实使用率。
4.3 聚合API该怎么设计,一个接口还是一个接口群
接口设计我采用“一卡片一接口”的方式。也就是说,每张卡片在前端有自己对应的独立接口。GitHub卡片请求/api/github,本地资源卡片请求/api/system,HTTP探测卡片请求/api/health?target=xxx。
这样做的好处是前端局部刷新的设计能落地,不需要请求一个聚合大接口然后挑选自己关心的字段。代价就是后端需要维护的接口数量多一些,而且每个接口内部会有自己的缓存策略。我统一做了一个30秒的缓存层,30秒内重复请求直接返回旧数据,避免探测动作过于频繁。
数据格式我统一成这样:
{ "status": "UP", "timestamp": "2024-06-20T09:30:00+08:00", "data": {} }前端拿到这个结构,不需要任何适配就能渲染。其中status字段直接喂给卡片组件的状态枚举。
4.4 涉及第三方API时的Key管理,这是一块硬伤
仪表盘如果要展示GitHub信息、天气、新闻等外部数据,就需要使用第三方API的认证信息,例如GitHub Personal Access Token。这里有个安全陷阱:如果前端直接调用第三方API,Token会暴露在服务端响应里吗?不会,因为一切请求都走后端代理。
但如果后端的代码仓库是公开的,Token又写死在环境变量文件里,那就等于公开了。我的做法是在项目根目录放一个.env.local文件,把Token存进去,然后在.gitignore中忽略这个文件。后端启动时通过process.env.GITHUB_TOKEN读取。这里要特别提醒一句:任何写入代码里的密钥,哪怕后来删掉了,也默认已经泄露了,该撤销就撤销。这是我从踩过的坑里得到的教训。
5. 桌面端包装,Tauri的配置与工程化细节
前端和后端都跑起来之后,下一步是把它包装成一个真正的桌面应用。Tauri在这套方案里扮演的是桌面应用壳的角色,负责提供窗口、系统托盘和本地服务的进程管理。
5.1 Tauri项目初始化与前端集成
Tauri项目可以理解为一个标准的Vite前端和一个Rust后端壳的合体。用npm create tauri-app初始化,然后选Vite加React模板就可以了。它会生成src-tauri目录,里面是Rust代码和配置文件。
关键配置文件是src-tauri/tauri.conf.json,里面指定了前端构建产物的位置和窗口的初始属性:
{ "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:5173", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" }, "app": { "windows": [ { "title": "Status Deck", "width": 900, "height": 600, "resizable": true, "decorations": true } ], "security": { "csp": null } } }width和height设成900乘600,这个尺寸刚好可以在不遮挡太多工作区的情况下显示整个仪表盘。如果屏幕分辨率高,也可以后续调整。窗口装饰保留decorations: true,这样能直接用系统自带的最小化、关闭按钮,省去自己实现窗口控制按钮的麻烦。
5.2 WebView的兼容性陷阱,我踩过的三个坑
Tauri用的WebView不是每个系统都完全相同——Windows上是WebView2,Linux上通常是WebKitGTK。这里有几个我在实际开发中遇到的具体问题:
第一个是CSS都现代特性支持不一致。比如backdrop-filter这个属性,在macOS的WebView上表现很好,但在某些Linux WebView版本上直接被忽略或者导致元素背景异常。我的方案是:不要依赖模糊背景这类视觉效果,改用基础的半透明色阶配合background-color实现。
第二个是字体渲染差异。同一套中文字体,在不同WebView里显示效果差别很大,间距和行高都可能不同。我在样式中显式声明了系统字体栈:
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;第三是HTML5 API的支持差异。比如某些Linux WebView不支持navigator.clipboard。如果仪表盘要支持复制操作,建议在Rust端实现剪贴板写权限,再通过Tauri的invoke命令调用。
5.3 本地服务进程管理和托盘集成
Status Deck的桌面端和后端服务是独立进程。前端页面通过http://127.0.0.1:11000访问后端服务,这个端口固定在配置里。桌面端启动时,需要顺带把Node服务进程拉起来。
我用Tauri的Rust后端来管理这个进程,在setup钩子里执行启动命令:
tauri::Builder::default() .setup(|app| { let server_command = std::process::Command::new("node") .arg("server/index.js") .spawn() .expect("failed to start backend server"); app.manage(ServerProcess(server_command)); Ok(()) })这是Tauri的setup钩子,在窗口创建前就运行。注意一个工程细节:启动前要检查端口是否已被占用,如果已经被占用,说明之前的进程没退出,杀掉旧进程再启动新的。我遇到过不止一次开发中途迭代,导致旧服务进程还占着端口,新进程起不来。
托盘方面,Tauri的SystemTray可以做得很完整。我实现了两个托盘菜单项:“显示主界面”和“退出”。关闭窗口时应用不退出,只是隐藏到托盘,这样仪表盘不会因为误关窗口而中断常驻。退出时才杀掉后端服务进程,保证没有孤儿进程残留。
6. 常见问题与排查清单,这些坑我帮你踩过了
整个项目从设计到能日常使用,我花了不少时间在排查问题上。把典型问题和排查清单整理一张表,方便你直接参考。
6.1 轮询导致的状态更新问题
现象:仪表盘运行一段时间后卡顿,CPU占用居高不下,连续跑十几分钟风扇就开始转。
根因:多个卡片并行轮询,每个轮询请求之间没有交错,导致所有请求同时打给后端;再加上 Vue/React 的更新被高频触发,渲染进程被反复占用。
排查与解决:每张卡片的轮询增加一个抖动(jitter),也就是在基础间隔上随机增加几秒的偏差。比如30秒的轮询,实际间隔是30到35秒之间随机。这样请求就不会扎堆同时触发,整体负载也平稳很多。实现上只需要在setInterval的间隔参数上做点小文章。
另一个解决办法是在后端做幂等缓存,也就是前文提到的30秒缓存层。哪怕前端同时请求同一个接口,后端也只需要执行一次探测。
6.2 本地缓存导致的“假数据”
现象:某个服务的状态明明已经变了,点击刷新后还是显示旧的。
根因:后端缓存层把结果缓存了30秒,手动刷新的请求命中了缓存。
解决:前端的手动刷新按钮请求时带一个_t参数,值是当前时间戳。后端看到这个参数则强制绕过缓存,直接执行新的探测。这个设计对本地开发特别有用——改完代码想立刻看到新状态,不用等缓存过期。
6.3 卡片刷新闪烁问题
现象:30秒轮询周期内,每张卡片都从UP变回LOADING又变回UP,界面频繁闪烁。
根因:轮询触发时,把卡片的status直接置成了LOADING。对用户来说,数据明明还是上一次的结果,没有必要展示一个加载中状态。
解决:轮询刷新时如果已有成功数据,则保持UP状态不变,只有首次加载或失败重试才显示LOADING。这个改动很小,但对观感影响非常大,是仪表盘能不能“安静地躺在角落”的关键。
我还整理了一份排查清单,方便对号入座:
| 问题 | 排查途径 | 解决方法 |
|---|---|---|
| 系统托盘图标不显示 | 检查Tauri初始化日志中的图标加载路径 | 确认图标资源路径正确且包含多尺寸 |
| 服务端口冲突 | 用lsof -i:11000查看占用 | 启动前自动检测并杀掉旧进程 |
| WebView的CSS渲染异常 | 不同系统的WebView版本差异 | 避免使用太新的CSS特性,用系统字体栈 |
| Linux下窗口透明失效 | 检查窗口管理器的合成器支持 | 改为不透明背景,或设置纯色背景 |
| 内存持续攀升 | 用Chrome DevTools的Performance面板录制 | 确认卸载的卡片清除了定时器 |
| API Token在代码库中泄露 | 检查Git历史 | 立即撤销并重新生成,加入.gitignore |
这里面最值得展开的是轮询抖动和手动刷新带时间戳这两个技巧,看起来都是几行代码的改动,实际效果却非常突出。这是我实际用了一个月以后体会最深的两个优化点。
7. 写在最后,一些经验与后续扩展
做这个项目的整体感受是,技术本身并不复杂,React加Node加Tauri的组合,你基本上可以复制到任何一个小工具项目里。真正花时间的反而是设计层面的决策:信息怎么组织、轮询怎么设计、状态怎么表达。
有个经验值得分享:不要在你的工具里做超出需求的复杂度。一开始我也想过给Status Deck加上历史趋势图、告警推送、多用户体系这些功能,后来全部否决了。因为它的定位是“此刻的状态”,不是监控平台。克制设计范围,能让整个项目保持在一个周末就能攻下来、一个月就能打磨完的程度。后续扩展的方向我也想好了,就是加一个插件机制,让每一类数据源都以插件的方式注册进系统,不需要改核心代码。
如果你也准备动手敲一个类似的项目,我的建议是从最小可用版本开始:先做一个React页面,展示两三个卡片的固定数据;再加后端服务,让数据动起来;最后套上Tauri壳。分解成这三个阶段,每一步的验收都很明确,不会陷入“还没写完就想重写”的循环。祝你在自己的开发机上也早一点把碎片信息收拢到一个窗口里。