ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

自造全栈Status Deck:Vue3+Golang+Electron桌面状态仪表盘实战

自造全栈Status Deck:Vue3+Golang+Electron桌面状态仪表盘实战 这题我太有发言权了。作为一个每天要开十来个网页、盯五六个后台的开发者我一直觉得工作台这个东西要么太重像监控大屏要么太散收藏夹里一堆链接。所以当我想把构建状态、服务健康、线上日志、待办提醒全部收进一个桌面小窗的时候决定不将就了直接自己造一个。这篇文章就是“全栈自造Status Deck”系列的第一篇。这项目不止是个仪表盘更是一次完整的全栈落地Vue 3 做界面Golang 做聚合服务SQLite 管数据再套一层桌面壳。全程没有用现成的开源仪表盘框架所有卡片、刷新策略、告警规则全是从零开始搭的。适合谁看想练全栈但不想再做“增删改查” demo 的人想给自己的开发流加点自动化的人以及单纯对“怎么把一个桌面应用完整落地”感兴趣的人。1. 整体思路与其找现成的不如自己攒一个1.1 为什么非要“自造”而不是用现成的仪表盘市面上的仪表盘工具比如 Grafana、Datadog、具体的 CI 监控插件它们都很强但问题在于它们太重也太“平台化”。Grafana 你要先配数据源再写 PromQL再设计 Dashboard 布局一套下来两个小时没了。我就想在桌面角落放一个“开发者状态卡片”能一眼看到我的 GitHub Actions 构建是绿是红服务端接口成功率有没有掉线上日志有没有出现 ERROR 级别的新条目今天有没有快到期的待办这些信息聚合在一起的形态本质上不是一个“监控平台”而是一个“个人工作台”。它的特点是数据源杂、数量少、更新要求不高秒级够用、UI 很个人化。用 Grafana 来干这事就是杀鸡用牛刀而且牛刀还不太听我的话——我想让某张卡片在失败时整块变红在 Grafana 里要写一堆告警规则而在自己项目里这就是一个v-if的事。所以核心思路很明确服务端做聚合适配前端做轻量展示桌面端只做壳。这意味着就算哪天我不想要桌面端了直接把前端跑在浏览器里后端接口原样能用整套东西没有一处被壳绑死。1.2 目标形态和使用场景我这套 Status Deck 第一版的目标形态是这样的一个约 420x320 的桌面小窗类似系统小组件内部纵向排列卡片每张卡片代表一个数据源卡片右上角有状态点绿/黄/红点开卡片能看到最近 20 条历史记录整体支持“聚焦模式”——无操作 30 秒后自动降低透明度使用场景分两类。工作时我把它放在副屏右下角构建变红、日志有错误它马上变颜色提醒摸鱼时它自动变成半透明的不挡视线。这两类场景决定了它的技术取舍它不能是一个重型的 Web 应用交互要轻但底层数据获取得稳。2. 技术选型全栈不是堆技术是选合适的技术2.1 前端Vue 3 TypeScript 而不是 React前端选 Vue 3 的原因不是 Vue 比 React 好而是对于这种“卡片多状态自动切换”的界面Vue 的响应式开发方式更顺。具体来说卡片的状态由后端推送的数据决定前端要做的就是把数据映射到颜色和文字上。Vue 的computed天然适合做这种派生状态const statusColor computed(() { if (latestError.value) return red if (latency.value 800) return yellow return green })不用手动去管 DOM 的 class 切换数据一变页面自动跟着变。这东西用 React 也能写但 JSX 里写这种条件 class 会显得啰嗦相比之下 Vue 模板里三行搞定。组件结构上我拆了三层DeckContainer最外层容器负责卡片布局和拖拽排序StatusCard通用卡片组件接收 dataSourceId 和 renderTypeMetricBlock指标块负责单条指标的数字和颜色展示这种拆法是为了后续加新卡片时不用改老组件只要新增一个数据源注册进去就行。2.2 后端Golang 做聚合服务后端用 Golang核心原因是并发模型和部署体积。Status Deck 需要同时维护多个数据源的连接GitHub API、自建服务的健康检查、日志文件流、数据库查询。每个源都有独立的刷新节奏有的十秒一次有的一分钟一次。用 Go 的 goroutine 来管理这些周期任务非常顺手每个数据源就是个独立的 goroutine 循环互不干扰func (s *SourceManager) Run(ctx context.Context) { for _, source : range s.sources { go s.loop(ctx, source) } } func (s *SourceManager) loop(ctx context.Context, source DataSource) { ticker : time.NewTicker(source.GetInterval()) defer ticker.Stop() for { select { case -ctx.Done(): return case -ticker.C: data : source.Fetch() s.bus.Publish(source.ID(), data) } } }几个数据源同时跑互不阻塞。谁慢了也不会拖累别人因为每个 loop 是独立的。如果用 Node.js 写这块也不是不行但事件循环里塞多个周期任务一旦某个任务里面有同步阻塞整个服务就抖一下。Go 的 goroutine 至少从模型上规避了这个坑。部署体积上Go 编译出来就一个二进制十几兆塞到桌面壳的子进程里占用很低。相比之下Java 要来一套 JREPython 要带解释器环境。桌面应用场景下这个体积差异体感非常明显。2.3 桌面壳Electron vs Tauri 的取舍桌面壳我很认真地对比过 Electron 和 Tauri。Electron 生态成熟遇到问题好查资料但内存占用是真的高——一个空窗口开起来就吃掉 150MB 内存加上我们的 Vue 页面和后端服务整体奔着 400MB 去了。Tauri 的优势是轻但问题在于Tauri 的 sidecar 行为在 Windows 上的表现让我心里没底。跨平台需要我同时编译 Go 的多个目标平台二进制并在配置里做好路径映射太折腾了。再加上内嵌的 WebView 在某些 Linux 发行版上的渲染兼容性差异我需要花额外的时间去排查和修复。所以最后选了 Electron但做了一层优化主进程只跑 Golang 服务的生命周期管理渲染进程只负责前端页面。把 Node 层做薄内存问题控制在了可接受的范围。实际跑起来整套应用从启动到窗口出来大约 1.5 秒内存稳定在 220MB 左右对于开发工具来说是个能接受的数值。如果你只是做浏览器版本这部分可以完全跳过。但你的目标是“桌面仪表盘”那这一层确实得认真选一遍。2.4 数据存储SQLite 做时间序列迷你库状态类数据需要存历史记录至少得留存最近 7 天的数据。这个量级用 MySQL 是重了直接内存存储又怕程序重启丢数据。我选了 SQLite理由简单单文件、零配置、读写够快。建表结构的时候没有用普通行存而是做了个迷你时间序列结构CREATE TABLE metric_samples ( source_id TEXT NOT NULL, metric_key TEXT NOT NULL, metric_value REAL, status TEXT NOT NULL, sampled_at INTEGER NOT NULL ); CREATE INDEX idx_samples_source_time ON metric_samples(source_id, sampled_at DESC);写入时按固定间隔采样每次写入前先删掉超出保留窗口的旧数据。这样数据量永远控制在一万条以内查询永远走索引响应基本在几毫秒内。2.5 关于 AI 能力的预留热词里总提 AI 全栈实际做的时候也留了 AI 的接口位置。但这个“AI”不是硬凑的我是让日志卡片在连续出现同一报错时自动调一次摘要服务把报错信息归纳成一句人话放到卡片下面。这功能在架构上就是多一个 dataSource前端完全无感知。以后要扩展也只需要在新数据源里实现Fetch()和Summarize()两个方法。3. 核心功能模块这块是整个项目的灵魂3.1 数据源接入层统一接口才是硬道理数据源是 Status Deck 的心脏架构上我给它抽象了一个统一的接口。每一种数据接入都要实现这么几个方法type DataSource interface { GetID() string GetInterval() time.Duration Fetch() SourceResult }返回值也统一收敛方便前端消费type SourceResult struct { SourceID string json:sourceId Status string json:status // ok | warn | error Metrics map[string]any json:metrics Message string json:message,omitempty SampledAt int64 json:sampledAt }这样做的好处是我加新数据源时不用动前端的渲染逻辑。比如加一个“数据库慢查询”的数据源后端只要实现一个新的结构体去调SHOW FULL PROCESSLIST前端那边注册一张新卡片、指定指标展示方式就行。整套体系是“后端增长前端不动”对扩展非常友好。实际实现的时候我做了三个典型数据源GitHub Actions 数据源调用 GitHub API拉取当前仓库的最新 workflow run 状态每 30 秒同步一次HTTP 健康检查对指定接口做 HEAD 请求记录响应时长和状态码每 10 秒探一次日志扫描本地开发服务日志文件实时监听新行发现 ERROR 级别就切换状态每个数据源独立运行各自维护状态互不干扰。这就是“聚合层”的核心价值。3.2 卡片渲染引擎状态驱动 UI卡片渲染的核心逻辑是“状态优先”。前端拿到的数据源结果里最关键的是那个status字段。渲染引擎会根据状态去决定卡片的颜色、图标和文字提示。这块我封装成一张卡片一个状态对象interface CardViewState { dataSourceId: string status: ok | warn | error title: string metrics: MetricDisplay[] lastUpdated: number }MetricDisplay负责把不同数据源的指标统一成卡片上可展示的格式interface MetricDisplay { label: string value: string unit?: string };比如 GitHub Actions 数据源返回的指标里包含workflowName和conclusion字段会被映射成label: workflow、value: ci-main这样的显示结构。这种映射逻辑放在前端因为后端只管给原始数据怎么展示是前端的事。3.3 状态聚合与刷新策略别把接口请求爆掉每个数据源都有自己的刷新节奏。GitHub API 有速率限制健康检查又需要高频探活日志扫描是事件驱动。这就需要统一调度策略。我设计了一个调度器它会根据每个数据源的GetInterval()来决定下一次拉取时间并且做了并发兜底同一个数据源上一次拉取还没结束下一次拉取要跳过避免积压单次拉取超过 5 秒就标记超时返回上一次的缓存结果连续三次失败数据源状态变为error卡片直接变红这些规则看着简单但实际跑起来帮我把很多隐性问题兜住了。尤其是 GitHub API偶尔会瞬时限流如果没有跳过机制那段时间卡片会一直转圈体验很糟。3.4 通知与告警状态变化的有效触达仪表盘不是看一眼就行状态变了得“主动发声”。第一版做了两个通知动作桌面通知通过 Electron 的 Notification 模块在卡片状态从ok变成error时弹一条系统通知声音提醒状态变化时播放一个很短的提示音注意不能做成闹钟式骚扰音量控制在 40% 以下告警规则用简单阈值实现不引规则引擎。比如HTTP 健康检查响应延迟超过 800ms 算warn超过 2s 算error日志数据源出现一个ERROR算warn同一分钟内出现 5 个以上算error这个“同一分钟内出现 5 个以上”的规则是被一次线上事故逼出来的。当时日志刷了 20 多条 ERROR其实是一些老的测试报错被触发并不是服务挂了。如果每一条 ERROR 都直接置红那告警就没意义了。所以现在的策略是频率比单条严重级别更能体现服务的真实状态。4. 实操记录搭建一个可运行的完整版本4.1 项目初始化与目录结构先从工程结构说起。项目整体是 mono-repo前后端和桌面壳都放同一个仓库里status-deck/ ├── frontend/ # Vue 3 TypeScript Vite ├── backend/ # Golang 聚合服务 ├── desktop/ # Electron 壳 ├── internal/ # 共享类型和工具 └── scripts/ # 一键启动/打包脚本初始化后端用的是 Go modules前端用的 Vite。分开初始化的原因是两者开发时监听的端口不同联调时只需要在 Vite 配置里配一下代理server: { proxy: { /api: http://localhost:8900 } }4.2 后端用 Go 写一个轻量 HTTP API后端对外只需要暴露两个接口GET /api/sources—— 返回所有数据源的当前状态GET /api/history?sourceIdxxx—— 返回某个数据源最近的历史记录另外加一个 WebSocket 接口推送状态变化。第一版我用的是 SSEServer-Sent Events因为它比 WebSocket 轻而且是单向推送足够满足“服务端到前端”的状态流func (s *Server) StreamStatus(w http.ResponseWriter, r *http.Request) { flusher, _ : w.(http.Flusher) w.Header().Set(Content-Type, text/event-stream) w.Header().Set(Cache-Control, no-cache) for { select { case -r.Context().Done(): return case data : -s.bus.Subscribe(): fmt.Fprintf(w, data: %s\n\n, string(data)) flusher.Flush() } } }前端那边用原生EventSource就能接收不需要引额外的库。这又是选型时省掉的一个依赖。关于事件总线建议用一个 channel 的 fan-out 模型。核心代码很短但是整个后端数据流的枢纽。没有它各个数据源之间没法解耦。4.3 前端构建卡片式布局前端的核心页面是一块卡片墙。用 CSS Grid 做布局卡片尺寸自适应。Grid 的好处是改列数只需要一个媒体查询.deck-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }窗口宽度变化时卡片数量自动增减很省心。每张卡片的内部结构很简单——标题栏、状态点、指标区。状态点用一个 8px 的圆点实现颜色由statusColor计算属性决定。Footer 区域展示“最后同步时间”对排查问题很有帮助。如果某张卡片的数据一直是旧的看最后同步时间就能判断是针对源出问题还是整个服务卡住了。4.4 联调过程中的沟通协议联调的时候会遇到一个比较现实的问题后端的数据格式和前端期待的数据格式经常对不上。第一期我踩过这个坑后来统一在internal目录下放了一份 TypeScript 类型定义Go 端生成 JSON 时按这个结构序列化前端拿到不用再做二次转换。type MetricValue number | string | boolean export interface SourceResult { sourceId: string status: ok | warn | error metrics: Recordstring, MetricValue message?: string sampledAt: number }这份类型定义就是前后端之间的“契约”。谁改了字段跑一遍go test或者vue-tsc马上就能发现哪里不匹配。这就是全栈项目的优势——契约由自己来定但也要用工具保证契约的合法性。4.5 桌面壳Electron 集成Electron 这边主要负责两件事拉起 Go 服务进程和承载前端页面。核心代码在主进程里const backend spawn(path.join(process.resourcesPath, backend), [], { detached: false, stdio: ignore }) app.on(quit, () { backend.kill() })这里有个细节打包后的process.resourcesPath和开发时的路径不一样需要注意做个兼容。开发环境直接从backend/bin/目录启动二进制生产环境从resources目录启动。我写了个工具函数去判断当前环境方便联调。窗口配置方面我固定成 420x360不可最大化但允许缩放。窗口透明度的自动调节用了 Electron 的setOpacity方法30 秒无交互就降到 0.6鼠标移入就恢复。5. 常见问题与排查技巧实录5.1 后端服务无响应可能是锁问题一个比较典型的问题是 SQLite 并发读写冲突。数据源在后台 goroutine 里不断写采样数据而前端的请求触发了读。SQLite 默认在并发写时会报database is locked。解决方案很简单开启 WAL 模式同时把写操作放到单独的连接里。db, _ : sql.Open(sqlite3, statusdeck.db) db.Exec(PRAGMA journal_modeWAL)开了 WAL 之后读写可以并发进行一个应用写数据库不会阻塞其他应用的读。这个改动解决了 90% 的无响应问题。5.2 Electron 打包后路径找不到前面提到的process.resourcesPath的坑在打包后才暴露。开发环境跑得好好的一打包就提示找不到后端二进制。排查方向是先console.log打出实际路径确认资源是否被打包进去。Electron Builder 的extraResources配置是常见的不生效位置extraResources: [ { from: backend/bin/backend, to: backend } ]注意这里的from是相对项目根目录to是相对于resources目录。打包后检查一下resources/backend路径是否存在基本能定位问题。5.3 卡片更新时的闪烁问题刚开始卡片每次收到 SSE 推送就整个重新渲染导致页面一闪一闪的。后来发现 Vue 的 diff 算法并没有帮到点子上——因为我在模板里绑定了整个sourceResult对象。改成绑定具体字段后更新就平滑了StatusCard :statuscurrentStatus :metricscurrentMetrics :last-updatedlastUpdated /这样只有当这些具体字段变化时组件才重新渲染。这也是 Vue 性能优化里很基础的一条拆细粒度绑定避免父组件一变动导致子组件全都刷新。5.4 常见问题速查表现象大概率原因排查方向卡片一直是灰的后端服务没启动检查backend进程是否存在访问 8900 端口GitHub 卡片频繁红色API 限流查看X-RateLimit-Remaining响应头日志卡片不更新日志文件路径变化检查文件是否被分割轮转路径是否需要通配符窗口无法拖动开启了自定义标题栏检查-webkit-app-region: drag属性内存占用缓慢上涨历史记录积压太多查看 SQLite 的表大小执行清理这张表是我在开发过程中遇到最多的五个问题的汇总也是第一版用户反馈的高频列表。6. 进一步扩展从“能用”到“好用”第一版跑通以后可扩展的方向其实非常多。我目前规划了三个方向后续文章会一个个落地第一个是模板市场。现在的卡片是写死在前端代码里的后续准备做成配置化——用户通过 JSON 配置定义一张卡片包含数据源、展示类型、刷新周期、告警阈值不用改代码就能加新卡片。做的话数据结构已经预留了只差一套配置解析和渲染适配层。第二个是多端联动。热词里的 uniapp 确实是个方向。桌面端的数据通过后端 API 暴露移动端做一个只读的简化版在地铁上瞄一眼服务状态不需要把所有功能都搬过去。前提是后端接口要保证幂等和无状态这一点现在的设计已经支持。第三个是AI 摘要能力的深化。当前日志卡片只做错误摘要后续可以做成每日报告——自动汇总当天所有数据源的关键变化、趋势和异常早上打开电脑时生成一条简报。这个比简单地堆仪表盘更有用也更符合“个人工作台”的定位。拿我实际跑下来的体验来说这个 Status Deck 最大的价值不是技术含量多高而是让我养成了“扫一眼就知道今天状态”的习惯。以前要开五个页面逐个看现在开机后右下角扫一眼全绿安心干活有红的先处理再写代码。这种确定的掌控感比任何花哨功能都值钱。如果你也想自己造一个我的建议是别一上来就搞全套先把一个数据源比如就一个 HTTP 健康检查从后端到前端到桌面端跑通感受一下整个链路然后再加第二个、第三个。全栈的乐趣就在这个“从一个点打通整条线”的过程里。
返回列表