1617 字
8 分钟

为 Fuwari 添加 Uptime Kuma 状态卡片

gemini-aiAI 摘要

终于想起来自己还有个博客了。

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 up
let 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 状态卡片就完成了。

这是一个基础版本,你还可以进一步扩展功能,例如:

  • 显示当前故障数量
  • 增加最近故障时间
  • 显示平均响应时延
为 Fuwari 添加 Uptime Kuma 状态卡片
https://blog.tianhw.top/posts/fuwari-uptime-status-card/
作者
THW
发布于
2026-03-15
许可协议
CC BY-NC-SA 4.0