每天到工位的第一件事估计很多人跟我一样开电脑、开浏览器、然后挨个切标签页。先看GitHub Actions跑完没再看某个服务的CI红了没有顺手瞄一眼服务器负载又想起半夜那个爬虫脚本不知道还活着没还得去翻日志。这种重复动作坚持一个多月我终于绷不住了决定动手做一个属于自己的“Status Deck”——一个给开发者的桌面仪表盘把平时需要反复盯的状态全部聚合到一个页面上全栈自造从数据采集、存储、API 到前端展示全部自己实现。说实话最开始也认真纠结过是不是直接找现成方案。后来反复对比还是决定自己写。这篇是系列的第一篇重点讲整体设计和骨架搭建为什么放弃现成监控工具、技术选型为什么是 Go 加 Vue、后端采集服务怎么设计、前端卡片怎么渲染以及第一版跑通后我踩过的几个印象深刻的坑。这个系列适合谁看只要你是个想给自己造趁手工具的开发者或者想完完整整跑一条前后端链路、又想避开常见坑的人都能从中找到点东西。基础要求不高懂点 Go、Vue 和 Linux 基本操作跟着做就顺了。1. 为什么我放着现成监控工具不用偏要自己造一个轮子1.1 现成监控工具的三个死穴先说一个很容易被忽略的问题我们聊的“状态”到底有哪些细数一下会发现数据源杂得离谱——有 GitHub 上的 Actions 和 Release、有自己服务器上的 CPU 和内存、有某个内部接口的健康度、有定时脚本最后跑完的时间、有 RSS 订阅源的新文章甚至可能还有天气预报。当数据源长这样的时候现成监控工具的短板就非常明显了。第一这些工具各自都有支持的数据源范围像 GitHub、Cloudflare 这种主流服务确实有现成插件但“我自己写的内部小工具还好不好使”这种数据没有标准出口要接入就得自己写插件而插件机制在不同项目里差别很大有的还强制要求你按它们的 agent 规范来学习成本不低。第二是体量不匹配。Grafana 很强大但要玩明白需要装一堆插件、配数据源、学查询语法Kibana 更不用说完全是日志分析平台的思路。我只想抬头扫一眼几块卡片告诉我现在还活着没、有没有红色告警引入这么重的系统属于杀鸡用牛刀。UptimeRobot 这类在线监控倒是简单可它只能做“网站通不通”想塞自定义指标进去又得绕个弯。第三点是隐私和可控性。个人项目里的很多状态信息我不想传到第三方平台比如内部脚本的运行结果、自己服务器的负载情况这些数据放在本地最安心。而且一旦用了 SaaS 监控服务挂了你还得先登录它家的控制台才能看状态有种“门锁坏了但钥匙在屋里”的荒诞感。综合这三点自己造一个不亏。1.2 我眼里的Status Deck应该是什么样既然决定自造那得先定义清楚它到底是什么、不是什么。我想要的 Status Deck核心就一句话把分散在各处的关键状态用统一的卡片形式聚合在一屏里做到“一眼扫过去就知道世界和平还是出了事”。在这个定位下它有几个明确特征。首先是本地部署所有服务都在自己的电脑或内网服务器上运行数据不出网。其次是高度可扩展新增一个数据源能在二十分钟内完成而不是写一堆胶水代码去适配复杂框架。再一个就是轻视宽它不是为了做精细化可观测而是为了做“状态聚合”卡片一眼扫过就够不需要钻进去分析趋势曲线。同时它也要有明确的边界。它不该变成告警系统因为告警会牵扯到通知渠道、路由规则、静默策略这些复杂东西那是 Prometheus Alertmanager 这类工具的战场它也不该变成时序数据库因为趋势分析需要专门的存储和查询引擎。我的做法是保持“状态展示”这一个核心把边缘功能都砍掉让第一版足够小而完整。1.3 第一版的功能清单不多不少刚刚好定完边界之后我列了一个最小可用功能清单控制在六个数据源、三个页面元素全部做完再考虑加功能数据源GitHub 仓库的最新提交和 Actions 状态、服务器 CPU 和内存使用率、一个内部 API 的存活探测、定时爬虫脚本最近一次运行结果、RSS 订阅里的新文章数量以及当前公网 IP 和网络延迟。页面元素顶部一排快速状态灯全绿、有告警、有错误、中间一组可拖动的状态卡片、底部一条最近更新时间轴。后端能力每类数据源独立采集、统一缓存、最后状态落盘再对外提供一个 JSON API。前端能力每 30 秒自动刷新、状态颜色编码、卡片显示真实更新时间超时数据自动置灰。这个清单我建议你也先写下来再动手。我做第一版时就是拿这个清单倒推数据模型的后面没走太多弯路。2. 技术选型Golang做采集、Vue做界面到底图什么2.1 后端采集器为什么选了Go技术选型这块我反反复复想了很久最后后端选了 Go不是因为什么“高性能”口号而是它跟这个场景实在太匹配了。第一Status Deck 的采集端是一个常驻进程需要长期挂在一台机器上。Go 编译出来是单个静态二进制扔到服务器上就能跑不用装 Python 环境、不用管理 Node 依赖这对一个“自造工具”来说太省心了。而且它运行时内存占用很低在一台 1G 内存的轻量服务器上跟其他服务挤在一起完全没压力。第二采集任务天然适合 goroutine。每类数据源都是独立的采集任务彼此之间不应该互相阻塞。比如 GitHub API 卡了 10 秒不能因此影响 RSS 采集。Go 的 goroutine 加 context 超时控制代码写起来非常直观每条数据源一个协程互不干扰。第三标准库直接覆盖了大部分需求。HTTP 客户端、JSON 序列化、时间处理、定时器这些都是拿起来就能用的不需要引入 Spring Boot 那种重型框架。对我这个项目来说Go 标准库加一个非常薄的封装就够了。2.2 前端界面为什么选了Vue前端选型其实纠结过 React 和 Vue最后选 Vue 是因为它跟我的需求丝丝入扣。Status Deck 的前端本质是“状态驱动的界面”——后端拉回来一份 JSON前端根据状态字段渲染成不同颜色和文案。Vue 的响应式数据模型做这件事非常自然改一个状态变量页面自动更新不需要手动操作 DOM。另一个理由是可迁移性。Vue 3 的组合式 API 写出来的逻辑层代码可以比较顺畅地移植到 uni-app 上后面如果我想把面板做成手机端 H5 或者小程序前端大部分代码能复用。这个点在后面第六节我再详细说。也补充一下为什么不用 Electron。Electron 做桌面应用是很成熟的方案但它会把 Chromium 和 Node 整个带上打包体积几百兆起内存占用也大。我做的这个面板在浏览器里就能打开没必要套一层桌面壳。如果你后续确实想打包成双击即用的桌面程序我建议看 Tauri 这类轻量方案这套前后端架构基本不用动。2.3 整体架构一次请求背后发生了什么整个系统分三个部分采集器、API 服务和前端面板。前端面板通过 HTTP 请求访问 API 服务API 服务背后是一组常驻运行的采集器每个采集器按各自的节奏去拉外部数据结果统一缓存在内存里并按周期落盘。一次请求的完整链路是这样的你在浏览器打开面板页面Vue 应用启动先请求/api/cards。API 服务收到请求后不再去实时调用外部数据源而是直接返回内存中最新一轮采集的结果。比如 GitHub 采集器每五分钟拉一次那在这五分钟内任何请求看到的都是最后一次拉取的快照。这样做的好处是前端永不被慢的外部接口拖累而所有外部 API 的调用频率也完全可控。这个设计其实参考了 CQRS 里“读写分离”的思路采集是写模型展示是读模型两边用内存缓存解耦。它让我在改采集逻辑时完全不用动前端改前端展示时也不影响采集节奏。3. 后端服务卡片上的数据是怎么被采集、缓存、吐给前端的3.1 采集器接口接入新数据源只花二十分钟的秘密后端代码我做得比较克制从头到尾核心接口只有一个type Collector interface { Name() string Fetch(ctx context.Context) (CardData, error) }就这么点东西采集器的全部协议就结束了。每个数据源实现一个Fetch方法方法里做三件事请求外部数据、解析成通用结构、返回结果。至于这个数据源是 GitHub 还是 RSS 还是服务器本机指标都被封装在实现里。CardData是前后端约定的通用卡片结构type CardData struct { ID string json:id Title string json:title Status string json:status // ok | warning | error Value string json:value // 主数值展示 Detail string json:detail // 辅助说明 Unit string json:unit // 单位 UpdatedAt int64 json:updated_at }这个结构是我反复琢磨后定下来的。Value放最核心的数字或短文本比如“42%”“正常”“3 个新提交”Detail放补充信息比如“最近提交fix: 修复登录超时”。前端渲染卡片时几乎不需要做任何逻辑判断直接填字段就行。你别小看这个接口它解决了整个项目最大的扩展性问题。后面新接入一个数据源就是新建一个文件、实现Fetch、在注册表里加一行。不夸张地说熟练的话二十分钟确实能搞定一个全新的数据源接入。3.2 调度器控制采集节奏才是关键有了采集器下一步就是调度器。调度器的核心职责是控制“每个数据源多久采集一次”这是整个项目里最容易出问题的地方。我的第一版策略非常简单每个采集器注册时带一个刷新间隔调度器用一个统一的时间轮来触发。伪逻辑大概是这样的for _, c : range collectors { go func(c Collector) { ticker : time.NewTicker(c.Interval()) defer ticker.Stop() for { select { case -ctx.Done(): return case -ticker.C: card, err : c.Fetch(ctx) if err ! nil { // 记录错误并生成 error 状态卡片 } memory.Store(c.Name(), card) } } }(c) }每个数据源开一个 goroutine互不阻塞这是 Go 最舒服的写法。但有几个关键点必须注意每个采集器都要有独立的context超时避免某次网络请求把协程卡死采集器内部要处理“上一次还没跑完下一次又触发了”的情况我用的方法是加一个sync.Mutex防止并发调用同一个外部接口。不同数据源的刷新间隔差异很大这点值得单独列个表给你参考数据源类型推荐刷新间隔原因本机 CPU / 内存5-10 秒属高频变化指标但没必要秒级刷新RSS 订阅5 分钟文章更新频率低实时性要求不高GitHub API5-10 分钟需严格遵守 API 限流内部 API 存活探测30 秒生产问题发现要快但别把服务打爆定时脚本运行状态1-2 分钟结合脚本自身执行周期设定3.3 存储方案第一版我连数据库都没用这个决定可能有人觉得奇怪后端服务难道不更应该上 SQLite 吗我第一版确实连数据库都没用只用了“内存 JSON 文件”的组合。原因很简单数据量太小了。全部卡片加起来也就几十 KB内存里放一个sync.Map就够API 读取是 O(1) 级别重启后需要恢复的也只有“上一次采集到的状态”一个 JSON 文件完美解决根本用不着引入数据库连接、迁移脚本、备份策略这一堆东西。落盘的时候要注意原子写先写临时文件再rename避免服务崩溃时把文件写坏tmp : file .tmp os.WriteFile(tmp, data, 0644) os.Rename(tmp, file)这个方案虽然简单但有一个隐含问题服务重启后会短暂地没有内存数据。我的处理是在启动时先把 JSON 文件读进内存并给卡片打上“数据时间已过期”的标记等到第一轮采集完成后再自动解除。这样前端在启动瞬间看到的是旧数据但界面上明确显示了“N 分钟前更新”不会造成“服务刚启动却显示正常”的错觉。3.4 对外API一个JSON约定全部前端只管渲染API 的设计直接决定了前端写起来有多舒服。我用了最朴素的 REST 风格一个/api/cards返回全部卡片一个/api/cards/{id}返回单张卡片一个/health用于探活。返回结构统一是{ code: 0, data: [...卡片数组...], ts: 1730000000 }code为 0 表示成功非 0 是错误码data是卡片数组ts是服务端当前时间戳。前端拿到这个结构唯一要做的事情就是把它映射到 Vue 的响应式数据里剩下的判断逻辑都在后端完成。这里有一个我花了不少时间才想明白的设计原则状态判断放在后端而不是前端。比如“CPU 使用率超过 80% 算警告超过 95% 算错误”这个阈值逻辑应该在后端算好前端只负责用颜色展示。原因很简单阈值可能会变如果写死在前端改一次阈值就要发一次前端放后端的话刷新页面就是新规则。4. 前端面板状态卡片从数据到渲染的完整链路4.1 卡片布局信息密度与眼动习惯前端这块不是随便拿个表格列数据就行的。状态面板的核心体验是“扫一眼就能知道有没有事”所以布局直接影响使用效率。我用的布局是 CSS Grid核心规则是.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }每张卡片的内容从上到下依次是标题和状态灯、核心数值、辅助说明、更新时间。这样设计的原因也很直接人的视线习惯先扫标题确认“这是哪项”然后看状态灯确认“它好不好”最后才落到具体数字。如果把数值放最上面反而会让人在扫读时被一堆数字干扰。在实际使用中我还有个体会一屏容纳 6 到 8 张卡片是最舒服的。卡片太多会让人失去“扫读”的效率变成逐张阅读那就背离了这个工具的定位。后面做扩展时我也刻意控制卡片总数优先保证一屏能看完。4.2 状态判定绿、黄、红是后端算好再交给前端的颜色编码这件事看起来简单但里面有不少细节。我定义了三种基础状态ok绿、warning黄、error红另外加了一个前端自己判断的“过期”状态灰。后端返回的status字段已经算好了比如内存采集器会这样判断if memoryUsedPercent 95 { status error } else if memoryUsedPercent 80 { status warning } else { status ok }前端拿到status后通过一个映射函数来设置颜色const statusColor { ok: #22c55e, warning: #f59e0b, error: #ef4444, }为什么要把阈值计算放后端除了刚才说的“改阈值不用发前端”之外还有一个理由是统一。如果前端同时处理后端数据和本地逻辑很容易出现“后端认为 OK、前端因为某个额外的本地判断显示红”这种不一致。所有业务规则都在后端算前端就是个纯粹的渲染器排查问题时边界非常清晰。4.3 拉取策略轮询间隔、超时与骨架屏前端数据拉取我用的是最朴素的 30 秒轮询没有上 WebSocket。原因很简单Status Deck 不是实时协作工具30 秒的延迟完全感知不到而 WebSocket 引入的连接管理、断线重连、心跳检测对第一版来说都是额外负担。轮询代码我用 Vue 3 组合式 API 封装了一下export function useCards() { const cards refCard[]([]) const loading ref(true) async function fetchCards() { const controller new AbortController() const timer setTimeout(() controller.abort(), 5000) try { const res await fetch(/api/cards, { signal: controller.signal }) const json await res.json() if (json.code 0) { cards.value json.data } } finally { clearTimeout(timer) loading.value false } } onMounted(() { fetchCards() setInterval(fetchCards, 30000) }) return { cards, loading, fetchCards } }这里有两个容易被忽略的点。第一是AbortController它用来给请求设置超时——如果后端 5 秒没响应就主动断开避免前端无限等待第二是组件卸载时一定要清掉setInterval否则页面切走再切回来会叠加定时器导致请求越来越频繁。这两个问题我在第一版都踩过印象很深。初次加载时我加了一层骨架屏用简单的灰色块模拟卡片布局避免用户打开页面先看到空白再突然跳出一堆内容。这个细节看着小但对使用感受提升非常明显。5. 第一版跑通后我在实测里踩过的几个坑5.1 第三方API限流采集频率不是你想定多少就定多少第一版刚跑通时我心情愉悦地给所有采集器设了很激进的刷新间隔GitHub 的 Actions 状态每 30 秒拉一次。结果不到一小时GitHub API 开始返回 403。查了一下才知道GitHub 未认证请求的限制是每小时 60 次我 30 秒拉一次很快就触顶了。解决方案有两个一是给请求加上Authorization: token ...头把限额提升到每小时 5000 次二是把刷新间隔调到 5 分钟以上。两个方案我都做了实际上即使有 token频繁调用也没有意义——Actions 状态五分钟内的变化概率很低。这个坑给了一个特别重要的经验每个数据源的刷新频率不能拍脑袋想多少就多少必须先查文档确认 API 限额。不同的数据源限流政策差异很大有的按小时算、有的按分钟算、有的按用户维度算一定要分清楚。5.2 服务宕机后的自恢复supervisor、systemd与开机自启自部署服务最尴尬的是你的监控服务本身挂了而你还不知道。我第一版是在一台 Linux 服务器上手动nohup跑的结果有一次服务器重启后Status Deck 就彻底没起来直到第二天打开网页发现打不开才察觉。后来我改成了 systemd 服务这是最省心的方案。配置文件非常简单[Unit] DescriptionStatus Deck Collector Afternetwork.target [Service] ExecStart/opt/statusdeck/statusdeck Restartalways RestartSec10 Userstatusdeck [Install] WantedBymulti-user.target关键就是Restartalways和RestartSec10进程崩溃或机器重启后都会自动拉起。如果你没有 systemd 环境用supervisor效果也差不多。但这里有个前提采集进程本身要能优雅退出。如果采集协程里有阻塞的 goroutine 而不响应 context 取消重启时会看到进程一直卡在那里所以写采集逻辑时一定要确保传进Fetch的ctx会被下层 HTTP 请求真正使用。5.3 时间戳失真过期的“正常”比报错更坑这是我在实际使用中最重视的一个问题没有之一。某个采集器凌晨 3 点挂掉了它存储的最后状态是“正常”。第二天早上你打开面板看到这张卡片是绿色整个人心情很好但实际上它已经五个小时没更新了。这种“过期的正常”比一个红色错误更危险因为它会给你虚假的安全感。我的解决办法是双管齐下。后端在每次返回卡片时实时计算并写入updated_at字段前端拿到这个时间戳后根据卡片的刷新周期判断“超过多久算过期”过期后把卡片整体置灰并在状态灯位置显示一个虚线圈。具体来说每张卡片在注册时可以带一个maxStaleDuration比如 CPU 卡片超过 1 分钟没更新就算过期GitHub 卡片超过 15 分钟没更新才算过期。前端统一按这个字段判断function isStale(card: Card): boolean { const age Date.now() / 1000 - card.updated_at return age card.max_stale_duration }这个机制成了整个面板可信度的基石。从那以后我看到绿色卡片时才真正放心因为我知道任何异常中断都会让卡片变灰而不是永远停留在最后的“正常”状态。6. 已经想好的下一步多端复用与AI摘要6.1 Vue代码怎么迁移到uni-app第一版跑通后我最大的感受是想在手机上也能看状态——有时候人在外面就想快速确认一下服务器还在不在。要重写一套小程序原生代码自然是不可能的幸好当时 Vue 3 的组合式 API 给多端复用留好了路。我们把useCards这个组合式函数抽出来它的核心逻辑只有“请求 API、解析 JSON、更新响应式数据”这部分在 uni-app 里可以原样使用。需要改动的只有请求层把fetch换成 uni-app 提供的uni.request。理论上 UI 层需要换掉但好在卡片组件本身不复杂每个卡片就是几个text和一段样式迁移成本并不高。这个方向我计划放在系列后面几篇里展开。核心思路就是先保住 API 不动、保住采集端不动前端单独立一个多端适配层。6.2 用AI把“状态列表”压缩成“一句话日报”多张卡片都正常时基本不用看细节只有出现异常才值得关注。我在想能不能让面板每天早晨生成一句话状态摘要比如“今早 9 点前所有服务正常唯一的警告来自 RSS 数据源最近 3 小时未更新”这样连打开面板都省了。实现思路是在后端加一个摘要生成模块把缓存中的卡片状态、最近一次异常记录、持续时间拼成一段上下文交给大模型生成自然语言描述。但这里有一个很重要的问题隐私和网络边界。如果采集数据里有内部服务信息就不能往外部 AI 接口发。我的处理是先把 AI 能力做成可开关的模块默认关闭同时设计一套模板规则作为兜底方案不依赖外部 API 也能生成“服务状态正常、1 个警告、0 个错误”之类的摘要。更细的想法是让 AI 做两件事一是归纳当天的异常状态变化二是根据历史记录给建议比如“CPU 连续三天在上午 10 点出现峰值建议检查定时任务”。这只是方向还没动手做留到后面版本再说。6.3 第二版的功能边界我给自己列了一些第二版想做的事也明确列了几个坚决不做的功能防止范围失控。想做的有告警通知通过钉钉或邮件只在状态翻转时发送、卡片拖拽排序和自定义布局、历史状态的时间轴记录、通过 Tauri 打包成真正的桌面程序。不做的包括复杂趋势图表、多用户权限系统、移动端原生 App、面向公网的部署和鉴权。保持“小而完整的个人工具”这个定位不变是我对这个项目最重要的约束。这个项目做到第一版我最大的感受是造工具最难的往往不是写代码而是反复确认“‘够用’到底长什么样”。一版做完自己每天打开面板扫一眼比用什么高级监控面板都舒服。后面第二篇我打算深入写采集器的设计和多端适配也会把告警通知这块补上。如果你也想搭一个自己的状态面板按这个骨架走应该能少走不少弯路。
