3380 字
17 分钟
阅读量
基于 Cloudflare Worker + KV 打造 Serverless GitHub 仓库更新监控系统

在日常运维与开源项目关注中,我们经常需要及时跟踪上游仓库的 最新 Release 发版代码 Commit 提交(例如面板更新、工具脚本更新、核心依赖发版等)。

传统的监控做法通常是在服务器上使用 Python / Go 编写一个常驻后台服务,借助 cron 或定时器轮询 GitHub API,并将已推送的记录保存在本地文件中。然而,这种常驻方案存在明显弊端:

  1. 占用服务器资源:需要 24 小时常驻 VPS 占用内存与进程守护;
  2. 受服务器状态影响:一旦服务器重启、网络波动或进程异常退出,就会漏掉发版提醒;
  3. 维护成本高:需要管理本地状态文件与日志,且难以直观查看当前监控状态。

本文介绍如何利用 Cloudflare Worker(Cron Triggers 定时调度) 结合 Cloudflare KV(边缘键值存储),零服务器资源占用、零成本搭建一个高可用的 GitHub 仓库更新实时监控系统。


架构拓扑#

+-----------------------+ +-------------------------+ +-------------------+
| Cloudflare Cron 调度 | ----> | Cloudflare Worker | ----> | GitHub REST API |
| (如每 6 小时自动触发) | | (检查更新 / 状态比对) | | (Commits/Releases)|
+-----------------------+ +-------------------------+ +-------------------+
| |
v v
+----------------+ +-------------------+
| Cloudflare KV | | Webhook 消息网关 |
| (记录最新 SHA) | | (企业微信/Bark等) |
+----------------+ +-------------------+

核心特性#

  • Serverless 零服务器常驻:无需 VPS 虚拟机,完全依托 Cloudflare 边缘全球网络运行。
  • Commit 与 Release 双模式监控
    • Commit 模式:监控指定分支最新提交,获取 Commit SHA、提交信息与提交时间;
    • Release 模式:监控仓库最新发版,支持仅监控正式版(排除 Prerelease 与 Draft)。
  • Cloudflare KV 智能防重与初始化
    • 首次运行自动将当前最新版本录入 KV 作为基准,杜绝冷启动误报和刷屏
    • 每次检测比对 KV 历史记录,仅当检测到真正的新提交/新版本时才触发通知。
  • 现代化护眼温润 Web 看板
    • 访问 Worker 域名可直观查看每个仓库的监控类型、当前基准版本与最近巡检时间;
    • 纯只读状态监控面板,不公开暴露外部触发接口;
    • 采用 #f8fafc 浅色护眼配色体系,长时间浏览舒适自然。

环境变量与 KV 绑定#

在 Cloudflare 控制台创建 Worker 后,配置如下绑定:

1. KV 命名空间绑定#

  • 变量名称REPO_KV
  • 绑定目标:创建并绑定一个 KV 命名空间(例如 REPO_MONITOR_KV),用于持久化存储各个仓库最新已推送记录。

2. 环境变量与机密(Variables and Secrets)#

变量名类型示例值说明
PUSH_URLPlaintexthttps://sms.example.com/messageWebhook 推送网关接口地址
PUSH_TOKENPlaintext / Secretyour_push_token推送接口认证密钥
GITHUB_TOKENSecretghp_xxxxxxxxxxxx(可选)GitHub Personal Access Token,避免触发匿名 API 限流
REPOS_CONFIGPlaintext[...](可选)JSON 字符串覆盖默认监控仓库配置

3. Cron 触发器设置(Schedules)#

  • 在 Worker 的 Settings -> Triggers -> Cron Triggers 中添加定时调度规则,如 0 */6 * * *(每 6 小时自动巡检一次)。

完整 Worker 源码#

/**
* Cloudflare Worker: GitHub Repository Update Monitor (Serverless)
* 特性:
* 1. 深度优化柔和暖调护眼浅色(消除纯白眩光)+ 微微黑柔和暗色自由切换
* 2. 宽屏(30寸/2K/4K)多列自适应卡片 + 手机端全端自适应 + 专属 Favicon
* 3. 内部巡检 Token 鉴权保护
*/
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="grad" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="#0284c7"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#eef2f6"/>
<rect x="1.5" y="1.5" width="61" height="61" rx="14.5" stroke="#cbd5e1" stroke-width="2"/>
<circle cx="20" cy="22" r="5.5" fill="#0284c7"/>
<circle cx="44" cy="22" r="5.5" fill="#059669"/>
<circle cx="32" cy="44" r="5.5" fill="#10b981"/>
<path d="M20 22V32C20 36 24 40 28 42L32 44M44 22V32C44 36 40 40 36 42L32 44" stroke="url(#grad)" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
export default {
// Cloudflare Cron 定时调度(每 6 小时自动触发)
async scheduled(event, env, ctx) {
ctx.waitUntil(runMonitor(env, false));
},
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
if (path === '/favicon.ico' || path === '/favicon.svg') {
return new Response(FAVICON_SVG, {
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'public, max-age=604800, immutable'
}
});
}
// 内部调试接口:必须携带有效 token 鉴权
if (path === '/check' || path === '/api/check') {
const token = url.searchParams.get('token') || request.headers.get('x-token');
const validToken = env.ADMIN_TOKEN || env.PUSH_TOKEN || 'fdngkee5yt';
if (!token || token !== validToken) {
return new Response(JSON.stringify({ status: 'fail', error: '未授权:请提供有效的管理员 token' }), {
status: 403,
headers: { 'Content-Type': 'application/json; charset=utf-8' }
});
}
const result = await runMonitor(env, false);
return new Response(JSON.stringify(result, null, 2), {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
});
}
if (path === '/health' || path === '/status') {
const status = await getStatus(env);
return new Response(JSON.stringify(status, null, 2), {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
});
}
// 纯只读展示看板
return handleDashboard(request, env);
}
};
const DEFAULT_REPOS = [
{
repo: "cedar2025/Xboard",
alias: "V面板",
monitor_commits: true,
monitor_releases: false,
branch: "master"
},
{
repo: "pppscn/SmsForwarder",
alias: "SmsForwarder",
monitor_commits: false,
monitor_releases: true,
stable_only: true
},
{
repo: "massgravel/Microsoft-Activation-Scripts",
alias: "KMS脚本",
monitor_commits: false,
monitor_releases: true,
stable_only: true
},
{
repo: "cmontage/mas-cn",
alias: "MAS中文版",
monitor_commits: false,
monitor_releases: true,
stable_only: true
}
];
function getReposConfig(env) {
if (env.REPOS_CONFIG) {
try {
return JSON.parse(env.REPOS_CONFIG);
} catch (e) {
console.error("Failed to parse REPOS_CONFIG:", e);
}
}
return DEFAULT_REPOS;
}
function formatBeijingTime(date) {
if (!date) return "未知时间";
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return String(date);
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(d).replace(/\//g, "-");
}
async function runMonitor(env, isTest = false) {
const repos = getReposConfig(env);
const results = [];
const pushUrl = env.PUSH_URL || "https://sms.onepve.com/message";
const pushToken = env.PUSH_TOKEN || "fdngkee5yt";
const ghToken = env.GITHUB_TOKEN || "";
const headers = {
"User-Agent": "Cloudflare-Worker-Repo-Monitor/1.0",
"Accept": "application/vnd.github.v3+json"
};
if (ghToken) {
headers["Authorization"] = `token ${ghToken}`;
}
for (const repoCfg of repos) {
const { repo, alias, monitor_commits, monitor_releases, stable_only, branch } = repoCfg;
const itemResult = { repo, alias, checked_at: formatBeijingTime(new Date()), status: "ok" };
try {
if (monitor_commits) {
let commitUrl = `https://api.github.com/repos/${repo}/commits?per_page=1`;
if (branch) commitUrl += `&sha=${encodeURIComponent(branch)}`;
const resp = await fetch(commitUrl, { headers });
if (!resp.ok) throw new Error(`GitHub Commits API HTTP ${resp.status}`);
const commits = await resp.json();
if (commits && commits.length > 0) {
const latest = commits[0];
const sha = latest.sha;
const shortSha = sha.substring(0, 7);
const commitMsg = (latest.commit?.message || "").split("
")[0];
const commitDate = latest.commit?.author?.date || latest.commit?.committer?.date;
const kvKey = `commit:${repo}`;
const recordedRaw = await env.REPO_KV.get(kvKey);
let recorded = null;
if (recordedRaw) {
try { recorded = JSON.parse(recordedRaw); } catch(e){}
}
const isFirstInit = !recorded;
const isNew = recorded && recorded.sha !== sha;
itemResult.commit = {
latest_sha: shortSha,
message: commitMsg,
date: formatBeijingTime(commitDate),
is_new: isNew || (isTest && !isFirstInit)
};
if (isFirstInit) {
await env.REPO_KV.put(kvKey, JSON.stringify({
sha,
short_sha: shortSha,
message: commitMsg,
date: commitDate,
updated_at: new Date().toISOString()
}));
itemResult.commit.action = "initialized";
} else if (isNew || isTest) {
const title = `📢 ${alias} Commit更新`;
const content = [
`仓库名:${alias}`,
`Commit ID:${shortSha}`,
`提交信息:${commitMsg}`,
`更新时间:${formatBeijingTime(commitDate)}`,
`仓库地址:https://github.com/${repo}/commit/${sha}`
].join("
");
const pushSuccess = await sendPush(pushUrl, pushToken, title, content);
if (pushSuccess) {
await env.REPO_KV.put(kvKey, JSON.stringify({
sha,
short_sha: shortSha,
message: commitMsg,
date: commitDate,
updated_at: new Date().toISOString()
}));
itemResult.commit.action = "pushed";
} else {
itemResult.commit.action = "push_failed";
}
} else {
itemResult.commit.action = "no_change";
}
}
}
if (monitor_releases) {
const relUrl = `https://api.github.com/repos/${repo}/releases?per_page=5`;
const resp = await fetch(relUrl, { headers });
if (!resp.ok) throw new Error(`GitHub Releases API HTTP ${resp.status}`);
const releases = await resp.json();
let targetReleases = Array.isArray(releases) ? releases : [];
if (stable_only) {
targetReleases = targetReleases.filter(r => !r.prerelease && !r.draft);
}
if (targetReleases.length > 0) {
const latest = targetReleases[0];
const tagName = latest.tag_name;
const releaseName = latest.name || tagName;
const publishedAt = latest.published_at;
const htmlUrl = latest.html_url;
const isStable = !latest.prerelease;
const kvKey = `release:${repo}`;
const recordedRaw = await env.REPO_KV.get(kvKey);
let recorded = null;
if (recordedRaw) {
try { recorded = JSON.parse(recordedRaw); } catch(e){}
}
const isFirstInit = !recorded;
const isNew = recorded && recorded.tag_name !== tagName;
itemResult.release = {
tag_name: tagName,
name: releaseName,
published_at: formatBeijingTime(publishedAt),
is_new: isNew || (isTest && !isFirstInit)
};
if (isFirstInit) {
await env.REPO_KV.put(kvKey, JSON.stringify({
tag_name: tagName,
name: releaseName,
published_at: publishedAt,
html_url: htmlUrl,
updated_at: new Date().toISOString()
}));
itemResult.release.action = "initialized";
} else if (isNew || isTest) {
const title = `📢 ${alias} 新增Releases`;
const contentLines = [
`仓库名:${alias}`
];
if (tagName.trim() === releaseName.trim()) {
contentLines.push(`版本:${releaseName}`);
} else {
contentLines.push(`标签:${tagName}`);
contentLines.push(`名称:${releaseName}`);
}
contentLines.push(`是否正式版:${isStable ? "true" : "false"}`);
contentLines.push(`发布时间:${formatBeijingTime(publishedAt)}`);
contentLines.push(`地址:${htmlUrl}`);
const content = contentLines.join("
");
const pushSuccess = await sendPush(pushUrl, pushToken, title, content);
if (pushSuccess) {
await env.REPO_KV.put(kvKey, JSON.stringify({
tag_name: tagName,
name: releaseName,
published_at: publishedAt,
html_url: htmlUrl,
updated_at: new Date().toISOString()
}));
itemResult.release.action = "pushed";
} else {
itemResult.release.action = "push_failed";
}
} else {
itemResult.release.action = "no_change";
}
} else {
itemResult.release = { action: "no_releases_found" };
}
}
} catch (err) {
console.error(`Error monitoring ${repo}:`, err);
itemResult.status = "error";
itemResult.error = err.message;
}
results.push(itemResult);
}
await env.REPO_KV.put("last_check_meta", JSON.stringify({
time: new Date().toISOString(),
formatted: formatBeijingTime(new Date()),
summary: results
}));
return {
success: true,
checked_at: formatBeijingTime(new Date()),
results
};
}
async function sendPush(url, token, title, content) {
try {
const targetUrl = new URL(url);
targetUrl.searchParams.set("token", token);
const resp = await fetch(targetUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
},
body: JSON.stringify({ title, content })
});
return resp.ok;
} catch (e) {
console.error("sendPush failed:", e);
return false;
}
}
async function getStatus(env) {
const repos = getReposConfig(env);
const list = [];
for (const r of repos) {
const commitRaw = await env.REPO_KV.get(`commit:${r.repo}`);
const releaseRaw = await env.REPO_KV.get(`release:${r.repo}`);
list.push({
...r,
saved_commit: commitRaw ? JSON.parse(commitRaw) : null,
saved_release: releaseRaw ? JSON.parse(releaseRaw) : null
});
}
const lastCheck = await env.REPO_KV.get("last_check_meta");
return {
status: "healthy",
last_check: lastCheck ? JSON.parse(lastCheck) : null,
repos: list
};
}
async function handleDashboard(request, env) {
const status = await getStatus(env);
const reposHtml = status.repos.map(r => {
let detail = "";
if (r.monitor_commits) {
const c = r.saved_commit;
detail += `
<div class="tag">
<div class="tag-top">
<div class="tag-left">
<span class="type-badge commit-badge">Commit 监控</span>
${c ? `<code>${c.short_sha || c.sha.substring(0,7)}</code>` : '<span class="desc">尚未初始化</span>'}
</div>
${c?.date ? `<span class="tag-time">${formatBeijingTime(c.date)}</span>` : ''}
</div>
${c?.message ? `<div class="desc">${c.message}</div>` : ''}
</div>
`;
}
if (r.monitor_releases) {
const rel = r.saved_release;
detail += `
<div class="tag">
<div class="tag-top">
<div class="tag-left">
<span class="type-badge release-badge">Release 监控</span>
${rel ? `<code>${rel.tag_name}</code>` : '<span class="desc">尚未初始化</span>'}
</div>
${rel?.published_at ? `<span class="tag-time">${formatBeijingTime(rel.published_at)}</span>` : ''}
</div>
${rel?.name && rel.name !== rel.tag_name ? `<div class="desc">${rel.name}</div>` : ''}
</div>
`;
}
return `
<div class="card">
<div class="card-header">
<div class="alias-wrap">
<span class="alias">${r.alias}</span>
<span class="repo-sub">${r.repo}</span>
</div>
<a href="https://github.com/${r.repo}" target="_blank" class="repo-link">访问仓库 ↗</a>
</div>
<div class="card-body">
${detail}
</div>
</div>
`;
}).join("");
const lastCheckTime = status.last_check?.formatted || "尚未运行";
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<title>GitHub 仓库监控网关</title>
<style>
:root {
--bg: #ebeff4;
--surface: #f6f8fb;
--surface-sub: #e2e8f0;
--border: #d1d8e2;
--border-sub: #b8c3d2;
--accent: #0284c7;
--accent-hover: #0369a1;
--accent-light: rgba(2, 132, 199, 0.12);
--text: #1e293b;
--text-muted: #576579;
--success: #059669;
--success-bg: #def7ec;
--success-border: #9ee3c3;
--code-bg: #e5ebf2;
}
[data-theme="dark"] {
--bg: #0b1120;
--surface: #1e293b;
--surface-sub: #2d3a4f;
--border: #334155;
--border-sub: #475569;
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--accent-light: rgba(56, 189, 248, 0.15);
--text: #f1f5f9;
--text-muted: #94a3b8;
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.14);
--success-border: rgba(16, 185, 129, 0.35);
--code-bg: #0f172a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 2.5rem 2rem;
min-height: 100vh;
display: flex;
justify-content: center;
transition: background 0.3s, color 0.3s;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 1400px;
width: 100%;
min-width: 0;
}
header {
margin-bottom: 1.75rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.header-content { flex: 1; min-width: 0; }
.badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
background: var(--accent-light);
border: 1px solid var(--border);
padding: 0.22rem 0.7rem;
border-radius: 9999px;
margin-bottom: 0.6rem;
}
.title-wrap {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.header-logo {
width: 36px;
height: 36px;
border-radius: 9px;
flex-shrink: 0;
}
h1 {
font-size: 2.1rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.02em;
line-height: 1.25;
}
.subtitle {
color: var(--text-muted);
font-size: 0.98rem;
line-height: 1.5;
}
.theme-toggle-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
width: 40px;
height: 40px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.15rem;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
flex-shrink: 0;
margin-top: 4px;
}
.theme-toggle-btn:hover {
background: var(--surface-sub);
border-color: var(--border-sub);
transform: scale(1.05);
}
.status-bar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.1rem 1.4rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.65rem;
font-size: 0.92rem;
font-weight: 600;
color: var(--success);
background: var(--success-bg);
border: 1px solid var(--success-border);
padding: 0.38rem 0.85rem;
border-radius: 8px;
}
.pulse-dot {
width: 8px;
height: 8px;
background-color: var(--success);
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse 2s infinite;
flex-shrink: 0;
}
@keyframes pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 7px rgba(16, 185, 129, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
.status-meta {
display: flex;
align-items: center;
gap: 1.5rem;
font-size: 0.88rem;
color: var(--text-muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.meta-label { color: var(--text-muted); }
.meta-val {
color: var(--text);
font-weight: 600;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Widescreen Multi-Column Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.3rem 1.45rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.alias-wrap {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.alias {
font-size: 1.12rem;
font-weight: 700;
color: var(--text);
line-height: 1.3;
}
.repo-sub {
font-size: 0.82rem;
color: var(--text-muted);
word-break: break-all;
line-height: 1.35;
}
.repo-link {
font-size: 0.78rem;
color: var(--accent);
text-decoration: none;
background: var(--accent-light);
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
margin-top: 2px;
transition: all 0.2s;
}
.repo-link:hover { opacity: 0.85; }
.tag {
padding: 0.75rem 0.9rem;
border-radius: 8px;
font-size: 0.86rem;
margin-top: 6px;
background: var(--surface-sub);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
}
.tag-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag-left {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.type-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
flex-shrink: 0;
}
.commit-badge { background: var(--accent-light); color: var(--accent); }
.release-badge { background: var(--success-bg); color: var(--success); }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--surface);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 0.84rem;
word-break: break-all;
}
.tag-time {
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.desc {
color: var(--text-muted);
font-size: 0.84rem;
line-height: 1.45;
word-break: break-word;
}
footer {
text-align: center;
margin-top: 3rem;
color: var(--text-muted);
font-size: 0.85rem;
}
@media (max-width: 640px) {
body { padding: 1.2rem 0.85rem; }
.container { max-width: 100%; }
h1 { font-size: 1.45rem; }
.header-logo { width: 28px; height: 28px; }
.subtitle { font-size: 0.86rem; }
.grid { grid-template-columns: 1fr; gap: 10px; }
.card { padding: 1rem 0.9rem; border-radius: 10px; }
.status-bar {
padding: 0.85rem 0.95rem;
flex-direction: column;
align-items: stretch;
gap: 8px;
border-radius: 10px;
}
.status-indicator { width: 100%; justify-content: flex-start; }
.status-meta {
width: 100%;
display: flex;
flex-direction: column;
gap: 5px;
padding-top: 7px;
border-top: 1px dashed var(--border);
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-size: 0.82rem;
}
.tag-top {
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.tag-time {
font-size: 0.74rem;
color: var(--text-muted);
}
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-content">
<div class="badge">Serverless Multi-Repo Watcher</div>
<div class="title-wrap">
<img src="/favicon.ico" class="header-logo" alt="Logo" />
<h1>GitHub 仓库监控网关</h1>
</div>
<p class="subtitle">Cloudflare Worker 边缘定时调度 · 自动检测 Commits 与 Releases 更新并推送</p>
</div>
<button class="theme-toggle-btn" id="themeToggle" onclick="toggleTheme()" title="切换明亮/暗色模式">🌙</button>
</header>
<div class="status-bar">
<div class="status-indicator">
<div class="pulse-dot"></div>
<span>● 监控服务运行正常</span>
</div>
<div class="status-meta">
<div class="meta-item">
<span class="meta-label">上次自动巡检:</span>
<span class="meta-val">${lastCheckTime}</span>
</div>
<div class="meta-item">
<span class="meta-label">巡检调度周期:</span>
<span class="meta-val">每 6 小时</span>
</div>
</div>
</div>
<div class="grid">
${reposHtml}
</div>
<footer>
Powered by Cloudflare Workers & KV Storage · 零服务器常驻
</footer>
</div>
<script>
function initTheme() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
updateThemeBtn(true);
} else {
document.documentElement.setAttribute('data-theme', 'light');
updateThemeBtn(false);
}
}
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const next = isDark ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateThemeBtn(!isDark);
}
function updateThemeBtn(isDark) {
const btn = document.getElementById('themeToggle');
if (btn) btn.innerHTML = isDark ? '☀️' : '🌙';
}
initTheme();
</script>
</body>
</html>`;
return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" }
});
}

总结#

将 GitHub 仓库监控迁移到 Cloudflare Worker + KV 架构后:

  1. 彻底摆脱 VPS:不仅节约服务器常驻内存和进程守护,即使 VPS 维护重启也不会漏掉任何发版提醒;
  2. 零成本与高可用:每天触发几十次检查,远低于 Cloudflare 免费配额(10 万次/天);
  3. 视觉与体验双提升:现代温润护眼看板让监控状态一目了然,调用检测方便快捷。