为 Fuwari 添加 Uptime Kuma 状态卡片
终于想起来自己还有个博客了。
NOTE本文基于 Uptime Kuma 状态页接口,需提前部署 Uptime Kuma 服务
WARNING为了实现动态的数据更新,你需要先启用 Astro 的 SSR 服务器渲染模式
开始
Uptime Kuma 是一款开源、自托管的轻量级监控工具,界面简洁,告警方式也很丰富。随着个人项目越来越多,用它监控服务状态已经成了很多人的日常配置。
如果你还没部署 Uptime Kuma,用 Docker 部署非常方便;不想自购服务器,也可以选择 Zeabur、爪云等平台免费部署。
状态页是 Uptime Kuma 的核心能力,用于展示全部监控项的健康状态。但对博客访客来说,通常需要先点击导航再跳转到状态页,体验不够直接。
所以我要做的事很简单:把状态总览直接放进博客侧边栏,这样无论是访客还是我自己也能第一时间知道服务运行是否正常。
这篇教程会带你做一个左侧状态卡片组件:
- 实时显示整体状态:所有服务正常、部分故障、全部故障
- 当部分故障时,展开显示具体故障项目
效果
这里我把组件在不同状态下的样式都放出来,方便你快速确认是否符合预期。状态分为四种:全部正常、部分故障、全部故障、获取状态失败。每种状态都对应不同的渐变和提示样式。
亮色模式:

暗色模式:

教程
准备
- 一个已部署的 Fuwari 博客
- 一个公网可访问的 Uptime Kuma 状态页
创建组件
在 src/components/widget 目录下创建 UptimeStatus.astro文件,代码如下:
---import WidgetLayout from "./WidgetLayout.astro";
const STATUS_PAGE_URL = "https://uptime.tianhw.top/status/thw";
const resolveUptimeConfig = (statusPageUrl: string) => { const parsedUrl = new URL(statusPageUrl); const pathSegments = parsedUrl.pathname.split("/").filter(Boolean); const statusSegmentIndex = pathSegments.lastIndexOf("status");
if (statusSegmentIndex === -1 || !pathSegments[statusSegmentIndex + 1]) { throw new Error(`Invalid Uptime Kuma status page URL: ${statusPageUrl}`); }
const basePath = pathSegments.slice(0, statusSegmentIndex).join("/"); const baseUrl = `${parsedUrl.origin}${basePath ? `/${basePath}` : ""}`; const slug = pathSegments[statusSegmentIndex + 1];
return { uptimeBaseUrl: baseUrl, statusPageSlug: slug, heartbeatApiUrl: `${baseUrl}/api/status-page/heartbeat/${slug}`, uptimeIconUrl: `${baseUrl}/icon.svg`, };};
const { heartbeatApiUrl: HEARTBEAT_API_URL, uptimeIconUrl: UPTIME_ICON_URL } = resolveUptimeConfig(STATUS_PAGE_URL);
interface Props { class?: string; style?: string;}
const { class: className, style } = Astro.props;
let allServicesStatus = "获取状态失败";let statusCode = -1; // -1: error, 0: all down, 1: partial down, 2: all uplet failedServices: Array<{ name: string; statusText: string;}> = [];
const decodeEscapedText = (value: string) => { return value .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)), ) .replace(/\\'/g, "'");};
const getStatusText = (status: number) => { if (status === 0) return "故障"; if (status === 2) return "暂停"; return "异常";};
try { const [heartbeatRes, statusPageRes] = await Promise.all([ fetch(HEARTBEAT_API_URL), fetch(STATUS_PAGE_URL), ]);
const data = await heartbeatRes.json(); const statusPageHtml = await statusPageRes.text();
const heartbeats = data.heartbeatList ?? {};
const monitorNameById = new Map<string, string>(); const monitorRegex = /'id':(\d+),'name':'((?:\\u[0-9a-fA-F]{4}|\\'|[^'])*)'/g; let match: RegExpExecArray | null; match = monitorRegex.exec(statusPageHtml); while (match !== null) { const [, id, rawName] = match; if (!monitorNameById.has(id)) { monitorNameById.set(id, decodeEscapedText(rawName)); } match = monitorRegex.exec(statusPageHtml); }
let totalServices = 0; let upServices = 0; failedServices = [];
for (const key in heartbeats) { const list = heartbeats[key]; if (list && list.length > 0) { totalServices++; const latest = list[list.length - 1]; if (latest.status === 1) { upServices++; } else { failedServices.push({ name: monitorNameById.get(key) ?? `服务 #${key}`, statusText: getStatusText(latest.status), }); } } }
if (totalServices === 0) { statusCode = -1; allServicesStatus = "暂无监控数据"; } else if (upServices === totalServices) { statusCode = 2; allServicesStatus = "所有服务运行正常"; } else if (upServices === 0) { statusCode = 0; allServicesStatus = "全部服务出现故障"; } else { statusCode = 1; allServicesStatus = "部分服务出现故障"; }} catch (e) { console.error("Failed to fetch Kuma status:", e);}
const statusConfig = { "-1": { color: "text-neutral-800 dark:text-neutral-100", bg: "bg-gradient-to-r from-neutral-200/75 via-neutral-100/75 to-slate-100/70 dark:from-slate-800/85 dark:via-slate-900/85 dark:to-slate-900/80", border: "border-neutral-400/35 dark:border-neutral-500/35", divider: "border-black/10 bg-black/[0.03] dark:border-white/10 dark:bg-white/[0.025]", listDivider: "divide-black/8 dark:divide-white/10", dot: "bg-neutral-500", dotGlow: "shadow-[0_0_0_3px_rgba(115,115,115,0.16)] dark:shadow-[0_0_0_3px_rgba(163,163,163,0.24)]", ping: false, pingColor: "bg-neutral-400", }, "0": { color: "text-red-800 dark:text-red-100", bg: "bg-gradient-to-r from-rose-200/80 via-red-100/75 to-red-50/70 dark:from-rose-950/85 dark:via-red-950/85 dark:to-red-900/80", border: "border-rose-500/35 dark:border-red-500/35", divider: "border-red-900/15 bg-red-950/[0.045] dark:border-red-100/10 dark:bg-red-100/[0.04]", listDivider: "divide-red-900/10 dark:divide-red-100/10", dot: "bg-red-500", dotGlow: "shadow-[0_0_0_3px_rgba(239,68,68,0.18)] dark:shadow-[0_0_0_3px_rgba(248,113,113,0.24)]", ping: true, pingColor: "bg-red-400", }, "1": { color: "text-amber-800 dark:text-amber-100", bg: "bg-gradient-to-r from-amber-200/85 via-orange-100/75 to-amber-50/70 dark:from-amber-950/85 dark:via-orange-950/85 dark:to-amber-900/80", border: "border-amber-500/35 dark:border-amber-400/35", divider: "border-amber-900/15 bg-amber-950/[0.04] dark:border-amber-100/10 dark:bg-amber-100/[0.035]", listDivider: "divide-amber-900/10 dark:divide-amber-100/10", dot: "bg-amber-500", dotGlow: "shadow-[0_0_0_3px_rgba(245,158,11,0.2)] dark:shadow-[0_0_0_3px_rgba(251,191,36,0.26)]", ping: true, pingColor: "bg-amber-400", }, "2": { color: "text-emerald-800 dark:text-emerald-100", bg: "bg-gradient-to-r from-emerald-200/80 via-cyan-100/75 to-emerald-50/70 dark:from-emerald-950/85 dark:via-cyan-950/85 dark:to-emerald-900/80", border: "border-emerald-500/35 dark:border-emerald-400/35", divider: "border-emerald-900/15 bg-emerald-950/[0.04] dark:border-emerald-100/10 dark:bg-emerald-100/[0.035]", listDivider: "divide-emerald-900/10 dark:divide-emerald-100/10", dot: "bg-emerald-500", dotGlow: "shadow-[0_0_0_3px_rgba(16,185,129,0.18)] dark:shadow-[0_0_0_3px_rgba(52,211,153,0.24)]", ping: true, pingColor: "bg-emerald-400", },};
const config = statusConfig[statusCode.toString() as keyof typeof statusConfig];---
<WidgetLayout name="状态" id="uptime-status" class:list={[className]} {style}> <div class={`overflow-hidden rounded-2xl border shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ${config.bg} ${config.border}`}> <a href={STATUS_PAGE_URL} target="_blank" rel="noopener noreferrer" class="group flex items-center justify-between gap-3 px-3.5 py-2.5 transition-all duration-300 hover:brightness-105 active:brightness-100" > <div class="flex min-w-0 items-center gap-2.5"> <img src={UPTIME_ICON_URL} alt="Uptime Icon" class="h-6 w-6 shrink-0" /> <span class={`relative z-[1] truncate text-[0.88rem] font-semibold tracking-[0.02em] [text-shadow:0_1px_0_rgba(255,255,255,0.25)] dark:[text-shadow:0_1px_0_rgba(0,0,0,0.45)] ${config.color}`}>{allServicesStatus}</span> </div> <div class="relative z-[1] flex h-2.5 w-2.5 shrink-0"> {config.ping && <span class={`animate-ping absolute inline-flex h-full w-full rounded-full ${config.pingColor} opacity-70`}></span>} <span class={`relative inline-flex h-2.5 w-2.5 rounded-full ${config.dotGlow} ${config.dot}`}></span> </div> </a>
{statusCode === 1 && failedServices.length > 0 && ( <div class={`border-t ${config.divider}`}> <ul class={`divide-y ${config.listDivider}`}> {failedServices.map((item) => ( <li class="px-3.5 py-1.5"> <div class="flex min-h-7 items-center justify-between gap-2.5"> <span class={`truncate text-[0.8rem] font-medium opacity-90 ${config.color}`}>{item.name}</span> <span class="shrink-0 rounded-full bg-rose-500/12 px-2 py-0.5 text-[0.64rem] font-semibold tracking-wide text-rose-700/90 dark:text-rose-200/90">{item.statusText}</span> </div> </li> ))} </ul> </div> )} </div></WidgetLayout>配置参数
配置非常简单,你只需要准备一个参数:Uptime Kuma 状态页 URL。
例如我的状态页地址是:https://uptime.tianhw.top/status/thw 那么我就把这个地址填写到刚刚UptimeStatus.astro的第4行中即可
const STATUS_PAGE_URL = "https://uptime.tianhw.top/status/thw";启用服务端渲染
如果你了解过CORS,你可能会担心博客域名(例如 blog.tianhw.top)和状态页域名(例如 uptime.tianhw.top)不是同一个源。会受到 CORS 策略限制,出现跨域拦截。
所以我的实现方式是把 fetch 放在 Astro 组件的服务端上下文中执行,这样请求发生在服务端,而不是浏览器中,因此不会触发前端跨域校验,所以我们需要启用SSR服务器端渲染,该组件才能正常更新数据。
如果你使用静态模式部署博客,那么这个组件只能在构建时获取一次状态数据,之后就不会更新了。
因此切记选用拥有适配器的部署平台(如 EdgeOne Pages、Cloudflare、Netlify、Vercel 等)开启服务端渲染(SSR)模式,这一块可以直接告诉AI帮你完成适配。
添加到侧边栏
在 SideBar.astro 中导入并插入组件:
---import Categories from "./Categories.astro";import Profile from "./Profile.astro";import Sponsors from "./Sponsors.astro";import UmamiStats from "./UmamiStats.astro";import UptimeStatus from "./UptimeStatus.astro";---<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4"> <Categories class="onload-animation" style="animation-delay: 150ms"></Categories> <UptimeStatus class="onload-animation" style="animation-delay: 175ms"></UptimeStatus> <UmamiStats class="onload-animation" style="animation-delay: 200ms"></UmamiStats> <Sponsors class="onload-animation" style="animation-delay: 250ms"></Sponsors></div>结尾
到这里,一个与 Fuwari 风格一致的 Uptime Kuma 状态卡片就完成了。
这是一个基础版本,你还可以进一步扩展功能,例如:
- 显示当前故障数量
- 增加最近故障时间
- 显示平均响应时延