diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py
index 5d641070..a7d08b7f 100644
--- a/backend/app/api/schedules.py
+++ b/backend/app/api/schedules.py
@@ -349,7 +349,22 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也
- 给 null:运行详情里同名的入口比的是另一次。"""
+ 给 null:运行详情里同名的入口比的是另一次。
+
+ inputs 只在目标是数据源维护时给:{steps, jobs: [{step, kind, id, tables, scope?}], skipped: [{step, reason}]}——这一次
+ 建了哪几个探查、剖析任务,哪几步没执行和原因(app/data/source_maintenance 记在触发上的那一份)。维护没有运行,触发历史、
+ 「上次触发」和立即运行的答复靠它写出建了什么,不写「未发起运行」。别的目标给 null:工作流触发的 inputs 是按模板渲染的
+ 工作流输入,触发历史不列。"""
+ maintenance = set((await session.execute(select(Schedule.id).where(
+ Schedule.id.in_({f.schedule_id for f in fires}), Schedule.target_kind == "source_maintenance"))).scalars()) \
+ if fires else set()
+
+ def inputs_of(f: ScheduleFire) -> dict[str, Any] | None:
+ if f.schedule_id not in maintenance:
+ return None
+ raw = f.inputs if isinstance(f.inputs, dict) else {}
+ return {key: raw[key] for key in ("steps", "jobs", "skipped") if key in raw}
+
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
compares = await auto_compare.briefs(list(compared.values()))
stale = await auto_compare.outdated(session, compares)
@@ -389,6 +404,7 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
"run_status": current.get(f.run_id, (None, None))[0] if f.run_id else None,
# 目标是评测题集时这一次触发建的那次评测(没有运行 id)
"eval_run_id": f.eval_run_id,
+ "inputs": inputs_of(f),
"reason": None if f.id in launching else f.reason,
"notified": notified.get(f.id, []),
"compare": compare_of(f),
diff --git a/backend/tests/test_maintenance_fire_history.py b/backend/tests/test_maintenance_fire_history.py
new file mode 100644
index 00000000..1d49b235
--- /dev/null
+++ b/backend/tests/test_maintenance_fire_history.py
@@ -0,0 +1,92 @@
+"""定时任务页的触发历史认得数据源维护(复核 W4R-05)。
+
+数据源维护的一次触发建的是探查、剖析任务,没有运行:触发记录的 inputs({steps, jobs, skipped},app/data/source_maintenance)
+要交到触发历史、页头的「上次触发」和「立即运行」的答复里,界面才写得出建了哪几个任务、哪几步没执行,不再写「未发起运行」。
+工作流目标的触发不给 inputs(那是按模板渲染的工作流输入,触发历史不列)。
+
+库、表、名字都是虚构的。
+"""
+from __future__ import annotations
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy import delete
+
+from app.db.base import SessionLocal, utcnow
+from app.db.models import Schedule, ScheduleFire
+from app.main import app
+from app.scheduler import service
+from tests.fixtures.sources import drop_source
+from tests.test_source_maintenance import _schedule, _source, _wait_jobs
+
+
+@pytest.fixture
+async def client():
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ yield c
+
+
+@pytest.fixture
+async def made():
+ ids: list[str] = []
+ yield ids
+ async with SessionLocal() as session:
+ await session.execute(delete(Schedule).where(Schedule.id.in_(ids)))
+ await session.commit()
+
+
+async def test_a_maintenance_fire_carries_the_jobs_it_started_and_the_steps_not_run(client, tmp_path, made):
+ sid = await _source(client, tmp_path / "shop.db", profile=False)
+ try:
+ schedule_id = await _schedule(sid)
+ made.append(schedule_id)
+ ran = (await client.post(f"/api/schedules/{schedule_id}/run")).json()
+ fire = ran["fire"]
+ assert ran["run_id"] is None and fire["status"] == "started" and fire["run_id"] is None
+ jobs = fire["inputs"]["jobs"]
+ assert [j["kind"] for j in jobs] == ["introspect"] and jobs[0]["id"]
+ assert {s["step"] for s in fire["inputs"]["skipped"]} == {"profile_needed", "observe"}
+ async with SessionLocal() as session:
+ await _wait_jobs(await session.get(ScheduleFire, fire["id"]))
+
+ listed = (await client.get(f"/api/schedules/{schedule_id}/fires")).json()["items"]
+ assert [f["id"] for f in listed] == [fire["id"]]
+ assert listed[0]["inputs"] == fire["inputs"]
+ assert set(listed[0]["inputs"]) == {"steps", "jobs", "skipped"}
+ head = (await client.get(f"/api/schedules/{schedule_id}")).json()
+ assert head["last_fire"]["inputs"]["jobs"] == jobs
+ rows = (await client.get("/api/schedules")).json()["items"]
+ assert [s["last_fire"]["inputs"]["jobs"] for s in rows if s["id"] == schedule_id] == [jobs]
+ finally:
+ await drop_source(sid)
+
+
+async def test_a_workflow_fire_does_not_list_its_rendered_inputs(client, made):
+ async with SessionLocal() as session:
+ row = Schedule(name="虚构周报 · 每周", cron="0 9 * * 1", timezone="Asia/Shanghai",
+ **service.target_columns("workflow", "wf-fictional"), input_template={"date": "{{ schedule.yesterday }}"})
+ session.add(row)
+ await session.flush()
+ session.add(ScheduleFire(schedule_id=row.id, fire_at=utcnow().replace(microsecond=0), kind="manual",
+ status="failed_to_start", reason="虚构的原因", inputs={"date": "2026-10-15"}))
+ await session.commit()
+ made.append(row.id)
+ [got] = (await client.get(f"/api/schedules/{row.id}/fires")).json()["items"]
+ assert got["inputs"] is None
+
+
+def test_the_steps_and_their_names_on_the_schedules_page_match_the_server():
+ """定时任务页照服务端的步骤和叫法写(types.ts 的 ScheduleMaintenanceStep、SCHEDULE_TEXT.maintenanceStep):服务端加了一步,
+ 界面没跟上就写出英文原值。直接读两份 .ts 比(同 test_frontend_type_unions 的写法)。"""
+ import re
+ from pathlib import Path
+
+ from app.data.source_maintenance import STEP_LABEL, STEPS
+
+ src = Path(__file__).resolve().parents[2] / "frontend" / "src"
+ types = (src / "types.ts").read_text(encoding="utf-8")
+ union = re.search(r"export type ScheduleMaintenanceStep =([^\n]*)", types)
+ assert union is not None and re.findall(r"'([^']+)'", union.group(1)) == list(STEPS)
+ terms = (src / "lib" / "terms.ts").read_text(encoding="utf-8")
+ labels = re.search(r"\n\s*maintenanceStep: \{([^}]*)\}", terms)
+ assert labels is not None and dict(re.findall(r"(\w+): '([^']+)'", labels.group(1))) == STEP_LABEL
diff --git a/backend/tests/test_schedules_api.py b/backend/tests/test_schedules_api.py
index 10b623f1..e3d5f753 100644
--- a/backend/tests/test_schedules_api.py
+++ b/backend/tests/test_schedules_api.py
@@ -43,8 +43,8 @@ SCHEDULE_KEYS = {"id", "name", "workflow_id", "workflow_name", "cron", "timezone
# 定时的目标(next-round X4):种类、id、叫法、现在的名字、还在不在
"target_kind", "target_id", "target_label", "target_name", "target_exists"}
FIRE_KEYS = {"id", "fire_at", "kind", "status", "run_id", "run_status", "reason", "notified", "compare", "created_at",
- # 目标是评测题集时这一次触发建的那次评测
- "eval_run_id"}
+ # 目标是评测题集时这一次触发建的那次评测;目标是数据源维护时这一次建了哪些任务、哪几步没执行(别的目标为 null)
+ "eval_run_id", "inputs"}
@pytest.fixture(autouse=True)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 28259d5f..083cb89d 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -3953,6 +3953,29 @@ export const SCHEDULE_TEXT = {
evalStarted: '已发起一次评测',
/** 触发历史里评测题集那一次:评测的编号 */
evalFire: (short: string) => `评测 #${short}`,
+ /**
+ * 数据源维护的一次触发:建的是探查、剖析任务,没有运行。和数据源页「定时维护」的「最近一次」同一个写法(步骤的叫法同服务端
+ * source_maintenance.STEP_LABEL)
+ */
+ maintenanceStep: { introspect: '探查结构', profile_needed: '剖析需要的表', observe: '取值观察' } as Record<
+ import('../types').ScheduleMaintenanceStep, string>,
+ maintenanceJobKind: { introspect: '探查任务', profile: '剖析任务' } as Record<'introspect' | 'profile', string>,
+ maintenanceJobs: '建了任务',
+ maintenanceJob: (step: string, kind: string, id: string, tables: number | null) =>
+ `${step}:${kind} #${id.slice(0, 6)}${tables != null ? `(${formatNumber(tables)} 张表)` : ''}`,
+ maintenanceJobTitle: {
+ introspect: '去数据源页看这个数据源的探查结果',
+ profile: '去数据目录看这个剖析任务的进度和结果',
+ } as Record<'introspect' | 'profile', string>,
+ maintenanceSkipped: (step: string, reason: string) => `${step}没有执行:${reason}`,
+ /** 维护没有建任务(发起失败、跳过):不写「未发起运行」,维护本来就不发起运行 */
+ maintenanceNoJobs: '没有建任务',
+ /** 刚触发、任务还在建(服务端按已发起给,任务还没记上) */
+ maintenanceLaunching: '正在建维护任务',
+ maintenanceStarted: '已开始维护',
+ maintenanceBusy: '上一次维护(或别的探查、剖析)还没做完,这一次已跳过',
+ maintenanceFailed: (reason: string | null) => (reason ? `没有开始维护:${reason}` : '没有开始维护'),
+ maintenanceStartedHint: '已建好这一次维护的任务',
runStarted: '已发起运行',
runSkipped: '上一次运行尚未结束,本次已按重叠策略跳过',
runFailed: (reason: string | null) => (reason ? `未能发起运行:${reason}` : '未能发起运行'),
diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx
index ffc2a77f..d6f8a67e 100644
--- a/frontend/src/pages/schedules/FireHistory.tsx
+++ b/frontend/src/pages/schedules/FireHistory.tsx
@@ -7,13 +7,15 @@ import { formatDateTime, parseServerTime, shortId } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types'
import { comparePath, type CompareVia } from '../runs/compare/entry'
-import { FireKindChip, FireStatusChip, ZonedTime } from './parts'
+import { maintenanceJobPath, maintenanceStepText } from './model'
+import { FireKindChip, FireStatusChip, ZonedTime, targetKindOf } from './parts'
const PAGE = 20
/**
* 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败
- * 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给
+ * 写原因;发起了的链到那次运行、写运行现在的状态(目标是评测题集的链到那次评测;目标是数据源维护的没有运行,逐个列出建了的
+ * 探查、剖析任务和没执行的步骤,见 MaintenanceFire);运行成功结束后服务端和同一任务上一次成功的运行比过的,另给
* 「与上一次对比」(基准不是紧挨着的上一次时写「与上一次成功的运行对比」);发出去的通知逐条写结果。refreshKey 变了
* (立即运行之后)从头重取。
*
@@ -96,7 +98,7 @@ export function FireHistory({ schedule, refreshKey, latest, onNewest }: {
body = (
- {items.map((f) => )}
+ {items.map((f) => )}
{T.shown(items.length)}
@@ -126,8 +128,14 @@ export function FireHistory({ schedule, refreshKey, latest, onNewest }: {
)
}
-function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) {
+function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Schedule; now: number }) {
const at = parseServerTime(f.fire_at)
+ const tz = schedule.timezone
+ const maintenance = targetKindOf(schedule) === 'source_maintenance'
+ const jobs = maintenance ? f.inputs?.jobs ?? [] : []
+ const skipped = maintenance ? f.inputs?.skipped ?? [] : []
+ // 维护有没执行的步骤时,服务端记的原因就是这几步的原因连成一句:逐步列出来,不再重复写一遍
+ const reason = skipped.length ? null : f.reason
return (
@@ -150,11 +158,21 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
{T.evalFire(f.eval_run_id.slice(0, 6))}
+ ) : maintenance ? (
+ // 目标是数据源维护:建的是任务、不是运行。建了的在下面逐个列出;没建任务照实说(刚触发时任务还在建)
+ !jobs.length && (
+
+ {f.status === 'started' ? T.maintenanceLaunching : T.maintenanceNoJobs}
+
+ )
) : {T.noRun}}
- {f.reason && (
+ {maintenance && (jobs.length > 0 || skipped.length > 0) && (
+
+ )}
+ {reason && (
- {T.reason}:{f.reason}
+ {T.reason}:{reason}
)}
{f.notified?.length > 0 && (
@@ -172,6 +190,41 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
)
}
+/**
+ * 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析
+ * 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果;
+ * 没执行的步骤逐条写原因
+ */
+function MaintenanceFire({ sourceId, jobs, skipped }: {
+ sourceId: string
+ jobs: NonNullable['jobs']>
+ skipped: NonNullable['skipped']>
+}) {
+ return (
+
+ {jobs.length > 0 && (
+
+ {T.maintenanceJobs}
+ {jobs.map((j) => (
+
+ {T.maintenanceJob(maintenanceStepText(j.step), T.maintenanceJobKind[j.kind] ?? j.kind, j.id,
+ j.tables ?? null)}
+
+
+ ))}
+
+ )}
+ {skipped.map((k) => (
+
+ {T.maintenanceSkipped(maintenanceStepText(k.step), k.reason)}
+
+ ))}
+
+ )
+}
+
/**
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行——和运行详情
* 「更多」里同名入口的默认基准是同一个挑法(服务端 api/compare.py 的 pick_base),两处进去比的是同一对运行。旁边写有
diff --git a/frontend/src/pages/schedules/ScheduleDetail.tsx b/frontend/src/pages/schedules/ScheduleDetail.tsx
index fc249129..2807ab5d 100644
--- a/frontend/src/pages/schedules/ScheduleDetail.tsx
+++ b/frontend/src/pages/schedules/ScheduleDetail.tsx
@@ -15,7 +15,7 @@ import {
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
targetText,
} from './parts'
-import { CREATABLE_TARGETS, graceLabel } from './model'
+import { CREATABLE_TARGETS, graceLabel, sourceCardPath } from './model'
import { ScheduleEditor } from './ScheduleEditor'
import { TargetEditor } from './TargetEditor'
@@ -312,8 +312,9 @@ export function ScheduleDetailPage({ id }: { id: string }) {
}
/**
- * 数据源维护的定时任务的设置:只读地列出名称、目标、频率、时区、补发宽限,链到数据源页(维护要做哪几步、频率和时段在数据源页的
- * 「定时维护」里改,next-round 03 B4)。页头的启用开关、立即运行、删除和触发历史照常可用
+ * 数据源维护的定时任务的设置:只读地列出名称、目标、频率、时区、补发宽限,链到数据源页上这个数据源的卡片(model.sourceCardPath:
+ * 数据源页认 #source-、滚到那张卡片;维护要做哪几步、频率和时段在卡片的「定时维护」里改,next-round 03 B4)。页头的启用开关、
+ * 立即运行、删除和触发历史照常可用
*/
function TargetSettings({ schedule }: { schedule: Schedule }) {
const kind = targetKindOf(schedule)
@@ -327,7 +328,7 @@ function TargetSettings({ schedule }: { schedule: Schedule }) {
return (
{T.maintenanceNote}
-
{T.toDataSource}
diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts
index e376cce7..6d06a478 100644
--- a/frontend/src/pages/schedules/model.ts
+++ b/frontend/src/pages/schedules/model.ts
@@ -5,8 +5,8 @@
*/
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type {
- Schedule, ScheduleCreate, ScheduleEvent, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate, ScheduleTargetKind,
- ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion,
+ Schedule, ScheduleCreate, ScheduleEvent, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate,
+ ScheduleTargetKind, ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion,
} from '../../types'
export * from '../../lib/freq'
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq'
@@ -235,6 +235,41 @@ export function targetProblems(f: TargetForm): Partial
,列表载入后滚到那张卡片(目录页后台任务条上探查的「查看」同一个
+ * 约定)。维护的设置(做哪几步、频率)在卡片的「定时维护」里,探查的结果也在卡片上
+ */
+export function sourceCardPath(sourceId: string): string {
+ return `/data/databases#source-${encodeURIComponent(sourceId)}`
+}
+
+/** 数据目录里这个剖析任务的进度和结果:目录页认 ?pjob=<任务 id>,打开那个任务的进度(数据源页「定时维护」的「最近一次」同一个约定) */
+export function profileJobPath(sourceId: string, jobId: string): string {
+ return `/data/catalog/${encodeURIComponent(sourceId)}?pjob=${encodeURIComponent(jobId)}`
+}
+
+/** 维护建的一个任务链到哪里:探查到数据源卡片,剖析到目录里那个任务的进度 */
+export function maintenanceJobPath(sourceId: string, job: { kind: 'introspect' | 'profile'; id: string }): string {
+ return job.kind === 'profile' ? profileJobPath(sourceId, job.id) : sourceCardPath(sourceId)
+}
+
+/** 维护的一个步骤(或剖析、观察合成一个任务时的 a+b)照服务端的叫法写;认不出的照原样 */
+export function maintenanceStepText(step: string): string {
+ return step.split('+').map((x) => (T.maintenanceStep as Record)[x] ?? x).join('、')
+}
+
+/** 立即运行一次维护之后 toast 的详情:建了哪几个任务、哪几步没执行(逐行,同触发历史里的写法) */
+export function maintenanceSummary(inputs: ScheduleMaintenanceInputs | null | undefined): string {
+ const jobs = (inputs?.jobs ?? []).map((j) => T.maintenanceJob(maintenanceStepText(j.step), T.maintenanceJobKind[j.kind] ?? j.kind,
+ j.id, j.tables ?? null))
+ const skipped = (inputs?.skipped ?? []).map((k) => T.maintenanceSkipped(maintenanceStepText(k.step), k.reason))
+ return [...jobs, ...skipped].join('\n')
+}
+
/**
* 服务端 422 指的那一项在表单里的值,拿来比:人改过那一项就不再摆着那句说明(改回原值时说明又出来)。
* 运行类别、输入连带工作流一起算:换了工作流,「没发布」「字段对不上」也就不一定成立了。认不出的项返回 null
diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx
index ab7d4cc8..7b6e48a4 100644
--- a/frontend/src/pages/schedules/parts.tsx
+++ b/frontend/src/pages/schedules/parts.tsx
@@ -7,7 +7,7 @@ import { StatusPill, confirmDialog, toast } from '../../components/ui'
import { formatDateTime, parseServerTime } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types'
-import { browserTimezone, formatUntil, formatZoned } from './model'
+import { browserTimezone, formatUntil, formatZoned, maintenanceSummary } from './model'
// -------------------------------------------------------------------------
// 启用开关
@@ -97,7 +97,8 @@ export function FireStatusChip({ fire }: { fire: ScheduleFire }) {
return (
void): Promise {
@@ -184,6 +186,12 @@ export async function runNow(s: Schedule, navigate: (to: string) => void): Promi
} else if (r.fire?.status === 'started' && r.fire.eval_run_id) {
// 目标是评测题集:发起的是一次评测,没有一次运行可看
toast.ok(T.evalStarted)
+ } else if (r.fire && targetKindOf(s) === 'source_maintenance') {
+ // 目标是数据源维护:建的是探查、剖析任务(在触发历史里逐个链得到),跳过、没开始的照服务端的原因说
+ const detail = maintenanceSummary(r.fire.inputs)
+ if (r.fire.status === 'started') toast.ok(T.maintenanceStarted, detail ? { detail } : undefined)
+ else if (r.fire.status === 'skipped_overlap') toast.warn(T.maintenanceBusy, r.fire.reason ? { detail: r.fire.reason } : undefined)
+ else toast.error(T.maintenanceFailed(r.fire.reason ?? null))
} else if (r.fire?.status === 'skipped_overlap' || r.fire?.status === 'skipped_misfire') {
toast.warn(r.fire.status === 'skipped_overlap' ? T.runSkipped : T.fireStatus.skipped_misfire,
r.fire.reason ? { detail: r.fire.reason } : undefined)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 49163c1d..264b5c81 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -3867,6 +3867,20 @@ export interface ScheduleFireCompare {
}
/** 一次触发(触发历史的一行) */
+/** 数据源定时维护的步骤(app/data/source_maintenance.STEPS) */
+export type ScheduleMaintenanceStep = 'introspect' | 'profile_needed' | 'observe'
+
+/**
+ * 数据源维护的一次触发建了什么(app/data/source_maintenance 记在触发上的 inputs):要做的步骤、建了的探查 / 剖析任务(step 是
+ * 这个任务做的那几步,剖析、观察合成一个任务时写成 profile_needed+observe)、没执行的步骤和原因。和数据源页「定时维护」的
+ * 「最近一次」读的是同一份
+ */
+export interface ScheduleMaintenanceInputs {
+ steps?: ScheduleMaintenanceStep[]
+ jobs?: { step: string; kind: 'introspect' | 'profile'; id: string; tables?: number; scope?: string }[]
+ skipped?: { step: ScheduleMaintenanceStep; reason: string }[]
+}
+
export interface ScheduleFire {
id: string
/** 计划触发的时间;手动运行是按下的那一刻 */
@@ -3880,6 +3894,8 @@ export interface ScheduleFire {
reason: string | null
/** 目标是评测题集时这一次触发建的那次评测(没有 run_id);老服务端不给 */
eval_run_id?: string | null
+ /** 目标是数据源维护时这一次建了哪些任务、哪几步没执行和原因(触发记录的 inputs);别的目标为 null,老服务端不给 */
+ inputs?: ScheduleMaintenanceInputs | null
notified: ScheduleFireNotice[]
/**
* 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态)
diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs
index 3a5d3e34..8af36ea7 100644
--- a/scripts/check-schedules.mjs
+++ b/scripts/check-schedules.mjs
@@ -226,6 +226,22 @@ await section('纯函数:非工作流目标(评测题集)的表单 ⇄ 请
JSON.stringify(probs))
})
+await section('纯函数:数据源维护链到哪里、建了什么怎么写', async () => {
+ const m = await import(await load('/src/pages/schedules/model.ts'))
+ check('数据源卡片:数据源页认的 #source-(id 编码)', m.sourceCardPath('ds-shop') === '/data/databases#source-ds-shop'
+ && m.sourceCardPath('ds 1/2') === '/data/databases#source-ds%201%2F2')
+ check('剖析任务:目录页认的 ?pjob=', m.profileJobPath('ds-shop', 'pj-1') === '/data/catalog/ds-shop?pjob=pj-1')
+ check('探查任务链到卡片、剖析任务链到目录', m.maintenanceJobPath('ds-shop', { kind: 'introspect', id: 'ij-1' }) === '/data/databases#source-ds-shop'
+ && m.maintenanceJobPath('ds-shop', { kind: 'profile', id: 'pj-1' }) === '/data/catalog/ds-shop?pjob=pj-1')
+ check('步骤照服务端的叫法写,合成的一个任务写成「a、b」,认不出的照原样',
+ m.maintenanceStepText('introspect') === '探查结构' && m.maintenanceStepText('profile_needed+observe') === '剖析需要的表、取值观察'
+ && m.maintenanceStepText('later_step') === 'later_step')
+ const sum = m.maintenanceSummary({ jobs: [{ step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d', tables: 1200 }],
+ skipped: [{ step: 'observe', reason: '没有开启数据剖析' }] })
+ check('立即运行的详情:一个任务一行、没执行的一行', sum === '探查结构:探查任务 #ij1a2b(1,200 张表)\n取值观察没有执行:没有开启数据剖析', sum)
+ check('什么都没有:空串', m.maintenanceSummary(null) === '' && m.maintenanceSummary({}) === '')
+})
+
await section('纯函数:服务端会拒的组合、保存回来怎么接、测试结果', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const fields = [{ name: 'date', required: true }, { name: 'region', required: true, default: '华东' }, { name: 'note' },
@@ -513,6 +529,30 @@ const S_MAINT = {
target_name: '门店库', workflow_name: '门店库', cron: '0 2 * * *', description_text: '每天 02:00(Asia/Shanghai)',
last_fire: null, version: 5,
}
+/**
+ * 数据源维护的触发记录(触发记录的 inputs:建了哪些任务、哪几步没执行,app/data/source_maintenance):维护没有运行。
+ * fm1 探查、剖析各建一个;fm2 剖析没开,只建了探查,另两步没执行(服务端记的原因就是这两步的原因连成一句);fm3 上一次的
+ * 探查还没做完、跳过;fm4 数据源不在了、没有建任务
+ */
+const MAINT_JOBS = [
+ { step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d5e', tables: 12 },
+ { step: 'profile_needed+observe', kind: 'profile', id: 'pj9f8e7d6c5b', scope: 'maintenance', tables: 3 },
+]
+const STEPS3 = ['introspect', 'profile_needed', 'observe']
+const NOT_PROFILED = '这个数据源没有开启数据剖析'
+function maintFires() {
+ const m = { run_id: null, run_status: null }
+ return [
+ fire('fm1', { ...m, kind: 'manual', inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }),
+ fire('fm2', { ...m, fire_at: iso(NOW - 2 * 24 * H), created_at: iso(NOW - 2 * 24 * H),
+ reason: `剖析需要的表没有执行:${NOT_PROFILED};取值观察没有执行:${NOT_PROFILED}`,
+ inputs: { steps: STEPS3, jobs: [MAINT_JOBS[0]], skipped: [{ step: 'profile_needed', reason: NOT_PROFILED }, { step: 'observe', reason: NOT_PROFILED }] } }),
+ fire('fm3', { ...m, status: 'skipped_overlap', fire_at: iso(NOW - 3 * 24 * H), created_at: iso(NOW - 3 * 24 * H),
+ reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在探查结构,这次触发已跳过', inputs: { steps: STEPS3 } }),
+ fire('fm4', { ...m, status: 'failed_to_start', fire_at: iso(NOW - 4 * 24 * H), created_at: iso(NOW - 4 * 24 * H),
+ reason: '数据源不存在,可能已被删除', inputs: {} }),
+ ]
+}
/** 评测题集(新建评测题集的定时任务时选) */
const SUITES = [
{ id: 'suite-retail', name: '零售样例题集', description: '虚构零售库上的几道问法', archived: false },
@@ -936,13 +976,62 @@ await section('目标种类:新建评测题集的定时、改名称和频率
const ro = page.locator('[data-schedule-target-settings="source_maintenance"]')
await ro.waitFor()
check('数据源维护的详情只读,写明在数据源页设置,链到数据源页', (await ro.innerText()).includes('在数据源页')
- && (await ro.locator('[data-schedule-to-source]').getAttribute('href')) === '/data/databases?source=ds-shop'
+ && (await ro.locator('[data-schedule-to-source]').getAttribute('href')) === '/data/databases#source-ds-shop'
&& await page.locator('[data-schedule-save]').count() === 0)
check('页头的启用、立即运行照常', await page.locator('[data-schedule-toggle]').count() === 1
&& await page.locator('[data-schedules-page] [data-schedule-run]').count() === 1)
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
+await section('数据源维护的触发历史:列出建了的任务和没执行的步骤,不写「未发起运行」', async () => {
+ const state = freshState({ schedules: [structuredClone(S1), { ...structuredClone(S_MAINT), last_fire: maintFires()[0] }], fires: maintFires() })
+ const { page, errors } = await open('/schedules/s-maint?tab=history', { state })
+ await page.locator('[data-fire-id="fm4"]').waitFor()
+ const row = (id) => page.locator(`[data-fire-id="${id}"]`)
+ const all = await page.locator('[data-fire-list]').innerText()
+ check('没有一行写「未发起运行」(维护本来就不发起运行)', !all.includes('未发起运行'), all.replace(/\s+/g, ' ').slice(0, 200))
+ const jobs = row('fm1').locator('[data-fire-job]')
+ check('建了两个任务就列两个:步骤、任务种类、编号、表数', await jobs.count() === 2
+ && (await jobs.nth(0).innerText()).includes('探查结构:探查任务 #ij1a2b(12 张表)')
+ && (await jobs.nth(1).innerText()).includes('剖析需要的表、取值观察:剖析任务 #pj9f8e(3 张表)'),
+ (await jobs.allInnerTexts()).join(' | '))
+ check('探查任务链到数据源页那张卡片(#source-,同数据源页认的约定)',
+ await row('fm1').locator('[data-fire-job-kind="introspect"]').getAttribute('href') === '/data/databases#source-ds-shop')
+ check('剖析任务链到数据目录里那个任务的进度(?pjob=,同「定时维护」的「最近一次」)',
+ await row('fm1').locator('[data-fire-job-kind="profile"]').getAttribute('href') === '/data/catalog/ds-shop?pjob=pj9f8e7d6c5b')
+ check('建了任务的那一行不另写「没有建任务」', await row('fm1').locator('[data-fire-maintenance-none]').count() === 0)
+ const skipped = row('fm2').locator('[data-fire-skipped]')
+ check('没执行的步骤逐条写原因', await skipped.count() === 2
+ && (await skipped.nth(0).innerText()).includes(`剖析需要的表没有执行:${NOT_PROFILED}`)
+ && (await skipped.nth(1).innerText()).includes(`取值观察没有执行:${NOT_PROFILED}`), (await skipped.allInnerTexts()).join(' | '))
+ check('……服务端连成一句的原因不再重复写一遍', !(await row('fm2').innerText()).includes('原因:'))
+ check('只建了探查的那一次照样列出探查任务', await row('fm2').locator('[data-fire-job]').count() === 1)
+ check('跳过的那一次:写「没有建任务」和原因', (await row('fm3').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务')
+ && (await row('fm3').innerText()).includes('还没做完:正在探查结构'))
+ check('数据源不在了的那一次:写「没有建任务」和原因', (await row('fm4').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务')
+ && (await row('fm4').innerText()).includes('数据源不存在'))
+ await shot(page, 'maintenance-history')
+
+ // 立即运行:建的是任务,说开始维护、详情写建了什么;不说「未能发起」
+ state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run'
+ ? { json: { fire: fire('fm-now', { kind: 'manual', run_id: null, run_status: null, inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }), run_id: null } }
+ : null)
+ await page.locator('[data-schedules-page] [data-schedule-run]').click()
+ const started = page.locator('[data-toast]', { hasText: '已开始维护' })
+ await started.first().waitFor()
+ const detail = (await started.first().locator('details').textContent().catch(() => '')) ?? ''
+ check('立即运行一次维护:说已开始维护,详情写建了哪几个任务', detail.includes('探查结构:探查任务 #ij1a2b') && detail.includes('剖析任务 #pj9f8e'), detail)
+ check('……不说「未能发起」', !(await page.locator('[data-toast]').allInnerTexts()).some((t) => t.includes('未能发起')))
+ state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run'
+ ? { json: { fire: fire('fm-busy', { kind: 'manual', status: 'skipped_overlap', run_id: null, run_status: null,
+ reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在剖析,这次触发已跳过', inputs: { steps: STEPS3 } }), run_id: null } }
+ : null)
+ await page.locator('[data-schedules-page] [data-schedule-run]').click()
+ const busy = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((t) => t.includes('上一次维护')) ?? null)
+ check('上一次维护还没做完:照实说跳过了,不说「上一次运行」', !!busy && !busy.includes('上一次运行'), busy ?? '')
+ check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
+})
+
await section('列表:空态与老服务端', async () => {
const { page, errors } = await open('/schedules', { state: freshState({ schedules: [] }) })
await page.locator('[data-schedules-empty]').waitFor()