feat(site): 站点拨测支持接口返回内容监控(文本 + JSON 字段)
需求:监控接口返回的数据本身是否变化(如 Cloudflare ips-v4 的 IP 段清单),
而不是只看服务是否可用。两者是不同的问题:服务 200 OK 但内容变了,拨测发现不了。
后端
- SiteTask 新增 content_check_mode(off/text/json)、content_paths(JSONPath)、
content_ignore_rules、content_min_length、content_confirm_times、content_sort_lines
- 新增 monitor_site_baselines(基线多版本)与 monitor_site_content_changes(变更记录)
- pkg/siteprobe/content.go:零依赖 JSONPath($.a.b[0]、$.a[*].b)、文本/JSON 规范化、
sha256 哈希、LCS 行级 diff、字段级 diff
- 核心判定区分「采集异常」与「内容变更」:状态码非 2xx、空响应、短于最小长度、
非合法 JSON 均不算变更,避免 CDN 拦截页造成误报
- 连续 N 次(默认 2)不一致才落变更记录,抑制抖动
- 基线首次自动建立(用户选择自动接受),之后变更需人工「接受为新基线」并留审计
- 补齐历史遗留死字段:expected_content(正则断言)与 expected_length 此前
只有表结构、执行器从未校验,现已实现
- 告警引擎新增 content_changed 指标,人工处理后自动恢复
- 迁移 0006 用 AutoMigrate 实现幂等(空库 0000 已建表,静态 ALTER 会重复列)
前端
- 任务表单新增内容监控配置区(模式/JSONPath/忽略规则/最小长度/确认次数/排序)
- 详情页新增内容监控面板:当前基线、待确认变更、并排 diff、版本历史、
接受/忽略操作
验证
- 9 项单元测试(JSONPath、时间戳忽略、顺序无关、采集异常、字段级监控、diff)
- 端到端:Cloudflare ips-v4 建基线 v1 → 模拟变更 → 第 2 轮告警 → 接受为 v2
- JSON 模式实测快照仅含指定字段:{"$.db_driver":"mysql","$.version":"v1.0.2"}
// Logan.Li
This commit is contained in:
1 parent
286d7cd906
commit
539db36f2a
18 files changed
+1704
-3
No files matched your search
@@ -2,6 +2,16 @@
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
// 内容监控配置(随任务一起提交) // Logan.Li
|
||||
export interface ContentCheckPayload {
|
||||
content_check_mode?: 'off' | 'text' | 'json';
|
||||
content_paths?: string;
|
||||
content_ignore_rules?: string;
|
||||
content_min_length?: number;
|
||||
content_confirm_times?: number;
|
||||
content_sort_lines?: boolean;
|
||||
}
|
||||
|
||||
export const siteApi = {
|
||||
getStats: async () => http.get('/site/stats'),
|
||||
getTasks: async () => http.get<{ list: unknown[]; total: number }>('/site/tasks'),
|
||||
@@ -9,12 +19,12 @@ export const siteApi = {
|
||||
name: string; url: string; type: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.post('/site/tasks', data),
|
||||
} & ContentCheckPayload) => http.post('/site/tasks', data),
|
||||
updateTask: async (id: number | string, data: {
|
||||
name?: string; url?: string; type?: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.put(`/site/tasks/${id}`, data),
|
||||
} & ContentCheckPayload) => http.put(`/site/tasks/${id}`, data),
|
||||
deleteTask: async (id: number | string) => http.delete(`/site/tasks/${id}`),
|
||||
toggleTask: async (id: number | string, enabled: boolean) =>
|
||||
http.put(`/site/tasks/${id}`, { enabled }),
|
||||
@@ -27,4 +37,19 @@ export const siteApi = {
|
||||
limit: opts?.limit ?? 500,
|
||||
...(opts?.hours ? { hours: opts.hours } : {}),
|
||||
}),
|
||||
};
|
||||
|
||||
// ── 内容监控 ──────────────────────────────────────────────
|
||||
/** 当前基线 + 待确认变更 + 配置,一次拿全供详情页渲染 */
|
||||
getTaskContent: async (id: number | string) => http.get(`/site/tasks/${id}/content`),
|
||||
/** 基线版本历史 */
|
||||
getBaselines: async (id: number | string) => http.get(`/site/tasks/${id}/baselines`),
|
||||
/** 变更记录,可按 status 过滤 */
|
||||
getContentChanges: async (id: number | string, status?: string) =>
|
||||
http.get(`/site/tasks/${id}/changes`, status ? { status } : undefined),
|
||||
/** 接受为新基线(人工确认) */
|
||||
acceptChange: async (changeId: number | string, note?: string) =>
|
||||
http.post(`/site/changes/${changeId}/accept`, { note: note ?? '' }),
|
||||
/** 标记为误报忽略 */
|
||||
ignoreChange: async (changeId: number | string) =>
|
||||
http.post(`/site/changes/${changeId}/ignore`),
|
||||
};
|
||||
@@ -33,11 +33,24 @@ import {
|
||||
interface CreateTaskForm {
|
||||
name: string; url: string; type: ProbeType;
|
||||
frequency_sec: number; timeout_sec: number; expected_status_code: number;
|
||||
// 内容监控 // Logan.Li
|
||||
content_check_mode: 'off' | 'text' | 'json';
|
||||
content_paths: string;
|
||||
content_ignore_rules: string;
|
||||
content_min_length: number;
|
||||
content_confirm_times: number;
|
||||
content_sort_lines: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: CreateTaskForm = {
|
||||
name: '', url: '', type: 'HTTP',
|
||||
frequency_sec: defaultProbeFrequency('HTTP'), timeout_sec: 10, expected_status_code: 200,
|
||||
content_check_mode: 'off',
|
||||
content_paths: '',
|
||||
content_ignore_rules: '',
|
||||
content_min_length: 0,
|
||||
content_confirm_times: 2,
|
||||
content_sort_lines: false,
|
||||
};
|
||||
|
||||
const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
|
||||
@@ -124,6 +137,13 @@ export function SiteMonitor() {
|
||||
frequency_sec: task.frequency_sec || 60,
|
||||
timeout_sec: task.timeout_sec || 10,
|
||||
expected_status_code: task.expected_status_code || 200,
|
||||
// 回填内容监控配置,否则编辑保存会把已配好的规则清空 // Logan.Li
|
||||
content_check_mode: (task.content_check_mode as CreateTaskForm['content_check_mode']) || 'off',
|
||||
content_paths: task.content_paths || '',
|
||||
content_ignore_rules: task.content_ignore_rules || '',
|
||||
content_min_length: task.content_min_length || 0,
|
||||
content_confirm_times: task.content_confirm_times || 2,
|
||||
content_sort_lines: Boolean(task.content_sort_lines),
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
@@ -144,6 +164,13 @@ export function SiteMonitor() {
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
// 内容监控:off 时后端会忽略其余字段并清空基线 // Logan.Li
|
||||
content_check_mode: form.content_check_mode,
|
||||
content_paths: form.content_paths,
|
||||
content_ignore_rules: form.content_ignore_rules,
|
||||
content_min_length: Number(form.content_min_length) || 0,
|
||||
content_confirm_times: Number(form.content_confirm_times) || 2,
|
||||
content_sort_lines: form.content_sort_lines,
|
||||
};
|
||||
try {
|
||||
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success(i18n.t('monitor.sitemonitortsx.se01498ee')); }
|
||||
@@ -388,6 +415,76 @@ export function SiteMonitor() {
|
||||
onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} />
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'HTTP' && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>内容变化监控</Label>
|
||||
<Select
|
||||
value={form.content_check_mode}
|
||||
onValueChange={(v) => setForm({ ...form, content_check_mode: v as CreateTaskForm['content_check_mode'] })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">关闭(只检查可用性)</SelectItem>
|
||||
<SelectItem value="text">文本内容(如 IP 段清单)</SelectItem>
|
||||
<SelectItem value="json">JSON 字段(如 $.data.status)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">
|
||||
开启后,返回内容与基线不同时告警。只有拨测成功才参与比对,避免错误页被误报成"内容变更"。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.content_check_mode === 'json' && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>监控字段(JSONPath,每行一个)</Label>
|
||||
<textarea
|
||||
className="w-full min-h-[64px] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs text-[var(--dg-text-primary)] font-mono"
|
||||
value={form.content_paths}
|
||||
onChange={(e) => setForm({ ...form, content_paths: e.target.value })}
|
||||
placeholder={'$.data.status\n$.data.ips[*].ip'}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">只有列出的字段参与比对,其余字段变化不会告警。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.content_check_mode !== 'off' && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>忽略规则(正则,每行一条)</Label>
|
||||
<textarea
|
||||
className="w-full min-h-[52px] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs text-[var(--dg-text-primary)] font-mono"
|
||||
value={form.content_ignore_rules}
|
||||
onChange={(e) => setForm({ ...form, content_ignore_rules: e.target.value })}
|
||||
placeholder={'"updated_at":\\s*"[^"]*"'}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">用于剔除时间戳、nonce、trace_id 等每次都变的字段。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>最小有效长度</Label>
|
||||
<Input type="number" min={0} value={form.content_min_length}
|
||||
onChange={(e) => setForm({ ...form, content_min_length: Number(e.target.value) || 0 })} />
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">低于此值判为采集异常。</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>连续确认次数</Label>
|
||||
<Input type="number" min={1} max={10} value={form.content_confirm_times}
|
||||
onChange={(e) => setForm({ ...form, content_confirm_times: Number(e.target.value) || 2 })} />
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">连续 N 次不同才告警。</p>
|
||||
</div>
|
||||
</div>
|
||||
{form.content_check_mode === 'text' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.content_sort_lines}
|
||||
onCheckedChange={(v) => setForm({ ...form, content_sort_lines: v })} />
|
||||
<Label>忽略行顺序(清单类接口建议开启)</Label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}> {i18n.t('rbac.jobRole.cancel')} </Button>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 站点拨测 — 内容监控面板:当前基线 / 待确认变更 / 并排 diff / 版本历史。 // Logan.Li
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Loader2, GitCompare, Check, EyeOff, History, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { siteApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface TextDiffLine { type: 'same' | 'add' | 'del'; old?: string; new?: string }
|
||||
interface FieldDiff { path: string; old: string; new: string; kind: 'changed' | 'added' | 'removed' }
|
||||
|
||||
interface Baseline {
|
||||
id: number; version: number; content_hash: string;
|
||||
source: string; accepted_by?: string; note?: string; created_at: string;
|
||||
}
|
||||
interface ContentChange {
|
||||
id: number; from_version: number; diff_type: string;
|
||||
diff_payload: string; status: string; detected_at: string;
|
||||
}
|
||||
|
||||
interface ContentView {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
baseline?: Baseline;
|
||||
pending?: ContentChange;
|
||||
baseline_count: number;
|
||||
pending_change_count: number;
|
||||
has_baseline: boolean;
|
||||
has_pending: boolean;
|
||||
}
|
||||
|
||||
const fmtTime = (iso?: string) => {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
export function SiteContentPanel({ taskId }: { taskId: number }) {
|
||||
const [data, setData] = useState<ContentView | null>(null);
|
||||
const [baselines, setBaselines] = useState<Baseline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const view = (await siteApi.getTaskContent(taskId)) as ContentView;
|
||||
setData(view);
|
||||
if (view?.enabled) {
|
||||
const list = (await siteApi.getBaselines(taskId)) as Baseline[];
|
||||
setBaselines(Array.isArray(list) ? list : []);
|
||||
}
|
||||
} catch {
|
||||
/* 面板为增强信息,失败时不打断详情页 */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>, okMsg: string) => {
|
||||
setActing(true);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(okMsg);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData)?.message as string || '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-6 text-center text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 未启用内容监控时整块隐藏,避免干扰普通拨测任务的详情页。
|
||||
if (!data?.enabled) return null;
|
||||
|
||||
const diffLines: TextDiffLine[] = (() => {
|
||||
if (data.pending?.diff_type !== 'text_line' || !data.pending?.diff_payload) return [];
|
||||
try { return JSON.parse(data.pending.diff_payload) as TextDiffLine[]; } catch { return []; }
|
||||
})();
|
||||
const fieldDiffs: FieldDiff[] = (() => {
|
||||
if (data.pending?.diff_type !== 'json_field' || !data.pending?.diff_payload) return [];
|
||||
try { return JSON.parse(data.pending.diff_payload) as FieldDiff[]; } catch { return []; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<GitCompare className="h-4 w-4" />
|
||||
内容监控
|
||||
<Badge variant="outline" className="text-[10px]">{data.mode === 'json' ? 'JSON 字段' : '文本'}</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setShowHistory((v) => !v)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
基线 v{data.baseline?.version ?? '-'}({data.baseline_count} 版)
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={load}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!data.has_baseline && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
尚未采集到基线,下一轮拨测完成后会自动建立 v1。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.has_baseline && !data.has_pending && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
内容与基线一致(v{data.baseline?.version},采集于 {fmtTime(data.baseline?.created_at)})。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.has_pending && data.pending && (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-xs">
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
检测到内容变更
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)] ml-2">
|
||||
v{data.pending.from_version} → 当前 · {fmtTime(data.pending.detected_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="h-7" disabled={acting}
|
||||
onClick={() => act(() => siteApi.acceptChange(data.pending!.id), '已接受为新基线')}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />接受为新基线
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7" disabled={acting}
|
||||
onClick={() => act(() => siteApi.ignoreChange(data.pending!.id), '已忽略该变更')}>
|
||||
<EyeOff className="h-3.5 w-3.5 mr-1" />忽略
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fieldDiffs.length > 0 && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">字段</TableHead>
|
||||
<TableHead className="h-8 text-xs">变更前</TableHead>
|
||||
<TableHead className="h-8 text-xs">变更后</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldDiffs.map((f, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="py-1 text-xs font-mono">{f.path}</TableCell>
|
||||
<TableCell className="py-1 text-xs font-mono text-[var(--dg-text-muted)] break-all">
|
||||
{f.old || <span className="italic">(无)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 text-xs font-mono dg-text-yellow break-all">
|
||||
{f.new || <span className="italic">(已删除)</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffLines.length > 0 && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-x-auto max-h-[380px] overflow-y-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<tbody>
|
||||
{diffLines.map((d, i) => {
|
||||
const isAdd = d.type === 'add';
|
||||
const isDel = d.type === 'del';
|
||||
return (
|
||||
<tr key={i}
|
||||
className={isAdd ? 'bg-[var(--dg-green)]/10' : isDel ? 'bg-[var(--dg-red)]/10' : ''}>
|
||||
<td className="w-6 text-center select-none text-[var(--dg-text-muted)]">
|
||||
{isAdd ? '+' : isDel ? '−' : ''}
|
||||
</td>
|
||||
<td className={`px-2 py-0.5 whitespace-pre-wrap break-all ${isDel ? 'dg-text-red line-through' : ''}`}>
|
||||
{isDel ? d.old : d.new}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showHistory && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">版本</TableHead>
|
||||
<TableHead className="h-8 text-xs">来源</TableHead>
|
||||
<TableHead className="h-8 text-xs">确认人</TableHead>
|
||||
<TableHead className="h-8 text-xs">时间</TableHead>
|
||||
<TableHead className="h-8 text-xs">备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{baselines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-3 text-center text-xs text-[var(--dg-text-muted)]">暂无基线</TableCell>
|
||||
</TableRow>
|
||||
) : baselines.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="py-1 text-xs">
|
||||
v{b.version}
|
||||
{b.id === data.baseline?.id && (
|
||||
<Badge variant="outline" className="ml-1 text-[9px]">当前</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 text-xs">{b.source === 'manual' ? '人工接受' : '自动建立'}</TableCell>
|
||||
<TableCell className="py-1 text-xs">{b.accepted_by || '-'}</TableCell>
|
||||
<TableCell className="py-1 text-xs">{fmtTime(b.created_at)}</TableCell>
|
||||
<TableCell className="py-1 text-xs text-[var(--dg-text-muted)]">{b.note || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { SiteTask } from './helpers';
|
||||
import { fmtFrequency, normalizeProbeType } from './helpers';
|
||||
import { SiteResultsPanel } from './SiteResultsPanel';
|
||||
import { SiteContentPanel } from './SiteContentPanel';
|
||||
|
||||
interface Props {
|
||||
task: SiteTask;
|
||||
@@ -33,6 +34,9 @@ export function SiteTaskDetailPage({ task, onBack, onProbed }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容监控:仅在任务启用时渲染,未启用时组件内部返回 null // Logan.Li */}
|
||||
<SiteContentPanel taskId={task.id} />
|
||||
|
||||
<SiteResultsPanel task={task} onProbed={onProbed} fullPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,15 @@ export interface SiteTask {
|
||||
created_at: string;
|
||||
ssl_days_remaining?: number;
|
||||
ssl_cert_expiry?: string;
|
||||
// 内容监控 // Logan.Li
|
||||
content_check_mode?: string;
|
||||
content_paths?: string;
|
||||
content_ignore_rules?: string;
|
||||
content_min_length?: number;
|
||||
content_confirm_times?: number;
|
||||
content_sort_lines?: boolean;
|
||||
current_baseline_id?: number;
|
||||
pending_change_count?: number;
|
||||
}
|
||||
|
||||
export interface SiteResult {
|
||||
|
||||
Reference in new issue
Block a user