feat(upstream): 上游更新检查——定期比上游、落后了推企业微信
Deploy VitePress site to Pages / build (push) Waiting to run
Deploy VitePress site to Pages / Deploy (push) Blocked by required conditions

本仓库在上游 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(';')
}
+30
View File
@@ -352,3 +352,33 @@ export function useTestWebhook() {
onError: (error: Error) => toast.error(`发送失败:${error.message}`),
})
}
// --- 上游更新检查 ----------------------------------------------------------
export function useUpstreamStatus() {
return useQuery({
queryKey: ['monitorUpstream'],
queryFn: async () => (await monitorApi.getUpstream()).data,
// 检查本身是每天一次的量级,页面开着时慢点刷就够了。
refetchInterval: 60_000,
})
}
export function useCheckUpstream() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => monitorApi.checkUpstream(),
onSuccess: (response) => {
const data = response.data
if (!data.ok) {
toast.error(`检查失败:${data.error ?? '未知错误'}`)
} else if ((data.behind ?? 0) > 0) {
toast.success(`上游有 ${data.behind} 个新提交`)
} else {
toast.success('已是最新,上游没有新提交')
}
queryClient.invalidateQueries({ queryKey: ['monitorUpstream'] })
},
onError: (error: Error) => toast.error(`检查失败:${error.message}`),
})
}
+10
View File
@@ -16,6 +16,7 @@ import type {
ReportResult,
SettingsResponse,
TaskCreatePayload,
UpstreamStatus,
WebhookStatus,
} from '@/types/monitor'
import type {
@@ -278,6 +279,15 @@ export const monitorApi = {
setWebhook: (url: string) => api.post('/monitor/webhook', { url }),
clearWebhook: () => api.delete('/monitor/webhook'),
testWebhook: (url?: string) => api.post('/monitor/webhook/test', { url: url ?? null }),
/** 最近一次上游检查的缓存结果;没有查过时是空对象。 */
getUpstream: () => api.get<UpstreamStatus>('/monitor/upstream'),
/**
* 立刻检查一次。服务端要等 fetch 跑完才回答,而 axios 默认 30 秒对此不够 ——
* 一条卡住的 git fetch 能拖到两分钟,这里必须单独放长超时,否则会误报失败。
*/
checkUpstream: () =>
api.post<UpstreamStatus>('/monitor/upstream/check', null, { timeout: 150_000 }),
}
/**
+34
View File
@@ -379,3 +379,37 @@ export interface SettingsResponse {
secrets: Record<string, SecretStatus>
specs: SettingSpec[]
}
// --- 上游更新检查 -----------------------------------------------------------
export interface UpstreamCommit {
sha: string
author: string
/** 提交日期,`YYYY-MM-DD`(git 侧已格式化,不做本地化)。 */
date: string
subject: string
}
/**
* 最近一次上游检查的结果,服务端缓存。
*
* `checked_at` 为空表示还没查过(`GET /monitor/upstream` 返回空对象);`ok` 为
* false 时 `error` 一定有值 —— 上游不通是常态,那也是一条要显示出来的结论。
*/
export interface UpstreamStatus {
checked_at?: number | null
remote_url?: string
branch?: string
ok?: boolean
/** 上游有、当前代码没有的提交数 —— 要合的就是这些。 */
behind?: number
/** 当前代码有、上游没有的提交数 —— 也就是这一层改动自己的规模。 */
ahead?: number
tip?: string
head?: string
commits?: UpstreamCommit[]
error?: string
/** 本次检查是否推送了通知。 */
notified?: boolean
notify_error?: string
}