两处显示问题(来自线上页面核对): 1. 耗时列:被重启打断的轮次,finished_at 是 cleanup_orphan_runs 在重启后补上的, 于是「耗时」把停机时间也算进去了(线上出现过 235s / 687s 这种假数字)。 改为只有真正扫描过数据的轮次才计算耗时,其余显示 '-' —— 一轮就报错的 (比如历史上的 SQL 语法错误)同理。 2. 下次运行:两个作业串行跑在同一个调度线程里,前一个耗时长时后一个会排队, next_run_at 因此会停在「已经过了」的时刻;直接显示一个过去时间看起来像故障。 改为:未启用 → 「未启用」;已过期 → 「排队中(<时间> 到期,等待前序作业让出 线程)」,未到期照常显示。
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import type { ReactNode } from "react";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Descriptions,
|
||
Form,
|
||
InputNumber,
|
||
Popconfirm,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} from "antd";
|
||
import type { ColumnsType } from "antd/es/table";
|
||
import { ReloadOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||
import { message } from "../utils/antdMessage";
|
||
import { getErrorMessage } from "../utils/error";
|
||
import {
|
||
automationApi,
|
||
type AutomationJobItem,
|
||
type AutomationManualGroup,
|
||
type AutomationManualSample,
|
||
type AutomationRunItem,
|
||
} from "../api/modules";
|
||
import { usePermissions } from "../hooks/usePermissions";
|
||
import { formatTime } from "../utils/time";
|
||
|
||
const STATUS_TAG: Record<string, { color: string; text: string }> = {
|
||
success: { color: "green", text: "成功" },
|
||
failed: { color: "red", text: "失败" },
|
||
running: { color: "blue", text: "执行中" },
|
||
skipped: { color: "default", text: "已跳过" },
|
||
};
|
||
|
||
/**
|
||
* 下次运行时间:两个作业串行跑在同一个调度线程里,前一个作业耗时长时,
|
||
* 后一个会排队等待 —— 于是 next_run_at 会停在「已经过了」的时刻。
|
||
* 直接显示一个过去时间看起来像故障,这里标明「排队中」。
|
||
*/
|
||
function nextRunLabel(job: AutomationJobItem): ReactNode {
|
||
if (!job.next_run_at) {
|
||
return <Typography.Text type="secondary">未启用</Typography.Text>;
|
||
}
|
||
const next = new Date(job.next_run_at).getTime();
|
||
if (!Number.isFinite(next)) return formatTime(job.next_run_at);
|
||
if (next > Date.now()) return formatTime(job.next_run_at);
|
||
return (
|
||
<span>
|
||
<Typography.Text type="warning">排队中</Typography.Text>{" "}
|
||
<Typography.Text type="secondary">
|
||
({formatTime(job.next_run_at)} 到期,等待前序作业让出线程)
|
||
</Typography.Text>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function secondsToHours(seconds: number): number {
|
||
return Math.round((seconds / 3600) * 10) / 10;
|
||
}
|
||
|
||
export default function AutomationPage() {
|
||
const [jobs, setJobs] = useState<AutomationJobItem[]>([]);
|
||
const [schedulerEnabled, setSchedulerEnabled] = useState(true);
|
||
const [schedulerRunning, setSchedulerRunning] = useState(false);
|
||
const [runs, setRuns] = useState<AutomationRunItem[]>([]);
|
||
const [manualGroups, setManualGroups] = useState<AutomationManualGroup[]>([]);
|
||
const [manualTotal, setManualTotal] = useState(0);
|
||
const [pendingRelogin, setPendingRelogin] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||
const [runningKey, setRunningKey] = useState<string | null>(null);
|
||
const [forms] = Form.useForm();
|
||
const { can } = usePermissions();
|
||
const canManage = can("automation:manage");
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await automationApi.jobs();
|
||
setJobs(data.jobs);
|
||
setPendingRelogin(data.pending_relogin ?? 0);
|
||
setSchedulerEnabled(data.scheduler.enabled);
|
||
setSchedulerRunning(data.scheduler.running);
|
||
const history = await automationApi.runs({ limit: 50 });
|
||
setRuns(history.items);
|
||
const manual = await automationApi.manualQueue();
|
||
setManualGroups(manual.groups);
|
||
setManualTotal(manual.total);
|
||
// 统一回填表单:initialValue 只在首次渲染生效,刷新后不会跟着变。
|
||
forms.setFieldsValue(
|
||
Object.fromEntries(
|
||
data.jobs.map((job) => [
|
||
job.key,
|
||
{
|
||
enabled: job.enabled,
|
||
interval_hours: secondsToHours(job.interval_seconds),
|
||
batch_size: job.batch_size,
|
||
concurrency: job.concurrency,
|
||
},
|
||
]),
|
||
),
|
||
);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [forms]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
const saveJob = async (job: AutomationJobItem) => {
|
||
const values = forms.getFieldValue(job.key) || {};
|
||
setSavingKey(job.key);
|
||
try {
|
||
await automationApi.updateJob(job.key, {
|
||
enabled: values.enabled,
|
||
interval_seconds: Math.max(
|
||
Math.round((values.interval_hours ?? 6) * 3600),
|
||
300,
|
||
),
|
||
batch_size: values.batch_size,
|
||
concurrency: values.concurrency,
|
||
});
|
||
message.success(`${job.title} 配置已保存`);
|
||
await load();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setSavingKey(null);
|
||
}
|
||
};
|
||
|
||
const runOnce = async (job: AutomationJobItem) => {
|
||
setRunningKey(job.key);
|
||
try {
|
||
const outcome = await automationApi.runJob(job.key);
|
||
message.success(
|
||
`跑完一轮:扫描 ${outcome.scanned} 条,有效 ${outcome.valid},失效 ${outcome.invalid}`,
|
||
);
|
||
await load();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setRunningKey(null);
|
||
}
|
||
};
|
||
|
||
const manualColumns: ColumnsType<AutomationManualSample> = [
|
||
{ title: "记录", dataIndex: "task_id", width: 80 },
|
||
{
|
||
title: "账号",
|
||
dataIndex: "account_username",
|
||
width: 160,
|
||
ellipsis: true,
|
||
},
|
||
{ title: "已重试", dataIndex: "relogin_attempts", width: 80 },
|
||
{
|
||
title: "失败原因",
|
||
dataIndex: "last_error",
|
||
ellipsis: true,
|
||
render: (value: string) => value || "-",
|
||
},
|
||
{
|
||
title: "最近尝试",
|
||
dataIndex: "attempt_at",
|
||
width: 160,
|
||
render: (value: string | null) => formatTime(value),
|
||
},
|
||
];
|
||
|
||
const runColumns: ColumnsType<AutomationRunItem> = [
|
||
{
|
||
title: "开始时间",
|
||
dataIndex: "started_at",
|
||
width: 180,
|
||
render: (value: string | null) => formatTime(value),
|
||
},
|
||
{
|
||
title: "作业",
|
||
dataIndex: "job_key",
|
||
width: 120,
|
||
},
|
||
{
|
||
title: "结果",
|
||
dataIndex: "status",
|
||
width: 90,
|
||
render: (value: string) => {
|
||
const tag = STATUS_TAG[value] ?? {
|
||
color: "default",
|
||
text: value || "-",
|
||
};
|
||
return <Tag color={tag.color}>{tag.text}</Tag>;
|
||
},
|
||
},
|
||
{ title: "扫描", dataIndex: "scanned", width: 80 },
|
||
{ title: "有效", dataIndex: "valid", width: 80 },
|
||
{
|
||
title: "失效",
|
||
dataIndex: "invalid",
|
||
width: 80,
|
||
render: (value: number) =>
|
||
value > 0 ? (
|
||
<Typography.Text type="warning">{value}</Typography.Text>
|
||
) : (
|
||
0
|
||
),
|
||
},
|
||
{
|
||
title: "耗时",
|
||
width: 90,
|
||
render: (_, row) =>
|
||
// 没实际扫描过的轮次没有真实耗时:被中断的记录 finished_at 是重启后
|
||
// 补上的(见 cleanup_orphan_runs),差值里混入了停机时间,是个假数字;
|
||
// 一轮就报错的(如 SQL 语法错误)同理。
|
||
row.scanned > 0 && row.finished_at
|
||
? formatDuration(row.started_at, row.finished_at)
|
||
: "-",
|
||
},
|
||
{ title: "说明", dataIndex: "message", ellipsis: true },
|
||
];
|
||
|
||
return (
|
||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||
<Card
|
||
title="自动巡检"
|
||
extra={
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
}
|
||
>
|
||
{!schedulerEnabled && (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="调度器总开关未打开"
|
||
description="服务端环境变量 AUTOMATION_ENABLED 未开启,打开下面的作业开关不会真正执行。需要先打开总开关并重启服务,再回到本页打开作业。"
|
||
/>
|
||
)}
|
||
{schedulerEnabled && !schedulerRunning && (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="调度线程未运行"
|
||
description="总开关已开但调度线程不在运行状态,请检查服务日志或重启服务。"
|
||
/>
|
||
)}
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="巡检只检测、不改 CK;发现失效后由「CK 自动重登」接手"
|
||
description="两个作业是联动的上下游:巡检把失效账号标记为 invalid,「CK 自动重登」的候选条件正是这个标记,所以巡检一发现失效,重登作业在下一个周期就会把它救回来(需打开重登开关)。刻意分成两个作业是为了止损粒度 —— 巡检只读、零风险;重登会真实登录(消耗邮箱验证码、可能遇到极验),出问题可单独关掉重登而不影响检测。巡检本身不会替换任何 Cookie、也不会改账号状态。"
|
||
/>
|
||
|
||
{jobs.map((job) => (
|
||
<Card
|
||
key={job.key}
|
||
type="inner"
|
||
title={job.title}
|
||
extra={
|
||
job.key === "ck_relogin" ? (
|
||
<Typography.Text type="secondary">
|
||
上游:巡检标记为失效且有 CK 的账号,当前待重登{" "}
|
||
<Typography.Text strong>{pendingRelogin}</Typography.Text> 个
|
||
</Typography.Text>
|
||
) : job.key === "ck_health" ? (
|
||
<Typography.Text type="secondary">
|
||
下游:CK 自动重登(发现失效后自动接手)
|
||
</Typography.Text>
|
||
) : null
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="最近运行">
|
||
{formatTime(job.last_run_at)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="下次运行">
|
||
{nextRunLabel(job)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最近结果">
|
||
{job.last_status ? (
|
||
<Tag color={STATUS_TAG[job.last_status]?.color ?? "default"}>
|
||
{STATUS_TAG[job.last_status]?.text ?? job.last_status}
|
||
</Tag>
|
||
) : (
|
||
"-"
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最近说明">
|
||
{job.last_message || "-"}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
<Form form={forms} layout="inline" onFinish={() => saveJob(job)}>
|
||
<Form.Item
|
||
name={[job.key, "enabled"]}
|
||
label="启用"
|
||
valuePropName="checked"
|
||
>
|
||
<Switch disabled={!canManage} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name={[job.key, "interval_hours"]}
|
||
label="单账号重检间隔(小时)"
|
||
>
|
||
<InputNumber min={0.1} step={1} disabled={!canManage} />
|
||
</Form.Item>
|
||
<Form.Item name={[job.key, "batch_size"]} label="每轮条数">
|
||
<InputNumber min={1} max={2000} disabled={!canManage} />
|
||
</Form.Item>
|
||
<Form.Item name={[job.key, "concurrency"]} label="并发">
|
||
<InputNumber min={1} max={16} disabled={!canManage} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button
|
||
type="primary"
|
||
htmlType="submit"
|
||
disabled={!canManage}
|
||
loading={savingKey === job.key}
|
||
>
|
||
保存
|
||
</Button>
|
||
<Popconfirm
|
||
title="立刻跑一轮?"
|
||
description="不看启用开关,立即执行一次巡检。"
|
||
onConfirm={() => runOnce(job)}
|
||
disabled={!canManage}
|
||
>
|
||
<Button
|
||
icon={<ThunderboltOutlined />}
|
||
disabled={!canManage}
|
||
loading={runningKey === job.key}
|
||
>
|
||
立即跑一次
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
{!canManage && (
|
||
<Typography.Text type="secondary">
|
||
当前账号只能查看,修改配置需要 automation:manage 权限。
|
||
</Typography.Text>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</Card>
|
||
|
||
<Card
|
||
title="待人工处理"
|
||
extra={
|
||
<Typography.Text type="secondary">
|
||
自动重登救不了的类型(共 {manualTotal} 个)
|
||
</Typography.Text>
|
||
}
|
||
>
|
||
{manualGroups.length === 0 ? (
|
||
<Typography.Text type="secondary">
|
||
暂无 —— 能自动重试的都会自己重试,需要人工的才出现在这里。
|
||
</Typography.Text>
|
||
) : (
|
||
manualGroups.map((group) => (
|
||
<Card
|
||
key={group.kind || "exhausted"}
|
||
type="inner"
|
||
title={`${group.label}(${group.count} 个)`}
|
||
style={{ marginBottom: 12 }}
|
||
>
|
||
<Table
|
||
rowKey="task_id"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={group.samples}
|
||
columns={manualColumns}
|
||
/>
|
||
</Card>
|
||
))
|
||
)}
|
||
</Card>
|
||
|
||
<Card title="运行历史">
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
loading={loading}
|
||
columns={runColumns}
|
||
dataSource={runs}
|
||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||
/>
|
||
</Card>
|
||
</Space>
|
||
);
|
||
}
|
||
|
||
function formatDuration(start: string | null, end: string | null): string {
|
||
if (!start || !end) return "-";
|
||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||
if (!Number.isFinite(ms) || ms < 0) return "-";
|
||
if (ms < 1000) return `${ms}ms`;
|
||
return `${(ms / 1000).toFixed(1)}s`;
|
||
}
|