feat(upstream): 上游更新检查——定期比上游、落后了推企业微信
本仓库在上游 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:
@@ -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(';')
|
||||
}
|
||||
@@ -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}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user