mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
定时任务页认得数据源维护:触发历史列出建了的探查、剖析任务和没执行的步骤;「去数据源页设置」改链 #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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` : '未能发起运行'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
/**
|
||||
* 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user