feat(upstream): 上游更新检查——定期比上游、落后了推企业微信
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

本仓库在上游 MediaCrawler 之上加了一整层(见 UPSTREAM.md),可合并流程默认
「有人知道上游动了」。而部署是 git pull --ff-only,只从自己的 Gitea 拉——上游的
提交不主动 fetch 就永远看不见。拖着不合并的代价是复利的:越久越难合。

于是把「上游动了没有」变成一条会自己跑、会推企业微信的通知:

* api/monitor/upstream.py:git fetch <url> <branch> 到 FETCH_HEAD,用
  rev-list --count HEAD..FETCH_HEAD 算落后数、FETCH_HEAD..HEAD 算领先数。
  用 git 而非托管商 API,因为只有 git 知道共同祖先在哪——本仓库含有上游没有的
  提交,直接比 tip 会得出错误结论。增量 fetch 只传几个新提交,不会遇到
  UPSTREAM.md 里说的「大包必断」。
* 只 fetch 到 FETCH_HEAD:不配 remote、不写 refs/remotes、不碰索引与工作区,
  所以不打断正在跑的采集,也不和 deploy.sh 的 git pull 抢锁。
* 挂在调度器 tick 上(不是采集,所以不看 is_busy、不受活跃时段限制——定时检查
  放在半夜反而最合适),按 checked_at + 间隔 到期才跑;失败也写 checked_at,
  于是 GitHub 不通时是每间隔重试一次,而不是每个 tick 撞一次墙。
* 同一个 tip 只推一次(记 tip 而不是「推过没」),上游真又动了会再推。
* 两个接口:GET /monitor/upstream 只读缓存;POST /monitor/upstream/check 手动
  查一次且刻意不推通知——点按钮的人正看着结果。
* 默认关闭,间隔默认一天。

Dockerfile 显式装 git(python:slim 不带,而这是唯一的依赖);deploy.sh 顺带补上
一个真 bug 的提示:Dockerfile/requirements.txt 变了只 up -d 用的还是旧镜像。
This commit is contained in:
2026-10-10 09:17:06 +08:00
parent 44cbe8e2aa
commit e348de48d3
15 changed files with 1183 additions and 6 deletions
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog'
import { WebhookPanel } from '@/components/monitor/WebhookPanel'
import { ChangePassword, SettingField } from '@/components/settings/SettingFields'
import { UpstreamPanel } from '@/components/settings/UpstreamPanel'
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
type Draft = Record<string, boolean | number | string>
@@ -44,6 +45,18 @@ export function SystemSettingsDialog({
[data],
)
// 上游那几项单独成块,其余(时段、CDP)仍归在「调度」下。按前缀分流就够了:
// 注册表是唯一的来源,新增一项上游设置不需要再动这个文件。写成「排除 upstream_」
// 而不是「只取 active_hours_」,这样以后再加系统设置也不会从界面上凭空消失。
const upstreamSpecs = useMemo(
() => systemSpecs.filter((spec) => spec.name.startsWith('upstream_')),
[systemSpecs],
)
const scheduleSpecs = useMemo(
() => systemSpecs.filter((spec) => !spec.name.startsWith('upstream_')),
[systemSpecs],
)
const dirty = useMemo(() => {
if (!data?.values) return {}
const changes: Draft = {}
@@ -86,7 +99,28 @@ export function SystemSettingsDialog({
调度器全局只有一套时段规则,因此不按平台区分。
</p>
</div>
{systemSpecs.map((spec) => (
{scheduleSpecs.map((spec) => (
<SettingField
key={spec.key}
spec={spec}
value={draft[spec.key] ?? (spec.default as boolean | number | string)}
onChange={(next) => setDraft((prev) => ({ ...prev, [spec.key]: next }))}
/>
))}
</div>
<div className="space-y-3 border-t border-cyber-border-subtle pt-4">
<div>
<h3 className="font-mono text-xs tracking-wider text-cyber-text-primary">
上游更新
</h3>
<p className="mt-0.5 text-[10px] font-mono text-cyber-text-muted">
本仓库在上游 MediaCrawler 之上加了一整层,这里定期看看上游有没有新提交。
改动立即生效,不需要等下一轮采集。
</p>
</div>
<UpstreamPanel />
{upstreamSpecs.map((spec) => (
<SettingField
key={spec.key}
spec={spec}
@@ -0,0 +1,75 @@
import { RefreshCw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useCheckUpstream, useUpstreamStatus } from '@/hooks/useMonitor'
import { formatDateTime, formatRelative } from '@/lib/monitorFormat'
import type { UpstreamStatus } from '@/types/monitor'
/**
* 最近一次上游检查的结果,外加一个「立即检查」。
*
* 开关与间隔都由设置项本身渲染(同一张注册表),这里只补状态显示:光有一个开关,
* 用户没法知道它到底跑没跑、上游到底动没动。后端只缓存结果,不在这里发 fetch ——
* 「立即检查」才发,而且那条请求要等 fetch 跑完,所以超时是单独放长的。
*/
export function UpstreamPanel() {
const { data, isLoading } = useUpstreamStatus()
const checkNow = useCheckUpstream()
return (
<div className="space-y-2 rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">{statusBadge(data)}</div>
<Button
variant="outline"
size="sm"
disabled={checkNow.isPending}
onClick={() => checkNow.mutate()}
>
<RefreshCw className={`w-3 h-3 mr-1 ${checkNow.isPending ? 'animate-spin' : ''}`} />
{checkNow.isPending ? '检查中…' : '立即检查'}
</Button>
</div>
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">
{isLoading ? '读取中…' : describe(data)}
</p>
{data?.commits && data.commits.length > 0 && (
<ul className="space-y-1 border-t border-cyber-border-subtle pt-2">
{data.commits.slice(0, 10).map((commit) => (
<li key={commit.sha} className="flex gap-2 text-[10px] font-mono leading-relaxed">
<span className="text-cyber-text-muted shrink-0">{commit.sha}</span>
<span className="text-cyber-text-secondary truncate" title={commit.subject}>
{commit.subject}
</span>
</li>
))}
</ul>
)}
</div>
)
}
function statusBadge(data?: UpstreamStatus) {
if (!data || !data.checked_at) return <Badge variant="idle">尚未检查</Badge>
if (!data.ok) return <Badge variant="destructive">检查失败</Badge>
if ((data.behind ?? 0) > 0) return <Badge variant="warning">落后 {data.behind} 个提交</Badge>
return <Badge variant="success">已是最新</Badge>
}
function describe(data?: UpstreamStatus): string {
if (!data || !data.checked_at) {
return '还没有检查过。开启上面的开关会按间隔自动查,也可以点「立即检查」。'
}
const when = `上次检查:${formatDateTime(data.checked_at)}(${formatRelative(data.checked_at)})`
if (!data.ok) return `${when};${data.error ?? '未知错误'}`
const parts = [when, `上游 ${data.branch ?? 'main'} 领先 ${data.behind ?? 0} 个提交`]
// 领先数就是我们自己这一层的规模;合并时要保留的东西,值得一并说出来。
if (data.ahead) parts.push(`本仓库另有 ${data.ahead} 个自己的提交`)
if (data.notify_error) parts.push(`通知发送失败:${data.notify_error}`)
return parts.join(';')
}