ARTICLE DETAIL

资讯详情

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

手写状态面板技术栈选型:SSE与SQLite构建全栈监控项目

手写状态面板技术栈选型:SSE与SQLite构建全栈监控项目 如果你的工作台上已经摆过三块不同型号的屏幕还坚持要在其中一块上常年挂着一个自制的状态面板那你大概能理解我为什么放着现成的监控工具不用非要自己从零造一个Status Deck。上一篇文章里我把自己想要的面板形态盘清楚了一张大屏能同时看到服务器健康、定时任务执行情况、几个外部API的可用性还有手头两个项目的进度指标。这篇接着往下写重点放在两件事——技术栈怎么选项目怎么落地实施。先说结论这是一个典型的个人全栈项目前端、后端、数据库、部署全都由一个人负责。它最大的价值不在于功能多复杂而在于从需求到上线整条链路都握在自己手里。文章会把我选型时的判断依据、数据模型的设计过程、核心代码的写法和踩过的坑全部摊开讲适合正在规划类似状态面板、或者想找一个能独立完成的练手全栈项目的朋友参考。1. 技术栈选型先想清楚这玩意儿谁来维护1.1 选型前必须回答的三个问题任何技术选型本质上是拿约束条件去换取舍。对于一个自己造的Status Deck约束条件非常明确它是一个长期个人维护的项目一个晚上能改完一个页面数据量撑死几十万条部署在一台小主机上。基于这些约束我给自己立了三条规矩。第一不引入需要额外人力维护的中间件。消息队列、独立的缓存服务、Kubernetes这些一概不碰。项目就一个人维护出问题要有能力在半个小时内恢复所以中间件越少越好。这也是我把数据库定位为SQLite而不是PostgreSQL的根本原因——不是PostgreSQL不好而是这个量级的数据根本用不上它多一个数据库服务就多一个要照顾的进程。第二实时性需求决定通信协议而不是反过来被流行度绑架。Status Deck的核心场景是服务端状态变了页面要尽快刷新这是典型的单向数据流由服务端往客户端推。我最终选了SSEServer-Sent Events而不是WebSocket不是因为WebSocket不行而是SSE在这个场景下简单得过分一个普通的HTTP响应Content-Type设为text/event-stream客户端用EventSource就能收自带断线重连不需要处理握手协议也不需要自定义心跳包。第三数据量先估再选不为了以后可能用得上提前上重量级。按照每5分钟记录一个指标快照、保留90天来算每天288条记录90天不到3万条。即使把粒度调到30秒一年也就100万条。SQLite对这种体量完全绰绰有余一个文件搞定存储和备份恢复的时候直接把文件拷回去就行。1.2 最终选型清单与理由层级技术选择理由前端框架Vue 3 Vite TypeScript组合式API写起来清爽Vite冷启动快适合单人高频迭代状态管理Pinia轻量配合TypeScript类型推导很舒服样式方案Tailwind CSS原子类写法适合快速改版暗色主题切换零成本图表ECharts覆盖仪表盘需要的所有图表类型暗色主题完善后端Node.js 20 Express TypeScript与前端统一语言共享类型定义避免双语言心智负担数据库SQLitebetter-sqlite3零运维单文件备份读写性能对个人项目绰绰有余实时推送SSE单向推送足够实现成本远低于WebSocket定时任务node-croncron表达式简单直接挂到进程里部署Docker Compose Nginx一条命令起服务Nginx统一处理反向代理与静态资源这个清单看着平平无奇但每一项都是掂量过实际场景后才定的。比如前端我没选React不是React不好而是Vue 3的组合式API加Vite的开发体验对单人全栈项目来说效率更高写起来心智负担更小。全栈项目有一个核心原则能少一个需要维护的知识体系就少一个。1.3 为什么不用Python、Go或Electron有朋友问我既然有数据采集和定时任务为什么不用Python FastAPI或者直接用Go写个单体服务我的回答是这个项目的核心价值在于全栈统一。前端是TypeScript后端也是TypeScriptAPI的请求响应类型可以放在一个shared包里共享。改一个字段编译期就能发现前后端两侧的报错。如果用Python就得维护两套类型定义、两份文档单人项目维护成本直接翻倍。Go的性能确实好但Status Deck每天的请求量屈指可数性能完全不是瓶颈开发速度和类型统一反而是更重要的。也有人建议直接做成Electron桌面应用把面板跑到本机上。这个想法不是不行但Status Deck的定位是随时可看的网页我要在手机上、平板上、公司电脑上都能打开同一个地址。Electron做出来的东西只能待在某一台机器的桌面上跟随时可看这个需求是冲突的。所以最终形态锁定为Web应用容器部署浏览器访问。2. 项目结构与数据模型设计2.1 Monorepo目录规划项目采用pnpm workspace的Monorepo结构分三个包。这个结构从Part 1的需求梳理阶段就开始规划实施阶段几乎没有调整说明前期的设计花时间是完全值得的status-deck/ ├── packages/ │ ├── shared/ # 前后端共享的TS类型与常量 │ │ └── src/ │ │ ├── types.ts │ │ └── constants.ts │ ├── server/ # 后端服务 │ │ └── src/ │ │ ├── index.ts │ │ ├── routes/ │ │ ├── collectors/ # 各数据源采集器 │ │ ├── store/ # 数据访问层 │ │ └── sse.ts │ └── web/ # 前端应用 │ └── src/ │ ├── components/ │ ├── stores/ │ └── views/ ├── docker-compose.yml ├── nginx.conf └── package.jsonMonorepo的好处在于前后端依赖同一个shared包类型定义只写一份。pnpm的workspace机制让本地开发时改shared里的类型立刻生效不需要单独发布npm包。单人项目用这种方式管理代码比拆成两个独立仓库要顺手得多。2.2 数据模型四张表解决所有需求数据库设计一开始总想着多建几张表最后沉淀下来是四张每张都对应Part 1中的一个核心需求。直接上建表SQL-- 卡片配置前端渲染的每一块卡片都由这张表驱动 CREATE TABLE deck_cards ( id TEXT PRIMARY KEY, title TEXT NOT NULL, type TEXT NOT NULL, -- gauge | chart | sparkline | progress | text span INTEGER NOT NULL DEFAULT 1, -- 卡片在网格中占的列数 order_index INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, config TEXT NOT NULL -- JSON字符串存放该卡片的具体展示参数 ); -- 指标快照定时任务产生的历史数据 CREATE TABLE metric_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_id TEXT NOT NULL, collected_at TEXT NOT NULL, -- ISO 8601统一存UTC value REAL NOT NULL, extra TEXT -- JSON字符串存放标签、详情等附加信息 ); CREATE INDEX idx_snapshots_card_time ON metric_snapshots(card_id, collected_at); -- 故障记录状态异常时自动写入用于事后回顾 CREATE TABLE incident_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, -- 故障来源如 http-check | ping target TEXT NOT NULL, -- 目标标识 started_at TEXT NOT NULL, ended_at TEXT, summary TEXT NOT NULL ); -- 数据源配置管理外部API的token、地址等 CREATE TABLE integrations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, -- github | custom-api | system config TEXT NOT NULL );这里踩过一个小坑指标快照的时间字段一开始存的是本地时间后来对比跨时区数据时发现完全没法看。所有时间后来统一改成UTC存储由前端负责转换成本地时间展示。这是个特别基础但特别重要的决定如果一开始没想清楚后续所有的统计查询都会被时区问题折磨。2.3 API契约与共享类型API设计上我坚持所有对外接口都走JSON所有类型定义都放在shared包里。核心接口长这样GET /api/deck 获取全部卡片配置与最新指标 GET /api/metrics/:cardId 获取单张卡片的历史指标默认最近24小时 GET /api/health 返回服务自身健康状态 POST /api/deck/refresh 手动触发全量采集 SSE /api/stream 实时推送指标更新与状态变更shared包里的核心类型大概这个感觉// packages/shared/src/types.ts export type CardConfig { id: string; title: string; type: gauge | chart | sparkline | progress | text; span: number; config: Recordstring, unknown; }; export type MetricSnapshot { id: number; cardId: string; collectedAt: string; value: number; extra?: Recordstring, unknown; }; export type LiveUpdate | { kind: snapshot; cardId: string; snapshot: MetricSnapshot } | { kind: incident; incident: IncidentLog } | { kind: deck-refresh; cards: CardConfig[] };前后端都从同一个types.ts里import类型接口一改编译期两边同时报错。这个问题我在做很多项目时都深有体会——后端改了字段前端不知道几乎就是全栈项目最常见的翻车点共享类型直接从根源上解决掉。3. 前端实现卡片渲染与实时数据流3.1 配置驱动的卡片渲染前端最核心的思路是配置驱动渲染。每一张卡片都是一个StatusCard组件但具体渲染成什么样的内容完全由后端返回的config字段决定。这样做的好处非常直接以后想加一张新卡片大多数情况下不用改前端代码只要在数据库里insert一条记录前端下次拉取deck配置就能自动渲染出来。!-- packages/web/src/components/StatusCard.vue -- script setup langts import { defineProps, computed } from vue; import type { CardConfig } from status-deck/shared; const props defineProps{ card: CardConfig }(); const latestValue computed(() props.card.config.latest ?? null); /script template div classrounded-lg border border-slate-700 bg-slate-800 p-4 h3 classtext-sm font-medium text-slate-400{{ card.title }}/h3 div classmt-2 text-2xl font-semibold text-slate-100 {{ latestValue }} /div /div /template实际渲染时会根据card.type分发到GaugeCard、LineChartCard等具体子组件每个子组件只负责一种视觉形式。这个模式在维护期极其舒服页面结构被完全扁平化加一个widget类型就等于加一个文件夹不用动其他任何代码。配合span字段控制卡片在网格布局中占的列数前端用CSS Grid排布天然支持响应式手机上看再多的卡片也会自动折行。3.2 SSE实时链路的具体实现实时链路是Status Deck最关键的工程点。我的实现分三部分服务端产生推送事件、前端监听事件、断线重连处理。服务端在Express里加一条普通的GET路由把响应对象挂到连接列表里由采集任务在数据更新后主动调用推送函数。核心逻辑大致如下// packages/server/src/sse.ts import { Router, Response } from express; const clients new SetResponse(); export const sseRouter Router(); sseRouter.get(/stream, (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.setHeader(X-Accel-Buffering, no); // 关键告诉Nginx不要缓冲 res.write(event: connected\ndata: ok\n\n); clients.add(res); req.on(close, () { clients.delete(res); }); }); export function broadcast(event: string, data: unknown) { const payload event: ${event}\ndata: ${JSON.stringify(data)}\n\n; for (const client of clients) { client.write(payload); } }后端数据采集完成之后调用broadcast(snapshot, payload)前端就能即时收到。前端用EventSource连接注意要给每个事件类型单独注册监听// packages/web/src/stores/live.ts import { defineStore } from pinia; import type { LiveUpdate } from status-deck/shared; export const useLiveStore defineStore(live, () { const connected ref(false); let es: EventSource | null null; function connect() { if (es) { es.close(); } es new EventSource(/api/stream); es.addEventListener(snapshot, (e) { const data JSON.parse(e.data) as LiveUpdate; // 更新对应的卡片数据 }); es.addEventListener(connected, () { connected.value true; }); es.onerror () { connected.value false; // EventSource在断线后会自动重连无需手动处理 }; } return { connected, connect }; });EventSource有一个很省心的特性连接断开之后会强制自动重连而且服务端收到新事件后会立刻推给重新连接的客户端。这个免费的重连机制是我选择SSE的另一个重要理由。如果换成WebSocket断线重连、心跳保活、消息序号对齐这些细节全得自己实现。3.3 ECharts的暗色主题与内存管理图表是状态面板的重头戏。我用ECharts做折线图和仪表盘但这里有一个个人项目里特别容易忽略的坑ECharts实例不销毁会内存泄漏。在Vue组件里使用ECharts时一定要在onUnmounted中调用chart.dispose()而且每次setOption之前要确认图表实例没有重复创建。script setup langts import * as echarts from echarts; import { onMounted, onUnmounted, ref, watch } from vue; const el refHTMLDivElement(); let chart: echarts.ECharts | null null; onMounted(() { chart echarts.init(el.value!, dark); // ... 初次渲染 }); watch(data, () { chart?.setOption({ series: [{ data: data.value }] }); }); onUnmounted(() { chart?.dispose(); // 不写这行页面长时间挂着就会卡顿 chart null; }); /script暗色主题直接用ECharts内置的dark主题配合Tailwind的暗色调色板视觉上基本不用额外调。另一个细节是图表容器大小变化时需要监听ResizeObserver并调用chart.resize()否则在调整卡片尺寸后会出现图表空白或拉伸变形。我把这个也封装成了一个可复用的composable所有图表组件共用。4. 后端服务与数据采集4.1 定时任务与采集器设计后端最有意思的部分是数据采集。我把每种数据来源抽象成一个Collector接口每个Collector负责一种数据源的采集然后由调度器统一按cron表达式触发// packages/server/src/collectors/types.ts export interface Collector { readonly id: string; // 唯一标识 readonly cron: string; // 采集周期 collect(): PromiseCollectorResult[]; } export interface CollectorResult { cardId: string; value: number; extra?: Recordstring, unknown; }当前实现了四类采集器采集器采集内容周期HttpProbeCollector对目标URL发起HEAD请求记录响应状态与响应时间30秒PingCollector对指定IP或域名做ICMP探测记录丢包率与延迟60秒GithubCollector拉取指定代码仓库的PR数量、issue数量、近期提交数5分钟SystemCollector读取宿主机CPU、内存、磁盘使用率60秒每个采集器独立实现互不影响。某个采集器挂了只会记录错误日志不会拖垮整个服务。这个采集器隔离的设计在实施中帮了我大忙因为最初我把所有采集逻辑塞在一个函数里后来某个外部接口超时导致定时任务阻塞连带着其他指标全部停更才让我下决心拆成现在的结构。4.2 HTTP探测采集器的实现细节以HTTP探测为例采集逻辑用fetch发起请求设置超时时间记录响应时间和状态码然后把结果写入metric_snapshots表。这里有两个细节值得展开。第一个是超时控制。fetch的AbortSignal.timeout(5000)可以方便地设置请求超时但要注意在超时时catch住AbortError把它当成一次目标不可用的记录而不是让异常向上抛// packages/server/src/collectors/http-probe.ts export class HttpProbeCollector implements Collector { readonly id http-probe; readonly cron */30 * * * * *; constructor(private targets: { cardId: string; url: string }[]) {} async collect(): PromiseCollectorResult[] { const results: CollectorResult[] []; for (const target of this.targets) { const startedAt Date.now(); try { const res await fetch(target.url, { method: HEAD, signal: AbortSignal.timeout(5000), }); results.push({ cardId: target.cardId, value: res.ok ? 1 : 0, extra: { status: res.status, latencyMs: Date.now() - startedAt, }, }); } catch (err) { results.push({ cardId: target.cardId, value: 0, extra: { status: -1, latencyMs: Date.now() - startedAt }, }); } } return results; } }第二个细节是故障记录的触发条件。采集器发现目标连续3次探测都失败时写入一条incident_logs记录。这样事后回顾这周哪段时间服务不可用时不用再去翻原始指标数据。这个设计比在面板上单纯显示红绿状态有价值得多——状态会过去但故障记录留着就能看出长期规律比如某个服务总是在夜间定时重启时挂掉。4.3 SSE与Nginx的缓冲坑SSE在本地跑得好好的部署到Nginx后面就出现消息不实时、几分钟才刷一次的问题这是我在这项目里踩过最大的坑。原因在于Nginx默认开启proxy_buffering会把后端响应整个缓冲起来SSE的流式输出全被攒住前端自然等不到数据。解决方法是在响应头加X-Accel-Buffering禁用缓冲同时为SSE接口单独配置locationlocation /api/stream { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; chunked_transfer_encoding off; }这里还有个配套细节proxy_read_timeout要设长否则Nginx默认60秒没收到新数据就会断开连接。而服务端那边为了保活会每30秒向所有连接写入一个注释行也就是发送以冒号开头的SSE注释。浏览器会把它当成心跳忽略掉但Nginx和代理层都会认为连接还活着。5. 部署方案与运维细节5.1 Docker Compose一键起服务整个项目部署用Docker Compose管理一共两个运行容器Nginx和server有一个volume专门放SQLite数据文件。docker-compose.yml的核心配置如下services: server: build: context: . dockerfile: packages/server/Dockerfile environment: - TZAsia/Shanghai - DB_PATH/data/status-deck.db volumes: - deck-data:/data restart: unless-stopped nginx: image: nginx:1.27-alpine ports: - 80:80 - 443:443 volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./packages/web/dist:/usr/share/nginx/html:ro depends_on: - server restart: unless-stopped volumes: deck-data:注意environment里的TZAsia/Shanghai这个必须显式设置否则node-cron的调度会基于容器默认的UTC时间所有定时任务会差8个小时。这个问题我刚开始没注意到排查了很久才发现是时区问题而不是代码逻辑问题。5.2 Nginx配置的静态资源与API代理Nginx同时承担静态资源服务和API反向代理。前端构建产物直接挂载到容器里访问根路径返回index.htmlAPI请求转发到server容器。两个细节值得强调一是静态资源要开gzip压缩二是/assets目录下的文件设置较长缓存时间避免每次刷新都重新下载几百KB的JS包。server { listen 80; server_name status.example.com; gzip on; gzip_types text/css application/javascript application/json; root /usr/share/nginx/html; location / { try_files $uri $uri/ /index.html; } location /assets/ { expires 7d; add_header Cache-Control public; } location /api/ { proxy_pass http://server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /api/stream { proxy_pass http://server:3000; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } }SSL证书我用的是自动续期的方案具体做法是在宿主机上跑一个定时任务每天检查证书有效期快到期就自动申请新证书并reload Nginx。这套流程在个人项目里非常成熟配合上面的Nginx配置基本是配一次跑一年。5.3 备份策略一个文件就够了SQLite最省心的地方在于备份。不需要mysqldump或者pg_dump直接复制文件就行。我用一个简单的定时任务每天凌晨3点把db文件打包后存到另一个目录保留最近7份#!/bin/bash # scripts/backup.sh BACKUP_DIR/data/backups mkdir -p $BACKUP_DIR tar czf $BACKUP_DIR/status-deck-$(date %Y%m%d).tar.gz /data/status-deck.db find $BACKUP_DIR -name *.tar.gz -mtime 7 -delete恢复更简单容器停机把备份文件解压回volume再启动容器。整个过程不到一分钟。对Status Deck这个量级的数据来说这种备份方案已经足够可靠了。6. 常见问题与避坑实录6.1 我实际踩过的问题速查表把实施过程中遇到的典型问题整理成一张表比长篇大论更直观问题现象根因解决方案SSE不实时面板几分钟才更新一次Nginx缓冲SSE响应关掉proxy_buffering见4.3SQLite锁库偶发SQLITE_BUSY写入失败多连接同时写入开启WAL模式连接复用cron时间差8小时定时任务在错误的时间执行容器默认UTC时区设置TZAsia/ShanghaiECharts内存泄漏页面长时间挂机后卡顿ECharts实例未销毁onUnmounted中dispose外部API超时阻塞所有指标都停更采集器未隔离每个采集器独立计时、独立捕获错误图表空白卡片尺寸变化后图表不刷新容器大小变化未调用resizeResizeObserver监听并chart.resize()6.2 SQLite WAL模式的一个关键操作SQLite在多连接场景下容易出现写锁冲突特别是SSE广播线程和定时采集线程同时操作时。解决办法很简单启动时执行两条PRAGMA// packages/server/src/store/db.ts import Database from better-sqlite3; const db new Database(process.env.DB_PATH ?? ./data/status-deck.db); db.pragma(journal_mode WAL); db.pragma(synchronous NORMAL);WAL模式允许读操作和写操作并发进行只有写入之间才互斥。同步级别NORMAL则保证崩溃恢复能力的同时减少磁盘写开销。这两个配置对于Status Deck这种读多写少、单机运行的场景非常合适。实测下来采集任务和SSE广播同时运行时再也没有出现过SQLITE_BUSY的报错。6.3 关于全栈这件事的真实感受做完这个项目我最深的感触是全栈项目最大的价值不是什么都会写而是自己给自己兜底。前端出了bug直接去后端日志里查后端数据异常打开SQLite看几眼就能定位部署出问题了Docker日志翻一遍就知道是哪个容器在闹脾气。问题在整个链路里都是透明的这种感觉是分工明确的大团队协作里很难体会到的。如果非要给后来者一个建议我会说控制规模。Status Deck这种项目最怕的就是什么都想加进去今天接个邮件统计明天接个天气API后天再做个用户系统。我的原则是功能跟着需求长Part 1里没有提到的需求实施阶段一律不预设。项目能在一个周末搭出可用的版本之后再按需迭代这才是个人全栈项目该有的节奏。
返回列表