本仓库在上游 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 用的还是旧镜像。
385 lines
13 KiB
TypeScript
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}`),
|
|
})
|
|
}
|