Files
live-hub-py/web/frontend/src/pages/AutomationPage.tsx
T
yml2213 847ed4539a fix(ui): 运行历史的假耗时、以及「下次运行」显示成过去时间
两处显示问题(来自线上页面核对):

1. 耗时列:被重启打断的轮次,finished_at 是 cleanup_orphan_runs 在重启后补上的,
   于是「耗时」把停机时间也算进去了(线上出现过 235s / 687s 这种假数字)。
   改为只有真正扫描过数据的轮次才计算耗时,其余显示 '-' —— 一轮就报错的
   (比如历史上的 SQL 语法错误)同理。

2. 下次运行:两个作业串行跑在同一个调度线程里,前一个耗时长时后一个会排队,
   next_run_at 因此会停在「已经过了」的时刻;直接显示一个过去时间看起来像故障。
   改为:未启用 → 「未启用」;已过期 → 「排队中(<时间> 到期,等待前序作业让出
   线程)」,未到期照常显示。
2026-10-02 21:51:37 +08:00

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;
}