定时任务页认得数据源维护:触发历史列出建了的探查、剖析任务和没执行的步骤;「去数据源页设置」改链 #source-<id>(复核 W4R-05、W4R-04)

W4R-05 为什么:数据源维护的一次触发建的是探查、剖析任务,没有运行;api/schedules._fire_outs 不交触发记录的 inputs,
FireRow 只认 run_id 和 eval_run_id,于是每一次建了任务的维护都写成「已发起 · 未发起运行」,和数据源页「定时维护」的
「最近一次」对不上;「立即运行」一次维护还弹「未能发起运行」。

- 后端 api/schedules._fire_outs:目标是数据源维护的触发另带 inputs({steps, jobs, skipped},app/data/source_maintenance
  记在触发上的那一份,和数据源页「定时维护」读的同一个形状);别的目标给 null(工作流触发的 inputs 是按模板渲染的工作流
  输入,触发历史不列)。触发历史、页头「上次触发」、列表、立即运行的答复都走它。test_schedules_api 的 FIRE_KEYS 加 inputs。
- 前端 FireHistory:维护的那一行逐个列建了的任务(步骤、任务种类、编号、表数),探查链到数据源页那张卡片、剖析链到数据
  目录里那个任务的进度(?pjob=,同「最近一次」);没执行的步骤逐条写原因,服务端连成一句的原因不再重复;没建任务写
  「没有建任务」(刚触发、任务还在建写「正在建维护任务」),不写「未发起运行」。
- 立即运行(parts.runNow):维护开始了说「已开始维护」、详情逐行写建了什么;跳过照实说上一次维护还没做完;没开始说
  「没有开始维护」和原因。状态小标签的悬停同理。
- types.ts 加 ScheduleMaintenanceStep、ScheduleMaintenanceInputs(和 W4-d 的 MaintenanceFireInputs 结构一致,合并后
  交叉类型照样过 tsc);叫法进 SCHEDULE_TEXT(步骤叫法同服务端 STEP_LABEL,测试逐个对上)。

W4R-04 为什么:维护定时详情里「去数据源页设置」链到 /data/databases?source=<id>,数据源页(W4-d)只认 #source-<id>
(目录页后台任务条「查看」的约定),点过去落在列表顶上。统一成 #source-<id>:链接都经 model.sourceCardPath 出,
探查任务的链接同用它。

检查:check-schedules 新增「纯函数:数据源维护链到哪里、建了什么怎么写」「数据源维护的触发历史」两段(假接口:四种
维护触发、立即运行开始与跳过),改「链到数据源页」的断言;394 项全过。去掉 FireRow 的维护分支、链接改回 ?source= 回跑:
4 项失败。后端新文件 tests/test_maintenance_fire_history.py(立即运行一次维护后触发历史、页头、列表都带 inputs;工作流
触发不列渲染的输入;步骤和叫法前后端对上)。
This commit is contained in:
ThearchyHelios
2026-10-16 22:02:09 +08:00
parent d0082b4645
commit 7f5578c094
10 changed files with 352 additions and 19 deletions
+17 -1
View File
@@ -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),
@@ -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
+2 -2
View File
@@ -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)
+23
View File
@@ -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}` : '未能发起运行'),
+59 -6
View File
@@ -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 = (
<div data-fire-list="">
<ul className="overflow-hidden rounded-lg border bg-panel">
{items.map((f) => <FireRow key={f.id} fire={f} tz={schedule.timezone} now={now} />)}
{items.map((f) => <FireRow key={f.id} fire={f} schedule={schedule} now={now} />)}
</ul>
<div className="flex items-center justify-center gap-2 px-3 py-3 text-2xs text-faint">
<span className="tnum">{T.shown(items.length)}</span>
@@ -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 (
<li className="border-b px-3 py-2.5 last:border-b-0" data-fire-id={f.id} data-fire-status={f.status} data-fire-kind={f.kind}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
@@ -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))}
<ChevronRight size={11} aria-hidden />
</Link>
) : maintenance ? (
// 目标是数据源维护:建的是任务、不是运行。建了的在下面逐个列出;没建任务照实说(刚触发时任务还在建)
!jobs.length && (
<span className="shrink-0 text-2xs text-faint" data-fire-maintenance-none="">
{f.status === 'started' ? T.maintenanceLaunching : T.maintenanceNoJobs}
</span>
)
) : <span className="shrink-0 text-2xs text-faint">{T.noRun}</span>}
</div>
{f.reason && (
{maintenance && (jobs.length > 0 || skipped.length > 0) && (
<MaintenanceFire sourceId={schedule.target_id ?? ''} jobs={jobs} skipped={skipped} />
)}
{reason && (
<div className="mt-1 text-2xs leading-relaxed text-dim">
<span className="text-faint">{T.reason}:</span>{f.reason}
<span className="text-faint">{T.reason}:</span>{reason}
</div>
)}
{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<NonNullable<ScheduleFire['inputs']>['jobs']>
skipped: NonNullable<NonNullable<ScheduleFire['inputs']>['skipped']>
}) {
return (
<div className="mt-1 space-y-0.5 text-2xs leading-relaxed" data-fire-maintenance="">
{jobs.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
<span className="text-faint">{T.maintenanceJobs}</span>
{jobs.map((j) => (
<Link key={j.id} to={maintenanceJobPath(sourceId, j)} title={T.maintenanceJobTitle[j.kind]}
className="inline-flex items-center gap-0.5 text-dim hover:text-fg hover:underline"
data-fire-job={j.id} data-fire-job-kind={j.kind}>
<span className="mono">{T.maintenanceJob(maintenanceStepText(j.step), T.maintenanceJobKind[j.kind] ?? j.kind, j.id,
j.tables ?? null)}</span>
<ChevronRight size={11} aria-hidden />
</Link>
))}
</div>
)}
{skipped.map((k) => (
<div key={k.step} className="text-[var(--warn)]" data-fire-skipped={k.step}>
{T.maintenanceSkipped(maintenanceStepText(k.step), k.reason)}
</div>
))}
</div>
)
}
/**
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行——和运行详情
* 「更多」里同名入口的默认基准是同一个挑法(服务端 api/compare.py 的 pick_base),两处进去比的是同一对运行。旁边写有
@@ -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-<id>、滚到那张卡片;维护要做哪几步、频率和时段在卡片的「定时维护」里改,next-round 03 B4)。页头的启用开关、
* 立即运行、删除和触发历史照常可用
*/
function TargetSettings({ schedule }: { schedule: Schedule }) {
const kind = targetKindOf(schedule)
@@ -327,7 +328,7 @@ function TargetSettings({ schedule }: { schedule: Schedule }) {
return (
<div className="mx-auto max-w-3xl space-y-3 p-4" data-schedule-target-settings={kind}>
<p className="text-xs text-dim">{T.maintenanceNote}</p>
<Link to={`/data/databases?source=${encodeURIComponent(schedule.target_id ?? '')}`} className="btn btn-sm"
<Link to={sourceCardPath(schedule.target_id ?? '')} className="btn btn-sm"
data-schedule-to-source={schedule.target_id ?? ''}>
{T.toDataSource}
</Link>
+37 -2
View File
@@ -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<Record<'name' | 'target'
return out
}
// ---------------------------------------------------------------------------
// 数据源维护:链到数据源页、数据目录(地址的约定照数据源页和目录页认的那几种)
// ---------------------------------------------------------------------------
/**
* 数据源页上这个数据源的卡片:数据源页认 #source-<id>,列表载入后滚到那张卡片(目录页后台任务条上探查的「查看」同一个
* 约定)。维护的设置(做哪几步、频率)在卡片的「定时维护」里,探查的结果也在卡片上
*/
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<string, string>)[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
+11 -3
View File
@@ -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 (
<span
className="chip shrink-0"
title={fire.reason || (fire.eval_run_id && fire.status === 'started' ? T.evalStarted : T.fireStatusHint[fire.status])
title={fire.reason || (fire.status === 'started' && fire.eval_run_id ? T.evalStarted
: fire.status === 'started' && fire.inputs?.jobs?.length ? T.maintenanceStartedHint : T.fireStatusHint[fire.status])
|| undefined}
data-fire-status-chip={fire.status}
style={{
@@ -172,7 +173,8 @@ export async function setEnabled(s: Schedule, enabled: boolean): Promise<Schedul
}
/**
* 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。返回这次触发(出错时 null),
* 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。目标是评测题集的说发起了一次评测;目标是数据源维护的说开始
* 维护、详情里逐行写建了哪几个任务和没执行的步骤(维护没有运行,不说「未能发起」)。返回这次触发(出错时 null),
* 调用方拿它更新「上次触发」和触发历史
*/
export async function runNow(s: Schedule, navigate: (to: string) => void): Promise<ScheduleRunResult | null> {
@@ -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)
+16
View File
@@ -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[]
/**
* 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态)
+90 -1
View File
@@ -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>(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-<id>,同数据源页认的约定)',
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()