Files
MediaCrawler/webui/src/hooks/useMonitor.ts
T
butubb e348de48d3
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 用的还是旧镜像。
2026-10-10 09:17:06 +08:00

385 lines
13 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { monitorApi, settingsApi } from '@/lib/api'
import { usePlatformStore } from '@/store/platformStore'
import type { TaskCreatePayload } from '@/types/monitor'
/**
* The platform every monitor hook is scoped to.
*
* Read from the store rather than threaded through props, so switching the
* global platform switcher re-scopes every view at once and no component can
* forget to pass it along.
*/
function usePlatformParam(): string {
return usePlatformStore((state) => state.platform)
}
/** Polling cadence for the dashboard. Runs take minutes, so a few seconds of
* lag is fine and keeps the number of requests low. */
const POLL_MS = 5000
export function useMonitorOverview() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorOverview', platform],
queryFn: async () => (await monitorApi.getOverview(platform)).data,
refetchInterval: POLL_MS,
})
}
export function useMonitorTasks() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorTasks', platform],
queryFn: async () => (await monitorApi.getTasks(platform)).data.tasks,
refetchInterval: POLL_MS,
})
}
export function useCreateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: TaskCreatePayload) => monitorApi.createTask(payload),
onSuccess: () => {
toast.success('监控任务已创建')
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
onError: (error: Error) => toast.error(`创建失败:${error.message}`),
})
}
export function useUpdateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<TaskCreatePayload> }) =>
monitorApi.updateTask(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
},
onError: (error: Error) => toast.error(`更新失败:${error.message}`),
})
}
export function useDeleteTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => monitorApi.deleteTask(id),
onSuccess: () => {
toast.success('监控任务已删除')
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
onError: (error: Error) => toast.error(`删除失败:${error.message}`),
})
}
export function useRunTaskNow() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => monitorApi.runTaskNow(id),
onSuccess: () => {
// The crawl itself runs in the background; the run list picks it up on poll.
toast.success('已排队执行,可在下方运行历史查看进度')
queryClient.invalidateQueries({ queryKey: ['monitorRuns'] })
},
onError: (error: Error) => toast.error(`启动失败:${error.message}`),
})
}
export function useTaskRuns(taskId: number | null) {
return useQuery({
queryKey: ['monitorRuns', taskId],
queryFn: async () => (await monitorApi.getRuns(taskId as number)).data.runs,
enabled: taskId !== null,
refetchInterval: POLL_MS,
})
}
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorNotes', platform, taskId, onlyNew],
queryFn: async () =>
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data.notes,
refetchInterval: POLL_MS,
})
}
export function useNoteSeries(noteId: string | null, taskId: number | null) {
return useQuery({
queryKey: ['monitorNoteSeries', noteId, taskId],
queryFn: async () => (await monitorApi.getNoteSeries(noteId as string, taskId ?? undefined)).data.series,
enabled: noteId !== null,
staleTime: 30_000,
})
}
export function useMonitorComments(taskId: number | null, noteId?: string | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorComments', platform, taskId, noteId ?? null],
queryFn: async () =>
(await monitorApi.getComments(taskId ?? undefined, noteId ?? undefined, 200, platform)).data
.comments,
refetchInterval: POLL_MS,
})
}
export function useMonitorCommentsGrouped(taskId: number | null, noteId?: string | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCommentsGrouped', platform, taskId, noteId ?? null],
queryFn: async () =>
(await monitorApi.getCommentsGrouped(taskId ?? undefined, noteId ?? undefined, 200, platform))
.data.groups,
refetchInterval: POLL_MS,
})
}
export function useCommentNoteOptions(taskId: number | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCommentNotes', platform, taskId],
queryFn: async () =>
(await monitorApi.getCommentNotes(taskId ?? undefined, platform)).data.notes,
refetchInterval: POLL_MS,
})
}
export function useMonitorEvents(taskId: number | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorEvents', platform, taskId],
queryFn: async () =>
(await monitorApi.getEvents(taskId ?? undefined, 200, platform)).data.events,
refetchInterval: POLL_MS,
})
}
export function useMarkEventsRead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (taskId?: number) => monitorApi.markEventsRead(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitorEvents'] })
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
})
}
export function useCookieStatus() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCookie', platform],
queryFn: async () => (await monitorApi.getCookieStatus(platform)).data,
refetchInterval: 15000,
})
}
export function useSetCookie() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: (cookie: string) => monitorApi.setCookie(cookie, platform),
onSuccess: () => {
toast.success('Cookie 已保存')
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
export function useClearCookie() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: () => monitorApi.clearCookie(platform),
onSuccess: () => {
toast.success('Cookie 已清除')
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
},
})
}
// --- QR login (over CDP) ---------------------------------------------------
/** Polls only while a code is on screen; an idle session has nothing to watch. */
export function useQrLoginStatus(polling: boolean) {
return useQuery({
queryKey: ['monitorQrLogin'],
queryFn: async () => (await monitorApi.getQrLogin()).data,
refetchInterval: polling ? 2000 : false,
})
}
export function useStartQrLogin() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: () => monitorApi.startQrLogin(platform),
onSuccess: (response) => {
queryClient.setQueryData(['monitorQrLogin'], response.data)
},
onError: (error: Error) => {
// The backend's reason -- "Chrome is not reachable on ...", "no QR on the
// page" -- is the entire value here, and axios would flatten it to
// "Request failed with status code 502".
const detail = (error as { response?: { data?: { detail?: string } } })?.response
?.data?.detail
toast.error(`获取二维码失败:${detail ?? error.message}`)
},
})
}
export function useCancelQrLogin() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => monitorApi.cancelQrLogin(),
onSuccess: (response) => {
queryClient.setQueryData(['monitorQrLogin'], response.data)
},
})
}
/**
* Whether the browser is actually signed in.
*
* Tracked separately from the QR session above: that session lives in the
* server's memory and a restart erases it, while the profile it wrote to does
* not. Polled slowly when idle and briskly while a code is on screen.
*/
export function useLoginState(polling: boolean) {
return useQuery({
queryKey: ['monitorLoginState'],
queryFn: async () => (await monitorApi.getLoginState()).data,
refetchInterval: polling ? 4000 : 30000,
refetchOnWindowFocus: false,
})
}
/** Re-ask after reloading the page, for when the state looks stale. */
export function useRecheckLogin() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => monitorApi.getLoginState(true),
onSuccess: (response) => {
queryClient.setQueryData(['monitorLoginState'], response.data)
},
onError: (error: Error) => toast.error(`检测失败:${error.message}`),
})
}
// --- Settings -------------------------------------------------------------
export function useSettings() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['settings', platform],
queryFn: async () => (await settingsApi.getAll(platform)).data,
staleTime: 30_000,
})
}
export function useUpdateSettings() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: (values: Record<string, unknown>) => settingsApi.update(values, platform),
onSuccess: (response) => {
toast.success(`已保存 ${response.data.changed.length} 项设置`)
queryClient.invalidateQueries({ queryKey: ['settings'] })
// Cookie / webhook panels read their own keys.
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
// --- Report ---------------------------------------------------------------
export function useReport(taskIds: number[], startDate: string, endDate: string) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorReport', platform, taskIds, startDate, endDate],
queryFn: async () =>
(await monitorApi.getReport(taskIds, startDate, endDate, platform)).data,
// Reports are read-only aggregates; no need to poll them every few seconds.
staleTime: 30_000,
})
}
// --- WeCom webhook --------------------------------------------------------
export function useWebhookStatus() {
return useQuery({
queryKey: ['monitorWebhook'],
queryFn: async () => (await monitorApi.getWebhook()).data,
refetchInterval: 30_000,
})
}
export function useSetWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (url: string) => monitorApi.setWebhook(url),
onSuccess: () => {
toast.success('Webhook 已保存')
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
export function useClearWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => monitorApi.clearWebhook(),
onSuccess: () => {
toast.success('Webhook 已删除')
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
})
}
export function useTestWebhook() {
return useMutation({
mutationFn: (url?: string) => monitorApi.testWebhook(url),
onSuccess: () => toast.success('测试消息已发送,请查看企业微信群'),
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}`),
})
}