
1. 项目缘起我的一天有一半时间在“看状态”而不是写代码1.1 一个典型开发日的状态散落问题如果你也是一线开发者大概会有类似的体验到工位第一件事不是打开编辑器而是先把一堆“状态页”挨个刷一遍。打开终端看服务器负载切到浏览器看 GitHub Actions 有没有跑完再点开 Jenkins 把昨天那个红了一整晚的发布任务找出来顺手还要确认两个线上服务的健康检查是否正常。这些页面之间没有任何联系有的要二次登录有的加载还要等半天信息密度却低得可怜——大部分时候我只是想知道三件事有没有失败、有没有跑完、有没有异常结果却要被迫面对一屏又一屏的日志和图表。这种状态粉尘式的信息获取方式大概就是我做 Status Deck 的直接原因。Status Deck 是一个给开发者自己用的桌面仪表盘把散落在各个服务里的开发状态数据聚合到一个常驻窗口里用卡片的方式呈现让“看一眼就知道今天要不要加班”成为可能。本篇是系列的第一篇重点聊聊项目立项时我是怎么想的、整体全栈架构长什么样、首版功能为什么是这三张而不是二十张以及从 0 到跑通过程中踩过的几个有代表性的坑。1.2 为什么市面上的监控大盘救不了我说到“仪表盘”很多人第一反应是 Grafana、Datadog 这类监控系统。我也用过但会发现它们的定位和我想要的完全不一样。监控大盘解决的是“可观测性”问题面向的是线上系统的指标、日志、链路追踪它回答的是“系统健康吗”。我想要回答的却是“我手里的活儿现在什么状态”本地有十个仓库在并行开发哪些分支已经干净了哪些还挂着未提交的改动同事推了几个 commit 我还没拉CI 那边有哪个 pipeline 挂了需要我去看看。这类信息的特点是离散、偏个人、变化频率不高、来源五花八门。正经监控系统根本不会管你本地仓库脏不脏也不会把 GitHub 和 Jenkins 的任务状态和你的待办联系起来。市面上的“开发者仪表盘”工具也有不少比如 DevDashboard、DuckieUI 这类但装了一圈发现两个问题要么太重配置半天要么太轻可扩展性差。我的诉求很朴素——一个能让我自己定义数据源的、轻量级的、长驻桌面的状态面板。既然没有完全匹配的现成工具那就自己造一个顺便把“全栈”这层皮完整走一遍。1.3 Status Deck 是什么不是什么给项目定了位之后我其实反复提醒自己做减法。Status Deck 的定位很简单它是我自己的开发状态聚合面板不是通用监控平台它展示的是“状态”不是“日志”争取一眼看懂而不是提供所有细节它跑在我的桌面上常驻、低干扰而不是又一个要打开浏览器收藏夹的工具。它也明确不是什么不做告警通道、不做日志检索、不做数据可视化大屏。这些领域都有成熟工具硬塞进来只会膨胀。后续版本可能会加通知能力但现阶段的首版我严格控制功能边界只解决“信息聚合 状态呈现”这一件事。提示自造工具最容易犯的错误是“越做越像商业产品”。我的止损线是如果这个功能有成熟工具能替代而且集成成本低于自研成本就不做。Status Deck 要做的永远是那层“最后一公里”的聚合而不是替代上游系统。2. 全栈架构设计一条数据从仓库走到桌面卡片要经过几层2.1 三层层级采集层、聚合层、展示层整个 Status Deck 虽然是个桌面应用但本质上是一个前后端分离的项目只是最终用桌面壳包了一层。我按数据流向把它拆成三个层级每一层职责单一互不干扰。层级职责技术选型边界采集层对接各种数据源拉取原始数据Python asyncio HTTP / git 命令只管取数不关心展示聚合层统一数据模型计算状态做缓存FastAPI 后台服务只做处理和缓存不产生业务逻辑展示层渲染卡片、交互、定时刷新React Vite Tauri只认聚合层的接口不直接碰上游数据源采集层是最“脏”的因为每个数据源的协议都不一样Git 要跑命令、GitHub 要调 REST API、健康检查要自己实现 TCP/HTTP 探测。聚合层的作用是把这个脏活隔离掉它对展示层只暴露一套干净的数据结构。这也是整个项目里最重要的设计决策——如果没有这一层抽象每个卡片组件都得自己去拉数据、解析格式、处理失败项目很快就乱成一锅粥。2.2 为什么后端选了 Python FastAPI后端服务承担的是“采集 聚合 缓存”三项任务我选了 Python 和 FastAPI理由有三个。第一生态适合做 IO 密集型的采集任务。GitHub API、健康探测、git 命令都是典型的 IO 等待型操作Python 的 asyncio 在这种场景下写起来非常顺手用asyncio.gather可以轻松并发跑几十个任务而不用担心线程管理。第二FastAPI 让我同时拿到了 HTTP 接口和 WebSocket。仪表盘的数据刷新不能只靠轮询也要支持服务端推送FastAPI 对这两者的支持都原生且简洁。第三写起来快。这个项目本质是自用工具我不想在语言层面花太多时间Python 的动态类型和 FastAPI 的自动文档让迭代速度非常快。当然Python 也不是没有短板。后面做打包分发时要带一个 Python 运行时导致整个应用体积偏大另外 asyncio 调试起来比同步代码麻烦一些。但作为个人项目的后端这些代价完全可控。2.3 前端和桌面壳我在 Electron 和 Tauri 之间的选择前端部分我选了 React Vite这一层没有太多悬念——组件生态成熟开发体验好。真正的选择题在桌面壳Electron 和 Tauri 二选一。Electron 几乎是桌面套壳的默认答案VSCode、Slack 都是它做的。我第一版也先用 Electron 跑通了全流程确实省事Node 层直接写主进程逻辑渲染进程就是纯粹的前端页面。但跑起来之后我盯着任务管理器皱了眉——一个这么简单的仪表盘常驻内存轻松吃掉四五百兆。我自认这是个“打开就要挂一整天”的工具这个内存占用没法接受。后来我开始试 Tauri。Tauri 用的是系统 WebView 渲染前端后端是 Rust内存占用可以压到 Electron 的零头。最大的心理障碍是 Rust但实际用下来发现根本不需要写什么 RustTauri 的项目里Rust 侧只需要承担窗口创建、系统托盘、文件读写这些壳逻辑业务代码全部在 WebView 里跑通过 Tauri 的 command 机制调系统能力。对我这个项目来说这就够了。最后一拍桌面壳定成了 Tauri 2但这篇文章里的架构描述对 Electron 版本同样适用因为业务逻辑不在壳层前后端交互的接口设计是一致的。2.4 通信设计初次拉取用 HTTP状态变更用 WebSocket前后端通信我设计了两个通道规则很简单初始数据用 HTTP应用启动、卡片刷新这类一次性场景走 REST 接口比如GET /api/cards一次性拿回所有卡片的完整数据。好处是简单失败重试、超时都容易处理。状态变更用 WebSocket聚合层的数据源发生变化时比如 Git 状态被标记为“有未提交改动”、CI 从 running 变为 failed后端主动推一条消息前端收到后只更新对应的卡片不用整页刷新。选择 WebSocket 而不是 SSE是因为后续我想做双向交互——从卡片上直接触发某个操作比如点一下“重新扫描”按钮这个指令要从前端发给后端WebSocket 天然支持双向SSE 只能单向推送。虽然首版用不上但我不想后面改通信层。注意WebSocket 的逻辑要处理好重连和心跳。桌面应用经常休眠被唤醒休眠期间连接会断开醒过来要能自动重连并把数据补回来。这个我在踩坑部分会细说属于那种不遇到根本不会想到的雷。3. 首版功能三张卡片每张都砍了三轮需求3.1 Git 状态卡把十几个仓库的烦恼浓缩成一张卡首版我最终只保留了三张卡片第一张是 Git 状态卡。之所以优先做它是因为这是我最常刷的信息工作区几个项目的分支是否干净、领先/落后多少个 commit、有没有需要处理的 merge 冲突。这些信息来自本地仓库不需要任何第三方 API隐私上也没有负担。这一张卡的核心能力是多仓库聚合。我配置了一个需要关注的仓库列表后端定期对每个仓库执行git status和git fetch然后把结果汇总成一个统一的状态条目。自然语言描述会优先呈现比如“3 个仓库需要处理branch main 落后 origin/main 2 个提交”而不是扔出一堆原始输出。功能实现上我砍掉了三样东西diff 统计需要读文件内容代价大、commit 历史列表信息密度太高、分支切换操作那是终端该干的事。留下来的只有“干净、脏、领先、落后、冲突”这五类状态配上归属仓库名和分支名足够我决定下一步动作。3.2 CI/CD 状态卡我不想知道日志细节只想知道“红了没有”第二张卡是 CI/CD 状态卡接的是 GitHub Actions API。当初想得很丰满列出每个仓库最近的 workflow 运行记录、成功失败、耗时、失败的是哪一步。做完就发现一个问题——这张卡会迅速变成“日志浏览器”和信息聚合的思路完全背道而驰。三轮砍下来这张卡最后只剩两行信息每个仓库当前需要关注的运行任务列表以及每个任务是 running、success 还是 failed。具体哪一步挂了点开卡片进详情页再查主面板上坚决不做逐行日志。另一个设计上的考虑是排序逻辑failed永远排最前面其次是running最后才是success。因为卡片的使命是让我快速回答“有没有事”不是“今天 CI 跑了几次”。3.3 健康探测卡从 TCP 到 HTTP一个比一个实用第三张卡是健康探测卡类型上既包括本地服务也包括我维护的几个线上小服务。这张卡的关注点同样非常简单服务活着没、响应快不快、最近一次探测时间是什么时候。探测方式分两种TCP connect 探测建立 TCP 连接能连上就认为存活。适合 Redis、MySQL、SSH 这类非 HTTP 服务实现成本极低一个socket连接搞定。HTTP 探测发一个 GET 请求除了判断能否响应还会检查 HTTP 状态码和响应耗时。比如状态码 200 是健康502 显然就是异常虽然服务“活着”但已经不具备正常服务能力了。TCP 和 HTTP 的区别打个比方就是TCP 只能证明“门能开”HTTP 才证明“里面的人在正常干活”。健康探测卡两种都用规则是同一个目标只能配置一种探测方式避免语义混乱。3.4 健康度模型为什么我用“状态”而不是“数字”三张卡片最终都指向同一个东西——健康度模型。我给每种数据源设计了一个枚举状态ok、warning、critical外加一个pending正在获取数据。界面上绿、黄、红、灰四种颜色对应四种状态这才是整套仪表盘的核心表达逻辑。这里我坚持一个原则大面积用状态不直接用原始数字。原因是人脑对颜色的反应速度远快于对数字的理解速度。看一个红点远比看“响应时间 3200ms”更快触发我的警觉。数字信息当然要保留但我把它们放在次要层级鼠标悬停或点开详情时再看视觉上第一优先级永远是“有无异常”。4. 从 0 到跑通一路踩过的坑五个值得记录的问题4.1 Electron 的内存焦虑以及我为什么转投 Tauri前面说过我一开始用 Electron跑通后发现一个非常现实的问题Status Deck 是要常驻桌面的应用我不可能每天打开编辑器之前手动启一个四五百兆内存的进程在那挂着。有朋友说“现在电脑都是十几个 G 内存四百兆算什么”但积少成多内存吃紧的根源往往就是一堆“不算什么”的常驻进程叠加出来的。转 Tauri 的过程给了我一个意外收获Tauri 的配置系统比 Electron 简单而且它强制我把“壳逻辑”和“业务逻辑”分开。Electron 很容易让 Node 主进程里慢慢长出业务代码因为写起来太顺手了Tauri 的主进程是 Rust门槛天然拦住了一部分坏味道逼我把逻辑全部放进 WebView 和独立后端服务里。这也让项目边界清晰了很多。如果让我对正在纠结选型的人说一句纯工具类桌面包壳优先考虑 Tauri如果必须要用 Node 生态的原生模块再回头考虑 Electron。4.2 被 GitHub API 限流教育后的轮询策略CI 状态卡的核心数据来自 GitHub REST API这里面的第一个坑就是限流。未认证请求只有 60 次/小时加了 token 是 5000 次/小时。我一开始放了个 30 秒一轮的定时器去拉所有仓库的 workflow runs跑了一会儿就收到了403 rate limit exceeded。被教育之后我做了三件事第一给所有请求加上Authorizationheader 放自己的 token第二把轮询频率从 30 秒放宽到 2 分钟因为 CI 状态本来就属于低频变化数据30 秒刷新反而是浪费第三也是最关键的加了内存缓存和客户端条件请求。GitHub API 支持If-None-Match头返回 304 不传输响应体这样既避开了限流也大幅减少了带宽开销。轮询策略最终总结成一条经验先确认数据的变化频率再设计轮询间隔优先用协议层的缓存机制而不是靠骂限流解决问题。4.3 YAML 配置膨胀到 UI 配置的必经之路项目一开始我把所有数据源配置写在一个config.yaml里要监视的仓库列表、CI 的 token、健康探测的目标清单。前几个版本迭代得很爽改配置只要编辑文件再重启服务。但配置项超过二十个之后问题来了语法错误只能让程序启动时崩没有即时校验配置拼音规格靠记忆字段名稍微长一点就容易写错每次加一个数据源类型就要改 YAML schema、写文档、再让编辑器的高亮跟上。后来我做了个内置配置页把配置管理工作从前端界面化了后端继续保留 YAML 文件作为持久化存储。这里有个值得分享的个人判断不是所有工具都要 UI 配置但配置项超过能靠脑力维护的数量级时一定要上 schema 校验和可视化编辑不然项目会死在“改配置比写代码还容易出错”这件事上。4.4 相对时间的时区陷阱Git 状态卡里有一行“2 小时前有提交”CI 卡里也有“3 分钟前完成”。这个“相对时间”看着简单背后藏着一个经典的时区坑。GitHub API 返回的时间是 UTC 字符串比如2025-01-15T09:30:00Zgit 命令的提交时间是 Unix 时间戳或本地时区的日期时间如果直接拿这些字符串去做“当前时间减它”的计算尤其在不同时区的机器上运行很容易出现“显示成 8 小时前但实际是 0 小时前”这种诡异结果。我最后的统一做法是采集层拿到数据后第一步不转换、不格式化全部转成 Unix 时间戳入缓存展示层要显示时基于用户本地时区算相对时间。时间数据一旦进入系统就只用绝对时间戳这一个标准格式化这件事永远放在最后一公里做。4.5 开发模式和生产模式的“两副面孔”这个坑在 Web 开发里很常见但在桌面应用里会更加恼人。开发模式下Vite 的前端跑在localhost:5173后端跑在localhost:8000前端通过 Vite proxy 转发 API 请求打包成 Tauri 应用后前端页面被 Tauri 加载成本地资源API 地址却变成了真正的后端配置地址。如果代码里硬编码了代理路径生产模式一打开就会全部请求失败。我的解决办法是约定一个环境变量STATUSDECK_API_BASE开发时指向http://localhost:8000/api打包时通过 Tauri 的配置注入实际后端地址前端的请求封装统一读这个配置绝不能有任何“硬编码 URL 直接出现在组件里”的写法。为了防止自己手滑我在前端代码里加了一个开关换环境后如果发现请求发到了 localhost 而项目配置里不是 localhost就弹一个明显警告。5. 三张卡片背后的核心实现逻辑5.1 Git 聚合asyncio 并行扫描别用 shell 脚本硬扛先说一个简单的数据模型。后端采集时每个仓库的状态用一个字典表示核心字段大概是{ repo: /Users/me/work/status-deck, branch: feature/config-ui, dirty: True, ahead: 2, behind: 4, conflict: False, last_commit: { author: octocat, timestamp: 1735695246, subject: feat: add config page } }扫描逻辑上我直接用git命令行而不是第三方 git 库因为这里需要的只是symbolic-ref、status、rev-list --left-right --count这几个操作命令行足够且行为可预期。真正的难点在于“同时管理十几个仓库”。最自然的方案是 asyncio 并行。我写了一个scan_all_repos()协程对仓库列表逐个创建子进程执行 git 命令import asyncio from pathlib import Path async def scan_one(repo: Path, sem: asyncio.Semaphore) - dict: async with sem: branch await run_git(repo, [symbolic-ref, --short, HEAD]) status await run_git(repo, [status, --porcelain]) dirty bool(status.strip()) ahead_behind await run_git( repo, [rev-list, --left-right, --count, HEAD...{upstream}] ) # 解析 ahead_behind 字符串例如 2\t4 ... return {repo: str(repo), branch: branch, dirty: dirty, ...} async def scan_all(repos: list[Path]): sem asyncio.Semaphore(5) # 限制并发子进程数量防止系统资源被瞬时打满 results await asyncio.gather(*(scan_one(r, sem) for r in repos)) return [r for r in results if r.get(branch)]为什么要限制信号量到 5如果不限同时拉起二三十个 git 进程瞬间 CPU 和内存都会有尖峰尤其是对大仓库执行status时。限制并发数后扫描全部仓库的时间从“一下卡住”变成了“约 2 秒完成”体感好得多。另外要注意HEAD...{upstream}在没有上游分支时会报错。这里我给 scan_one 加了异常捕获凡是某个仓库执行失败就返回一个error字段并在前端标成灰色绝不拖垮整个聚合循环。5.2 CI 状态Webhook 与轮询的混合姿势CI 状态卡的数据我最终用了混合方案而不是单纯依赖某一侧。主链路是轮询。后端开一个后台任务每 2 分钟请求一次 GitHub 的actions/workflows和actions/runs接口把每个仓库最近几条关键 run 的状态拉回来。用If-None-Match后绝大多数轮询只会拿到 304 空响应成本很低。辅助链路是 Webhook 接收。GitHub 支持给仓库配置 webhookCI 任务状态变化时会推送事件到指定 URL。我为仪表盘后端单独注册了一个/api/webhook/github路由收到事件后先把关键信息落库再主动触发一次全量刷新频率控制在每分钟最多一次。这样做的好处是不依赖纯轮询的延迟关键状态变化可以在几秒内反映到前端卡片上。实现上真正的决策点是该相信 webhook 还是相信轮询我选择“轮询兜底webhook 加速”。因为 webhook 推送有可能丢失服务重启、网络抖动、GitHub 端配置错误如果只靠 webhook丢一次事件卡片就永远卡在旧状态上。轮询虽然延迟大一点但一定能拿到最终一致的数据。5.3 健康探测connect 超时和 HTTP 状态为什么都要看健康探测的实现本身不难难的是判断标准。TCP 探测我封装成一个很短的函数import asyncio async def tcp_probe(host: str, port: int, timeout: float 3.0) - tuple[bool, float]: start asyncio.get_event_loop().time() try: reader, writer await asyncio.wait_for( asyncio.open_connection(host, port), timeouttimeout ) writer.close() await writer.wait_closed() elapsed round((asyncio.get_event_loop().time() - start) * 1000) return True, elapsed except (asyncio.TimeoutError, OSError): return False, -1针对 HTTP 探测还会有第二层判断状态码必须落在 200-399 之间视为正常否则视为异常。为什么不是只看“有没有连上”因为反向代理、网关这类组件的存在即使状态码是 502TCP 层也照样能建立连接。只做 TCP 探测等于只看门开不开不看店里的货在不在。在实际配置里我还支持给 HTTP 探测加一个“预期状态码列表”比如某些服务允许 503正在重启也标记为warning而不是critical。这类配置在 YAML 里定义schema 校验保证不会写错。5.4 前端如何展示“状态”而不是“裸数据”到前端这一层核心设计问题是三个来源完全不同的数据如何让用户在同一个视觉语言下读取我的方案是定义一个统一的卡片数据协议{ card_id: git-status, title: Git 状态, items: [ { label: status-deck, status: warning, summary: main 落后 origin/main 2 个提交, detail: feature/config-ui 有 3 个未提交改动, updated_at: 1735695246 } ] }前端组件只认识status这个字段的四个枚举值颜色规则、排序规则都由它驱动。组件里不写任何“如果仓库名是 xxx 就显示黄色”这种硬逻辑——状态必须由聚合层算好前端只负责翻译成视觉。排序方面我借鉴了收件箱的思路critical的项永远置顶warning其次ok的项折叠成一个小分组图标pending的项等结果回来后自动归位。页面默认是“安静”的只有异常出现时才会变得显眼。这也是仪表盘最重要的性格大部分时间是背景板出问题的那一小会儿才值得成为焦点。6. 系列后续规划以及给想自造仪表盘的人的三条建议6.1 第二篇会做什么通知、历史趋势、插件化首版跑通后我列了一个“下一步”清单按我的使用频率排序大概是这样的异常通知Status Deck 不应该只坐在桌面上等我主动看当某张卡从ok跳变到critical时要能通过系统通知弹出来。前提是做好防抖避免同一事件反复打扰。状态历史目前只有实时快照没有“过去一小时发生了什么”。我想加一层简单的时序存储让每个卡片的每次状态变化都被记录这样能回答“刚才那个崩溃是什么时候开始的”这类问题。插件化入口现在的三类数据源是硬编码的想让外部数据源能通过一个简单的接口描述接入。这项工作重在设计稳定协议估计会放在系列后半段。这些都会陆续写出来第二篇大概率先讲通知和状态历史因为这两块能直接提升这个工具的使用舒适度。6.2 三条建议自造工具的第一性原理整个项目从立项到首版跑通如果要提炼成三条可以拿去就能用的建议我想写这三点第一先解决自己的痛点别想着做通用产品。自造工具最大的优势就是可以“不讲道理地自私”只为自己最痛的那几个场景做优化。一旦开始考虑“别人会不会用”功能边界就会失控。第二数据源接入一定要过一层抽象。无论后端选什么语言、前端用什么框架把“上游数据源”和“展示层”用中间模型隔开都能让你的项目在加新数据源时不用翻前面的代码。那个统一的数据协议是我这个项目里觉得最值回票价的投资。第三高价值功能不等于高频信息状态优于数字聚合优于原始记录。做仪表盘的时候你可以反复问自己一个问题这一屏信息能不能在三秒内判断出“有事没事”如果不能说明信息层级或聚合粒度还需要再压一层。最后再分享一个我在这个项目里的实际体会自造一件趁手的工具本质上是一种“对日常失控感的小规模反击”。一开始我只是想把散落的标签页收拢到一个窗口里做完之后发现真正被收拢的其实是我每天切换上下文时耗掉的那部分精力。Status Deck 现在每天开机自己启动、全程安静地缩在屏幕角落只有在某个仓库出问题、某条 CI 变红的时候才轻轻亮一下。这种感觉比任何“效率工具”带来的满足感都要真实。