diff --git a/frontend/src/api/compare.ts b/frontend/src/api/compare.ts index 94bdb756..9d29b2e1 100644 --- a/frontend/src/api/compare.ts +++ b/frontend/src/api/compare.ts @@ -1,7 +1,25 @@ /** - * 运行对比的接口(/api/runs/{run_id}/…)。现在是空的,运行对比的工作包往里加,挂在 api.compare 上。 + * 运行对比的接口(/api/runs/{run_id}/compare…),挂在 api.compare 上。两个接口都只读:比较结果服务端另存工件, + * 不写运行记录。 * * 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入 * 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。 */ -export const compare = {} +import type { RunCompareBase, RunComparison } from '../types' +import { get, type RequestOptions } from './http' + +const run = (id: string) => `/runs/${encodeURIComponent(id)}` + +export const compare = { + /** + * 默认的基准:同一定时任务的上一次运行,没有就同一工作流、同一运行类别的上一次。找不到时回 404,ApiError 的 + * message 是给人看的原因 + */ + base: (runId: string, opts?: RequestOptions) => get(`${run(runId)}/compare/base`, opts), + /** + * 两次运行的对比(runId 是本次)。任一未封存或封存核对不一致回 409;不是同一工作流、或者是同一次运行回 422; + * 运行不存在回 404。detail 都是一句中文。signal 用来丢掉换了基准之后过时的请求 + */ + get: (runId: string, base: string, opts?: RequestOptions) => + get(`${run(runId)}/compare?${new URLSearchParams({ base })}`, opts), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index da35a65f..7485af5d 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2740,12 +2740,164 @@ export const SCHEDULE_TEXT = { pendingHint: '定时任务尚未开放,完成后可在此处管理', } -/** 运行对比:记录页里两次运行的对照。配方前后对照另有 COMPARE_TEXT,别混用 */ +/** + * 运行对比(/runs/compare,pages/runs/ComparePage.tsx):两次运行的指标变化拆成数据、口径、模板与 SQL 三类说清楚。 + * 配方前后对照另有 COMPARE_TEXT,别混用。分类、拆分原因、说明(reasons、decomposition_note、notes)是服务端写的话, + * 原样显示,这里不改写 + */ export const RUN_COMPARE_TEXT = { title: '运行对比', - hint: '对照两次运行的结论与数据', - pending: PENDING_TEXT, - pendingHint: '运行对比尚未开放,完成后可从记录页选择两次运行进行对照', + hint: '把两次运行的指标变化拆成数据、口径、模板与 SQL 三类', + // 入口:运行详情的「更多」菜单 + menu: '与上一次对比', + menuSealedOnly: '运行结束并封存后才能对比', + menuFinding: '正在查找上一次运行…', + pickManually: '手动选择基准', + // 顶部的两次运行 + base: '基准', + target: '本次', + swap: '互换', + swapHint: '互换基准与本次', + openRun: (name: string) => `查看「${name}」这次运行的详情`, + scheduled: '定时任务发起', + unsealed: '未封存', + unsealedHint: '这次运行没有封存清单,不能参与对比', + runFailed: '无法读取这次运行', + noBase: '未选择', + reason: { + same_schedule: '默认基准:同一定时任务的上一次运行', + same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行', + } as Record, + pickBase: '更换基准', + pickHint: '同一工作流最近的运行,未封存的不能作为基准', + pickLoading: '正在读取同一工作流的运行…', + pickFailed: '无法读取同一工作流的运行', + pickNone: '同一工作流没有其他运行', + pickUnsaved: '临时构建的工作流没有其他运行可选', + pickPlaceholder: '选择一次运行作为基准', + /** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */ + pickOption: (time: string, cls: string, id: string, sealed: boolean) => + `${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`, + // 页面状态 + noTarget: '未指定要对比的运行', + noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行', + toRuns: '前往记录', + finding: '正在查找基准运行…', + loading: '正在对比两次运行…', + errSealed: '无法对比:有运行未封存或封存核对未通过', + errSealedHint: '两次运行都需已结束并封存,且封存核对一致。可在运行详情的封存凭证条上核对', + errMismatch: '无法对比这两次运行', + errMismatchHint: '请选择同一工作流的另一次运行作为基准', + errMissing: '运行记录不存在', + errMissingHint: '链接可能已失效,请从记录页重新打开', + errNoBase: '未找到可作为基准的运行', + errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准', + viewBase: '查看基准运行', + viewTarget: '查看本次运行', + // 摘要 + summary: '变化摘要', + summaryHint: '点击一类只看这一类的指标', + classLabel: { + data_only: '只数据变', + caliber_and_data: '口径与数据都变', + sql_changed: 'SQL 结构变', + params_only: '只参数变', + added: '新增', + removed: '移除', + incomparable: '无法比较', + unchanged: '未变', + } as Record, + classHint: { + data_only: '口径与 SQL 都没有改动,变化来自数据', + caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应', + sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分', + params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同', + added: '本次有、基准没有的指标', + removed: '基准有、本次没有的指标', + incomparable: '缺少比较所需的信息,无法判断变化来自哪里', + unchanged: '两次的值相同,口径与 SQL 也没有改动', + } as Record, + classOther: '其他', + filterOn: (label: string, n: number) => `只显示「${label}」的 ${formatNumber(n)} 个指标`, + showAll: '显示全部', + // 指标 + metrics: '指标', + metricsEmpty: '两次运行的口径卡上都没有指标', + count: (n: number) => `${formatNumber(n)} 个`, + colMetric: '指标', + colBase: '基准', + colTarget: '本次', + colDelta: '变化', + colClass: '分类', + colSplit: '拆分或原因', + legend: '口径效应按本次的数据计算,是只换口径带来的变化;数据效应按基准的口径计算,是只换数据带来的变化。两者之和等于总变化', + caliber: '口径', + data: '数据', + splitSum: (caliber: string, data: string, total: string) => + `口径效应 ${caliber},数据效应 ${data},两者之和等于总变化 ${total}`, + noChange: '无变化', + expand: (name: string) => `展开「${name}」的输入对比与维度归因`, + collapse: (name: string) => `收起「${name}」的明细`, + reasons: '变化说明', + inputs: '输入对比', + inputsNone: '口径卡没有记录这个指标的输入', + colInput: '输入', + attribution: '维度归因', + attrBy: (measure: string) => `按「${measure}」的变化,从大到小`, + attrTop: (n: number) => `前 ${formatNumber(n)} 项`, + attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`, + attrTotal: (value: string) => `合计变化 ${value}`, + attrNone: '没有可按维度归因的明细', + /** 服务端没给占比、也没说为什么时的说法 */ + attrNoShare: '无法计算各项占比', + colKey: '维度', + colShare: '占比', + // 查询 + queries: '查询', + queriesEmpty: '两次运行都没有执行查询', + structure: { + same: '相同', + params_only: '只参数变', + changed: '结构变', + added: '新增', + removed: '移除', + } as Record, + structureHint: { + same: 'SQL 完全相同', + params_only: 'SQL 结构相同,只有字面量不同', + changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动', + added: '本次新增的查询,基准中没有', + removed: '基准中有、本次没有的查询', + } as Record, + nth: (n: number) => `第 ${n} 条`, + rows: (n: number | null | undefined, truncated: boolean | null | undefined) => + (n == null ? '—' : `${formatNumber(n)} 行${truncated ? '(已截断)' : ''}`), + rowsBoth: (base: string, target: string) => `基准 ${base} → 本次 ${target}`, + rowsBase: (base: string) => `基准 ${base}`, + rowsTarget: (target: string) => `本次 ${target}`, + literals: '变化的参数', + baseSql: '基准的 SQL', + targetSql: '本次的 SQL', + bothSql: '两次相同的 SQL', + onlyBase: '基准独有', + onlyTarget: '本次独有', + showSql: '查看 SQL', + hideSql: '收起 SQL', + diffCoarse: '两条 SQL 差异较大,只标出了大致范围', + // 模板 + template: '模板', + version: (v: number | null | undefined) => (v == null ? '未固定版本' : `v${v}`), + versions: (base: string, target: string) => `基准 ${base} → 本次 ${target}`, + sameGraph: '两次运行使用同一张执行图', + nodesNone: '执行图有变化,但没有列出具体节点', + nodeChange: { + added: '新增节点', + removed: '移除节点', + config: '配置变更', + } as Record, + keys: '配置项', + // 说明 + notes: '说明', } /** diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 5a80ba6f..508b3004 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -1,21 +1,113 @@ +import { useEffect, useState } from 'react' +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { GitCompareArrows } from 'lucide-react' -import { EmptyState, PageHeader } from '../../components/ui' +import { EmptyState, PageHeader, Skeleton, Spinner } from '../../components/ui' import { RUN_COMPARE_TEXT } from '../../lib/terms' +import type { RunComparison } from '../../types' +import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data' +import { comparePath, type CompareVia } from './compare/entry' +import { Metrics } from './compare/Metrics' +import { compareErrorKind } from './compare/model' +import { CompareError } from './compare/parts' +import { Queries } from './compare/Queries' +import { RunPair } from './compare/RunPair' +import { Summary } from './compare/Summary' +import { NotesSection, TemplateSection } from './compare/Template' /** - * 运行对比(/runs/compare)。现在是占位页,运行对比的工作包补上内容。 + * 运行对比(/runs/compare?target=<本次>&base=<基准>):两次运行的指标变化拆成数据、口径、模板与 SQL 三类讲清楚, + * 能看维度归因和查询差异(GET /api/runs/{本次}/compare?base=)。 + * + * 地址就是状态:本次和基准都在查询串里,换基准、互换都是改地址(replace,不往历史里堆),对比跟着重新请求,慢的 + * 旧答复回来也不认。只带本次时先问默认的基准(compare/base),找到了补进地址并写明是怎么选的;找不到照实说, + * 下拉照样能手动挑。从运行详情「与上一次对比」进来时,菜单已经问过默认基准,来历跟着跳转带过来,这里不再问。 * * 挂在 /runs 下面,导航照旧点亮「记录」;路由表里排在 /runs/:runId 之前,「compare」不会被当成运行 id。 */ export function ComparePage() { + const navigate = useNavigate() + const location = useLocation() + const [params] = useSearchParams() + const target = params.get('target') || null + const base = params.get('base') || null + const via = location.state as CompareVia | null + const finder = useDefaultBase(target && !base ? target : null) + // 默认基准的来历:菜单带过来的,或者这里自己找到的;只对那一个基准有效,换了就不写 + const reason = via && via.base === base ? via.reason : null + + const found = finder.state === 'ok' ? finder.data : undefined + useEffect(() => { + if (!target || base || !found) return + const next: CompareVia = { base: found.base_run_id, reason: found.reason } + navigate(comparePath(target, found.base_run_id), { replace: true, state: next }) + }, [target, base, found, navigate]) + + const targetRun = useRunRecord(target) + const baseRun = useRunRecord(base) + const workflowId = targetRun.data?.workflow_id + const candidates = useCandidates(workflowId) + const cmp = useComparison(target, base) + + const go = (t: string, b: string) => navigate(comparePath(t, b), { replace: true }) + + let state: 'no-target' | 'finding' | 'loading' | 'error' | 'ok' + if (!target) state = 'no-target' + else if (!base) state = finder.state === 'error' ? 'error' : 'finding' + else state = cmp.state === 'ok' ? 'ok' : cmp.state === 'error' ? 'error' : 'loading' + return (
} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} /> -
- {/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */} - } title={RUN_COMPARE_TEXT.pending} - body={RUN_COMPARE_TEXT.pendingHint} offline={false} /> +
+ {!target ? ( + } title={RUN_COMPARE_TEXT.noTarget} body={RUN_COMPARE_TEXT.noTargetHint} + offline={false} action={{RUN_COMPARE_TEXT.toRuns}} /> + ) : ( +
+ go(target, b)} onSwap={() => { if (base) go(base, target) }} + /> + {state === 'finding' && } + {state === 'loading' && } + {state === 'error' && ( + + )} + {state === 'ok' && cmp.data && } +
+ )}
) } + +/** 找基准、对比的请求在路上:一句在做什么,下面是占位的骨架 */ +function Pending({ text }: { text: string }) { + return ( +
+
{text}
+ + +
+ ) +} + +/** 对比结果。按「本次|基准」换 key:换了一对运行,筛选、展开都从头来 */ +function Comparison({ data }: { data: RunComparison }) { + const [filter, setFilter] = useState(null) + return ( + <> + + + setFilter(null)} /> + + + + ) +} diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 98a64ef1..636d005e 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -30,6 +30,7 @@ import { ClassChip, MoreMenu, RunTabs, TierChip, copyText, type MenuItem, type T import { ProvenanceBar, Telemetry, runClocks } from './Telemetry' import { TracePane } from './TracePane' import { useRunDetail } from './useRunDetail' +import { useCompareEntry } from './compare/entry' /** * 一次运行的详情:头部读数、凭证、排错路径,下面分三个视图—— @@ -61,6 +62,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const [feedback, setFeedback] = useState(null) const streamBox = useRef(null) const now = useTicker(60_000) + // 「更多」里的「与上一次对比」:查找基准时的状态在这里挂着,菜单项等拿到运行之后再造(下面有提前返回) + const compareEntry = useCompareEntry() useEffect(() => { if (d.state !== 'missing') return @@ -460,6 +463,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { key: 'copy-link', label: '复制链接', icon: , onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'), }, + compareEntry(run, live), { key: 'delete', label: '删除记录…', icon: , danger: true, onSelect: () => void remove(), disabled: live || busy === 'delete', diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx new file mode 100644 index 00000000..5d7d9896 --- /dev/null +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -0,0 +1,315 @@ +import { Fragment, useEffect, useId, useRef, useState } from 'react' +import clsx from 'clsx' +import { ChevronRight } from 'lucide-react' +import { NONE } from '../../../lib/format' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import type { RunCompareAttribution, RunCompareDecomposition, RunCompareMetric } from '../../../types' +import { Block, ClassChip } from './parts' +import { metricKey, percent, sameValue, signedNumber, valueText } from './model' + +/** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */ +const ATTR_TOP = 10 +/** 口径效应、数据效应在条形和图例里的颜色:和「口径与数据都变」「只数据变」两个分类标签同色 */ +const CALIBER_COLOR = 'var(--warn)' +const DATA_COLOR = 'var(--accent)' +/** + * 指标名一栏:窄屏上表格横着滚时钉在左边(滚到分类、拆分那几栏也知道是哪个指标),右边一道细线和后面的内容隔开。 + * 钉住的格子要有自己的底色,否则滚过去的数字会透出来 + */ +const STICKY = 'sticky left-0 z-[1] min-w-[9em] shadow-[inset_-1px_0_0_var(--border)]' + +/** + * 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段 + * 两截的条形),不能拆的写服务端给的原因。每行能展开:变化说明、输入对比、维度归因前 10 项。 + * + * 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的 + * 可见宽度:窄屏上不用横着滚也读得全 + */ +export function Metrics({ metrics, filter, onClearFilter }: { + metrics: RunCompareMetric[]; filter: string | null; onClearFilter: () => void +}) { + const id = useId() + const [open, setOpen] = useState>(() => new Set()) + const scroller = useRef(null) + const shown = filter ? metrics.filter((m) => m.classification === filter) : metrics + const split = metrics.some((m) => m.decomposition) + + // 明细的宽度跟着框的可见宽度走(CSS 变量),表格多宽都不撑出去 + useEffect(() => { + const el = scroller.current + if (!el) return + const sync = () => el.style.setProperty('--compare-view', `${el.clientWidth}px`) + sync() + const ro = new ResizeObserver(sync) + ro.observe(el) + return () => ro.disconnect() + }, [metrics.length]) + + const toggle = (k: string) => setOpen((s) => { + const next = new Set(s) + if (next.has(k)) next.delete(k) + else next.add(k) + return next + }) + + return ( + + {!metrics.length ? ( +

{T.metricsEmpty}

+ ) : ( + <> + {filter && ( +
+ {T.filterOn(T.classLabel[filter] ?? T.classOther, shown.length)} + +
+ )} + {split && ( +

+ + + + + {T.legend} +

+ )} +
+ + + + + + + + + + + + + {shown.map((m) => { + const k = metricKey(m) + return toggle(k)} /> + })} + +
{T.colMetric}{T.colBase}{T.colTarget}{T.colDelta}{T.colClass}{T.colSplit}
+
+ + )} +
+ ) +} + +function MetricRows({ m, k, detailId, open, onToggle }: { + m: RunCompareMetric; k: string; detailId: string; open: boolean; onToggle: () => void +}) { + const pct = m.delta_pct != null && m.delta_pct !== 0 ? percent(m.delta_pct, true) : null + return ( + + + + + + {m.base_rendered || NONE} + {m.target_rendered || NONE} + +
{deltaText(m)}
+ {pct &&
{pct}
} + + + + {m.decomposition ? ( + + ) : m.decomposition_note ? ( +

+ {m.decomposition_note} +

+ ) : ( + {NONE} + )} + + + {open && ( + + + + + + )} +
+ ) +} + +/** 变化量:服务端渲染的写法;没给(没变化、缺一边)时,是 0 的写「无变化」,其余写「—」 */ +function deltaText(m: RunCompareMetric): string { + if (m.delta_rendered) return m.delta_rendered + return m.delta === 0 ? T.noChange : NONE +} + +/** + * 能严格拆分的:口径效应、数据效应并列,下面一段两截的条形按两者的绝对值分长短。一正一负(互相抵消)时负的那截画 + * 斜纹:长短只说谁的分量大,方向看数的正负号 + */ +function Split({ d, total }: { d: RunCompareDecomposition; total: string }) { + const a = Math.abs(d.caliber_effect) + const b = Math.abs(d.data_effect) + const sum = a + b + const fill = (color: string, negative: boolean) => (negative + ? `repeating-linear-gradient(135deg, ${color} 0 2px, color-mix(in srgb, ${color} 35%, transparent) 2px 4px)` + : color) + return ( +
+
+ + + {T.caliber} + {d.caliber_rendered} + + + + {T.data} + {d.data_rendered} + +
+ {sum > 0 && ( + + + + + )} +
+ ) +} + +/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */ +function MetricDetail({ m }: { m: RunCompareMetric }) { + return ( +
+
+ {!!m.reasons.length && ( +
+

{T.reasons}

+
    + {m.reasons.map((r, i) =>
  • {r}
  • )} +
+
+ )} + {m.decomposition && ( +

+ {T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, deltaText(m))} +

+ )} + +
+
+ +
+
+ ) +} + +function Inputs({ m }: { m: RunCompareMetric }) { + return ( +
+

{T.inputs}

+ {!m.inputs.length ? ( +

{T.inputsNone}

+ ) : ( +
+ + + + + + + + + + {m.inputs.map((inp) => { + const changed = !sameValue(inp.base, inp.target) + return ( + + + + + + ) + })} + +
{T.colInput}{T.colBase}{T.colTarget}
{inp.path}{valueText(inp.base)} + {changed && } + {valueText(inp.target)} +
+
+ )} +
+ ) +} + +/** + * 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有, + * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的) + */ +function Attribution({ a }: { a: RunCompareAttribution | null }) { + if (!a) { + return ( +
+

{T.attribution}

+

{T.attrNone}

+
+ ) + } + const rows = a.rows.slice(0, ATTR_TOP) + const more = a.rows.length - rows.length + const note = a.note || (a.rows.some((r) => r.share == null) ? T.attrNoShare : null) + return ( +
+

+ {T.attribution} + {T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''} +

+
+ + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + ))} + +
{a.columns.length ? a.columns.join(' / ') : T.colKey}{T.colBase}{T.colTarget}{T.colDelta}{T.colShare}
+ {r.key.map(valueText).join(' / ')} + {valueText(r.base)}{valueText(r.target)}{signedNumber(r.delta)} + {r.share == null ? NONE : percent(r.share)} +
+
+
+ {more > 0 &&

{T.attrMore(more)}

} + {a.total_delta != null &&

{T.attrTotal(signedNumber(a.total_delta))}

} + {note &&

{note}

} +
+
+ ) +} diff --git a/frontend/src/pages/runs/compare/Queries.tsx b/frontend/src/pages/runs/compare/Queries.tsx new file mode 100644 index 00000000..8eb1838d --- /dev/null +++ b/frontend/src/pages/runs/compare/Queries.tsx @@ -0,0 +1,131 @@ +import { Fragment, useId, useMemo, useState } from 'react' +import { ChevronRight } from 'lucide-react' +import clsx from 'clsx' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import type { RunCompareQuery } from '../../../types' +import { Block, StructureChip } from './parts' +import { queryKey, queryNumbers, valueText } from './model' +import { diffSql, type DiffPart } from './sqlDiff' + +/** 基准独有、本次独有的高亮:颜色之外,删去的划线、新加的下划线,色弱、灰度打印也分得出 */ +const MARK = { + del: { + background: 'color-mix(in srgb, var(--err) 18%, transparent)', color: 'var(--text)', + textDecoration: 'line-through', textDecorationColor: 'color-mix(in srgb, var(--err) 70%, transparent)', + }, + add: { + background: 'color-mix(in srgb, var(--ok) 18%, transparent)', color: 'var(--text)', + textDecoration: 'underline', textDecorationColor: 'color-mix(in srgb, var(--ok) 70%, transparent)', textUnderlineOffset: '2px', + }, +} as const + +/** + * 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的并排两条 SQL(窄屏上下排),逐词标出各自独有的 + * 部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看 + */ +export function Queries({ queries }: { queries: RunCompareQuery[] }) { + const id = useId() + const numbers = useMemo(() => queryNumbers(queries), [queries]) + return ( + + {!queries.length ? ( +

{T.queriesEmpty}

+ ) : ( +
    + {queries.map((q) => { + const k = queryKey(q) + return + })} +
+ )} +
+ ) +} + +function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number | null }) { + const sqlId = useId() + const [showSql, setShowSql] = useState(false) + const foldable = q.structure === 'same' || q.structure === 'params_only' + const rows = q.structure === 'added' ? T.rowsTarget(T.rows(q.target_rows, q.target_truncated)) + : q.structure === 'removed' ? T.rowsBase(T.rows(q.base_rows, q.base_truncated)) + : T.rowsBoth(T.rows(q.base_rows, q.base_truncated), T.rows(q.target_rows, q.target_truncated)) + return ( +
  • +
    + {q.node_title || q.node_id} + {nth != null && {T.nth(nth)}} + + {rows} + {foldable && ( + + )} +
    + {q.structure === 'params_only' && !!q.literals.length && ( +
    +
    {T.literals}
    +
      + {q.literals.map((l, i) => ( +
    • + {valueText(l.base)} + → + {valueText(l.target)} +
    • + ))} +
    +
    + )} + {q.structure === 'changed' && } + {q.structure === 'added' && } + {q.structure === 'removed' && } + {foldable && showSql && ( +
    + {q.structure === 'same' + ? + : } +
    + )} +
  • + ) +} + +/** 两条 SQL 并排,逐词标出各自独有的部分 */ +function SideBySide({ base, target }: { base: string; target: string }) { + const diff = useMemo(() => diffSql(base, target), [base, target]) + return ( +
    +
    + {T.onlyBase} + {T.onlyTarget} +
    +
    + + +
    + {diff.coarse &&

    {T.diffCoarse}

    } +
    + ) +} + +/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */ +function SqlPane({ which, label, sql, parts }: { + which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[] +}) { + const kind = which === 'base' ? 'del' : 'add' + return ( +
    +
    {label}
    +
    +        {parts
    +          ? parts.map((p, i) => (p.changed
    +            ? {p.text}
    +            : {p.text}))
    +          : sql}
    +      
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx new file mode 100644 index 00000000..0cc011ee --- /dev/null +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -0,0 +1,134 @@ +import { useId, type ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { ArrowLeftRight, CalendarClock } from 'lucide-react' +import { Skeleton, useTicker } from '../../../components/ui' +import { formatDateTime, formatTime, shortId } from '../../../lib/format' +import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms' +import type { RunWithSchedule } from '../../../types' +import { ClassChip as RunClassChip } from '../parts' +import type { Loaded } from './data' + +/** + * 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的 + * 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。 + */ +export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: { + target: string + base: string | null + targetRun: Loaded + baseRun: Loaded + candidates: Loaded + /** 本次的工作流没保存:没有同一工作流的其他运行可选 */ + unsaved: boolean + /** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */ + reason: string | null + onPick: (id: string) => void + onSwap: () => void +}) { + return ( +
    +
    + + + +
    + +
    + +
    + {reason && T.reason[reason] && ( +

    {T.reason[reason]}

    + )} +
    + ) +} + +function RunCard({ role, id, run, children }: { + role: 'base' | 'target'; id: string | null; run: Loaded; children?: ReactNode +}) { + const now = useTicker(60_000) + const r = run.data + const when = r ? r.started_at ?? r.created_at ?? null : null + return ( +
    +
    + {role === 'base' ? T.base : T.target} + {r?.schedule_id && ( + {T.scheduled} + )} + {r && !r.manifest_hash && ( + + {T.unsealed} + + )} +
    + {!id ? ( +
    {T.noBase}
    + ) : r ? ( + <> + + {runName(r)} + +
    + + · + + · + {shortId(id)} +
    + + ) : run.state === 'error' ? ( + // 读不到运行记录(删了、连不上):对比本身照样请求,能不能比由对比接口说 +
    + {shortId(id)} + {T.runFailed} +
    + ) : ( +
    + )} + {children} +
    + ) +} + +/** + * 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。 + * 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行 + */ +function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { + target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded; unsaved: boolean + onPick: (id: string) => void +}) { + const id = useId() + const now = useTicker(60_000) + if (unsaved) return

    {T.pickUnsaved}

    + if (candidates.state === 'error') return

    {T.pickFailed}

    + const loading = candidates.state !== 'ok' + const list = (candidates.data ?? []).filter((r) => r.id !== target) + if (base && baseRun && !list.some((r) => r.id === base)) list.unshift(baseRun) + if (!loading && !list.length) return

    {T.pickNone}

    + const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)), + runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash) + return ( +
    + + +

    {T.pickHint}

    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/Summary.tsx b/frontend/src/pages/runs/compare/Summary.tsx new file mode 100644 index 00000000..06611da1 --- /dev/null +++ b/frontend/src/pages/runs/compare/Summary.tsx @@ -0,0 +1,47 @@ +import clsx from 'clsx' +import { formatNumber } from '../../../lib/format' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import type { RunCompareSummary } from '../../../types' +import { CLASS_ORDER, classTone } from './model' + +/** + * 摘要条:八类指标各有几个,个数照服务端的 summary 写(不按指标表重数)。点一类只看这一类的指标,再点一次回到全部; + * 是 0 的不能点——筛出一张空表没有意义 + */ +export function Summary({ summary, filter, onFilter }: { + summary: RunCompareSummary; filter: string | null; onFilter: (k: string | null) => void +}) { + return ( +
    +
    + {CLASS_ORDER.map((k) => { + const n = Number(summary?.[k] ?? 0) + const on = filter === k + const tone = classTone(k) + return ( + + ) + })} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/Template.tsx b/frontend/src/pages/runs/compare/Template.tsx new file mode 100644 index 00000000..687d73d2 --- /dev/null +++ b/frontend/src/pages/runs/compare/Template.tsx @@ -0,0 +1,63 @@ +import { useId } from 'react' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import type { RunCompareTemplate } from '../../../types' +import { Block } from './parts' + +const CHANGE_COLOR: Record = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' } + +/** + * 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项写的是节点 + * 配置里的键:同一个键在不同类型的节点里叫法不同,接口又不给节点类型,照原样写比猜一个中文名可靠 + */ +export function TemplateSection({ template }: { template: RunCompareTemplate }) { + const id = useId() + return ( + +

    + {T.versions(T.version(template.base_version), T.version(template.target_version))} +

    + {template.same_graph ? ( +

    {T.sameGraph}

    + ) : !template.nodes.length ? ( +

    {T.nodesNone}

    + ) : ( +
      + {template.nodes.map((n) => { + const color = CHANGE_COLOR[n.change] ?? 'var(--text-dim)' + return ( +
    • + {n.title || n.node_id} + + {T.nodeChange[n.change] ?? n.change} + + {!!n.keys.length && ( + + {T.keys} + {n.keys.map((k) => ( + {k} + ))} + + )} +
    • + ) + })} +
    + )} +
    + ) +} + +/** 服务端给的说明(截断、缺快照这类会影响读数的事),照原样列出;没有就不画这一节 */ +export function NotesSection({ notes }: { notes: string[] }) { + const id = useId() + if (!notes.length) return null + return ( + +
      + {notes.map((n, i) =>
    • {n}
    • )} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/data.ts b/frontend/src/pages/runs/compare/data.ts new file mode 100644 index 00000000..cea7942e --- /dev/null +++ b/frontend/src/pages/runs/compare/data.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../../../api/client' +import type { RunCompareBase, RunComparison, RunWithSchedule } from '../../../types' + +/** + * 对比页要的几份数据:默认基准、两次运行各自的记录(运行卡、封存与否、所属工作流)、同一工作流最近的运行(换基准的 + * 下拉)、对比本身。每份按键取:键变了(换了基准、互换)就丢掉上一份,过时的答复回来也不认。 + */ + +export interface Loaded { + /** idle:键是空的,不取 */ + state: 'idle' | 'loading' | 'ok' | 'error' + data?: T + error?: unknown + reload: () => void +} + +type Slot = { key: string; state: 'loading' | 'ok' | 'error'; data?: T; error?: unknown } + +/** + * 按键取一份数据。请求晚一拍(下一个宏任务)才发:开发模式的 StrictMode 会把挂载时的 effect 先跑一遍、立即撤掉再跑, + * 撤掉时把还没发的那一拍清掉,同一份对比就只算一次(对比要读两次运行的工件、核对封存,不便宜)。键变了、卸载了 + * 都会取消在路上的请求;答复对不上当前的键就丢掉 + */ +function useKeyed(key: string | null, load: (signal: AbortSignal) => Promise): Loaded { + const [slot, setSlot] = useState | null>(null) + const [nonce, setNonce] = useState(0) + useEffect(() => { + if (!key) return + const ctrl = new AbortController() + const timer = setTimeout(() => { + setSlot({ key, state: 'loading' }) + load(ctrl.signal).then( + (data) => { if (!ctrl.signal.aborted) setSlot({ key, state: 'ok', data }) }, + (error: unknown) => { + if (ctrl.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return + setSlot({ key, state: 'error', error }) + }, + ) + }, 0) + return () => { + clearTimeout(timer) + ctrl.abort() + } + // load 由键决定:键里已经写着它用到的全部参数 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, nonce]) + const reload = useCallback(() => setNonce((n) => n + 1), []) + if (!key) return { state: 'idle', reload } + // 键刚变、请求还没发出去的那一下也算加载中:不拿上一份(另一对运行的)充数 + if (!slot || slot.key !== key) return { state: 'loading', reload } + return { state: slot.state, data: slot.data, error: slot.error, reload } +} + +/** 默认基准(只带了本次时才找)。找不到是 404,交给页面照实说 */ +export function useDefaultBase(target: string | null): Loaded { + return useKeyed(target, (signal) => api.compare.base(target!, { signal })) +} + +/** 一次运行的记录:运行卡上的名字、时间、版本、类别、封存与否,和它属于哪个工作流 */ +export function useRunRecord(id: string | null): Loaded { + return useKeyed(id, () => api.runs.get(id!) as Promise) +} + +/** 换基准的下拉:同一工作流最近的运行(新的在前)。没保存的工作流(workflow_id 为空)不取 */ +export const CANDIDATE_LIMIT = 50 + +export function useCandidates(workflowId: string | null | undefined): Loaded { + return useKeyed(workflowId || null, () => + api.runs.list({ workflow_id: workflowId!, limit: CANDIDATE_LIMIT }) as Promise) +} + +/** 对比本身 */ +export function useComparison(target: string | null, base: string | null): Loaded { + return useKeyed(target && base ? JSON.stringify([target, base]) : null, + (signal) => api.compare.get(target!, base!, { signal })) +} diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx new file mode 100644 index 00000000..bbe611f2 --- /dev/null +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -0,0 +1,49 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { GitCompareArrows } from 'lucide-react' +import { ApiError, api } from '../../../api/client' +import { toast } from '../../../components/ui' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import type { Run } from '../../../types' +import type { MenuItem } from '../parts' + +/** 对比页的地址。via 是默认基准的来历(同一定时任务 / 同一工作流),只跟着这一次跳转走,换了基准就不再显示 */ +export interface CompareVia { base: string; reason: string } + +export const comparePath = (target: string, base?: string | null) => + `/runs/compare?${new URLSearchParams(base ? { target, base } : { target })}` + +/** + * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的 + * 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。 + * 没封存、还在运行的不给点:对比只认已封存的运行,点了也是 409。 + * + * 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上 + */ +export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { + const navigate = useNavigate() + const [busy, setBusy] = useState(false) + return useCallback((run: Run, live: boolean): MenuItem => { + const blocked = live || !run.manifest_hash + return { + key: 'compare', + label: T.menu, + icon: , + disabled: blocked || busy, + hint: blocked ? T.menuSealedOnly : busy ? T.menuFinding : undefined, + onSelect: () => { + setBusy(true) + api.compare.base(run.id).then((found) => { + const via: CompareVia = { base: found.base_run_id, reason: found.reason } + navigate(comparePath(run.id, found.base_run_id), { state: via }) + }, (e: unknown) => { + if (e instanceof ApiError && e.kind === 'http' && e.status === 404) { + toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } }) + } else { + toast.error(e) + } + }).finally(() => setBusy(false)) + }, + } + }, [navigate, busy]) +} diff --git a/frontend/src/pages/runs/compare/model.ts b/frontend/src/pages/runs/compare/model.ts new file mode 100644 index 00000000..3de37f28 --- /dev/null +++ b/frontend/src/pages/runs/compare/model.ts @@ -0,0 +1,118 @@ +import { ApiError } from '../../../api/client' +import { NONE } from '../../../lib/format' +import type { RunCompareClass, RunCompareMetric, RunCompareQuery } from '../../../types' + +/** + * 运行对比页的纯函数:分类的顺序和颜色、数怎么写、查询怎么编号、错误归哪一类。数值本身都是服务端算好的 + * (渲染值原样显示),这里只管服务端没替界面写好的那几样:变化百分比、占比、输入和归因里的原始数。 + */ + +/** 摘要条和分类的顺序:要人看的变化在前,未变放最后 */ +export const CLASS_ORDER: RunCompareClass[] = [ + 'data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged', +] + +/** + * 分类的颜色。颜色只表达「要不要多看一眼」:口径变了、SQL 结构变了是警示色并铺底(数不能直接比);只数据变、新增 + * 用强调色;只参数变、移除、无法比较、未变是中性色。标签上都写着字,去掉颜色也分得出 + */ +const CLASS_TONE: Record = { + data_only: { color: 'var(--accent)', soft: false }, + caliber_and_data: { color: 'var(--warn)', soft: true }, + sql_changed: { color: 'var(--warn)', soft: true }, + params_only: { color: 'var(--text-dim)', soft: false }, + added: { color: 'var(--accent)', soft: false }, + removed: { color: 'var(--text-dim)', soft: false }, + incomparable: { color: 'var(--text-faint)', soft: false }, + unchanged: { color: 'var(--text-faint)', soft: false }, +} + +export function classTone(k: string): { color: string; soft: boolean } { + return CLASS_TONE[k as RunCompareClass] ?? { color: 'var(--text-dim)', soft: false } +} + +/** 一个指标在表里的键:同一张卡里指标 id 唯一,几张卡之间可能重名 */ +export const metricKey = (m: Pick) => `${m.card_id}:${m.metric_id}` + +/** 一条查询的键:同一节点里按服务端的序号区分 */ +export const queryKey = (q: Pick) => `${q.node_id}:${q.ordinal}` + +const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) + +/** 原始的数(输入、归因里的值):千分位,小数最多四位。服务端没给渲染值的地方用它 */ +export function plainNumber(v: number): string { + return v.toLocaleString('en-US', { maximumFractionDigits: 4 }) +} + +/** 输入、归因键里的一个值:数照原样加千分位,缺的写「—」,列表和对象压成一行 */ +export function valueText(v: unknown): string { + if (v == null) return NONE + if (isNum(v)) return plainNumber(v) + if (typeof v === 'boolean') return v ? 'true' : 'false' + if (typeof v === 'string') return v + try { + const s = JSON.stringify(v) + return s.length > 120 ? `${s.slice(0, 119)}…` : s + } catch { + return String(v) + } +} + +/** 带正负号的原始变化量:「+1,012.6」「-3」「0」 */ +export function signedNumber(v: number | null | undefined): string { + if (!isNum(v)) return NONE + if (v === 0) return '0' + return `${v > 0 ? '+' : '-'}${plainNumber(Math.abs(v))}` +} + +/** + * 小数写成百分比(一位小数)。signed 时带正负号:变化比例 0.125 →「+12.5%」;占比 0.4 →「40.0%」。 + * 不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」 + */ +export function percent(v: number | null | undefined, signed = false): string { + if (!isNum(v)) return NONE + const pct = Math.abs(v) * 100 + const sign = signed && v !== 0 ? (v > 0 ? '+' : '-') : v < 0 ? '-' : '' + if (v !== 0 && pct < 0.05) return `${sign}<0.1%` + return `${sign}${pct.toFixed(1)}%` +} + +/** 两个值算不算一样(输入对比里标「变了」用):数按值比,别的按 JSON 比 */ +export function sameValue(a: unknown, b: unknown): boolean { + if (isNum(a) && isNum(b)) return a === b + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return a === b + } +} + +/** + * 同一节点有几条查询时,界面按 1 起数写「第 N 条」。服务端的 ordinal 只用来排先后:它从 0 还是从 1 数、中间有没有 + * 空号(只有一边有的那条),都不该漏到界面上 + */ +export function queryNumbers(queries: RunCompareQuery[]): Map { + const byNode = new Map() + for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q]) + const out = new Map() + for (const list of byNode.values()) { + const sorted = [...list].sort((a, b) => a.ordinal - b.ordinal) + sorted.forEach((q, i) => out.set(queryKey(q), list.length > 1 ? i + 1 : null)) + } + return out +} + +/** + * 对比失败归哪一类:409 未封存或封存核对不一致、422 不是同一工作流或同一次运行、404 运行不存在;找默认基准时的 404 + * 是「找不到基准」。其余(网络、服务端出错)按通用的出错态画,带重试 + */ +export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other' + +export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind { + if (!(e instanceof ApiError) || e.kind !== 'http') return 'other' + if (finding) return e.status === 404 ? 'no-base' : 'other' + if (e.status === 409) return '409' + if (e.status === 422) return '422' + if (e.status === 404) return '404' + return 'other' +} diff --git a/frontend/src/pages/runs/compare/parts.tsx b/frontend/src/pages/runs/compare/parts.tsx new file mode 100644 index 00000000..fb493b15 --- /dev/null +++ b/frontend/src/pages/runs/compare/parts.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { AlertTriangle } from 'lucide-react' +import { ErrorState } from '../../../components/ui' +import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' +import { classTone, type CompareErrorKind } from './model' + +/** 对比页的一节:带标题的卡片。count 写在标题旁(「11 个」),hint 是一句说明 */ +export function Block({ id, title, count, hint, children, attr }: { + id: string; title: string; count?: string; hint?: ReactNode; children: ReactNode + attr?: Record +}) { + return ( +
    +
    +

    {title}

    + {count && {count}} + {hint &&

    {hint}

    } +
    +
    {children}
    +
    + ) +} + +/** + * 标签的配色:字和边框用这个颜色;soft 时再铺一层 12% 的底。铺了底的字往正文色里调一成半:浅色主题下警示色的字压在 + * 自己的浅底上只有 4.4:1,调过之后深浅两套都在 4.5 以上(深色里正文色是浅的,调完字更亮) + */ +function toneStyle(color: string, soft: boolean) { + return { + color: soft ? `color-mix(in srgb, ${color} 85%, var(--text))` : color, + borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, + background: soft ? `color-mix(in srgb, ${color} 12%, transparent)` : undefined, + } +} + +/** 指标的分类标签。要多看一眼的(口径、SQL 结构变了)铺一层底色,和出具档位的标签同一种画法 */ +export function ClassChip({ value }: { value: string }) { + const tone = classTone(value) + return ( + + {T.classLabel[value] ?? T.classOther} + + ) +} + +/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */ +export function StructureChip({ value }: { value: string }) { + const warn = value === 'changed' + const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)' + return ( + + {T.structure[value] ?? value} + + ) +} + +const ERROR_TEXT: Record, { title: string; hint: string }> = { + '409': { title: T.errSealed, hint: T.errSealedHint }, + '422': { title: T.errMismatch, hint: T.errMismatchHint }, + '404': { title: T.errMissing, hint: T.errMissingHint }, + 'no-base': { title: T.errNoBase, hint: T.errNoBaseHint }, +} + +/** + * 对比失败时的说明:先说是哪一种(未封存、不是同一工作流、运行不存在、找不到基准),再原样带上服务端的原因, + * 最后一句下一步。未封存、封存核对不一致的,给两次运行详情的入口:封存凭证条上能核对。别的错误按通用的出错态画 + */ +export function CompareError({ kind, error, onRetry, target, base }: { + kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null +}) { + if (kind === 'other') { + return ( +
    + +
    + ) + } + const { title, hint } = ERROR_TEXT[kind] + const reason = error instanceof Error ? error.message : '' + return ( +
    + +
    +
    {title}
    + {reason && reason !== title &&
    {reason}
    } +
    {hint}
    + {kind === '409' && (target || base) && ( +
    + {base && {T.viewBase}} + {target && {T.viewTarget}} +
    + )} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/sqlDiff.ts b/frontend/src/pages/runs/compare/sqlDiff.ts new file mode 100644 index 00000000..d7540910 --- /dev/null +++ b/frontend/src/pages/runs/compare/sqlDiff.ts @@ -0,0 +1,121 @@ +/** + * 两条 SQL 逐词对比:并排显示时标出各自独有的部分(结构变了的那条查询,差在哪一眼能看到)。 + * + * 按词(名字、关键字、数、字符串、运算符)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」 + * 当成没变,读起来支离破碎;按行比又太粗,模型写的 SQL 常常一整条写在一行里。关键字和不加引号的名字不分大小写 + * (服务端的规范化也不算它们不同),字符串、加引号的名字照原样比。空白不参与比较,只是两个变了的词之间的空白也 + * 跟着标,免得一段新加的条件被切成好几截。 + * + * 先去掉两头相同的部分,中间再求最长公共子序列。中间太长(两边词数之积超过 CELL_LIMIT)时不逐词算,整段标出—— + * 页面不能为一条超长的 SQL 卡住,coarse 为 true,界面照实说只标了大致范围。 + */ + +export interface DiffPart { + text: string + /** 这一段只在自己这边有 */ + changed: boolean +} + +export interface SqlDiff { + base: DiffPart[] + target: DiffPart[] + /** 中间太长、没有逐词比,整段标出 */ + coarse: boolean +} + +/** 逐词比较时动态规划表的格数上限(Uint32Array,4 MB) */ +const CELL_LIMIT = 1_000_000 + +// 空白、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、数、两个字符的运算符、其余单个字符 +const TOKEN = /\s+|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu + +interface Token { + text: string + space: boolean + /** 比较用的写法 */ + key: string +} + +export function tokenize(sql: string): Token[] { + const out: Token[] = [] + for (const m of sql.matchAll(TOKEN)) { + const text = m[0] + const space = /^\s+$/.test(text) + const word = /^[\p{L}_]/u.test(text) + out.push({ text, space, key: word ? text.toLowerCase() : text }) + } + return out +} + +/** 非空白的词里,哪些和对面配上了(最长公共子序列)。返回两个布尔数组:true 是配上了 */ +function matchWords(a: string[], b: string[]): { a: boolean[]; b: boolean[]; coarse: boolean } { + const ma = a.map(() => false) + const mb = b.map(() => false) + let lo = 0 + while (lo < a.length && lo < b.length && a[lo] === b[lo]) { ma[lo] = true; mb[lo] = true; lo++ } + let ea = a.length + let eb = b.length + while (ea > lo && eb > lo && a[ea - 1] === b[eb - 1]) { ea--; eb--; ma[ea] = true; mb[eb] = true } + const n = ea - lo + const m = eb - lo + if (n === 0 || m === 0) return { a: ma, b: mb, coarse: false } + if ((n + 1) * (m + 1) > CELL_LIMIT) return { a: ma, b: mb, coarse: true } + // dp[i][j]:a 从 lo+i、b 从 lo+j 起到中段末尾的最长公共子序列长度,从后往前填,回溯时从前往后走 + const w = m + 1 + const dp = new Uint32Array((n + 1) * w) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i * w + j] = a[lo + i] === b[lo + j] + ? dp[(i + 1) * w + j + 1] + 1 + : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]) + } + } + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[lo + i] === b[lo + j]) { + ma[lo + i] = true + mb[lo + j] = true + i++ + j++ + } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { + i++ + } else { + j++ + } + } + return { a: ma, b: mb, coarse: false } +} + +/** 词和配对结果 → 一段段文字。夹在两个变了的词之间的空白也算变了,相邻同类的段并成一段 */ +function parts(tokens: Token[], matched: boolean[]): DiffPart[] { + const flags: (boolean | null)[] = [] + let w = 0 + for (const t of tokens) flags.push(t.space ? null : !matched[w++]) + // 每个位置往后最近的一个词变没变(空白看前后两个词),从后往前扫一遍,不在每个空白处再各找一次 + const next: boolean[] = new Array(tokens.length).fill(false) + let ahead = false + for (let i = tokens.length - 1; i >= 0; i--) { + next[i] = ahead + if (flags[i] !== null) ahead = flags[i] as boolean + } + const out: DiffPart[] = [] + let behind = false + tokens.forEach((t, i) => { + const flag = flags[i] + const changed = flag ?? (behind && next[i]) + if (flag !== null) behind = flag + const last = out[out.length - 1] + if (last && last.changed === changed) last.text += t.text + else out.push({ text: t.text, changed }) + }) + return out +} + +export function diffSql(base: string, target: string): SqlDiff { + const tb = tokenize(base) + const tt = tokenize(target) + const words = (ts: Token[]) => ts.filter((t) => !t.space).map((t) => t.key) + const { a, b, coarse } = matchWords(words(tb), words(tt)) + return { base: parts(tb, a), target: parts(tt, b), coarse } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 39866717..b628f779 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3167,3 +3167,151 @@ export interface WhatIfResult { unsupported: WhatIfUnsupported[] affected_cards: WhatIfAffectedCard[] } + +// --------------------------------------------------------------------------- +// 运行对比(GET /api/runs/{id}/compare/base、/compare?base=):两次运行的指标变化按数据 / 口径 / 模板与 SQL 拆开说。 +// 形状照前后端共用的接口约定,字段名一个不改;数值都是服务端算好的,界面只排版,渲染值原样显示 +// --------------------------------------------------------------------------- + +/** 对比里的一次运行。版本是空的:没从固定版本发起(探索运行);schedule_id 是空的:手动发起 */ +export interface RunCompareRef { + id: string + workflow_name: string + version: number | null + run_class: 'formal' | 'exploratory' | null + started_at: string | null + finished_at: string | null + schedule_id: string | null +} + +/** 默认基准从哪来:同一定时任务的上一次,没有就同一工作流、同一运行类别的上一次 */ +export type RunCompareBaseReason = 'same_schedule' | 'same_workflow' + +/** GET /runs/{id}/compare/base。找不到时 404,detail 是一句原因 */ +export interface RunCompareBase { + base_run_id: string + reason: RunCompareBaseReason | (string & {}) + base: RunCompareRef +} + +/** + * 指标的分类。unchanged:未变;data_only:只数据变;caliber_and_data:口径与数据都变(能严格拆时给口径效应、数据效应); + * sql_changed:所依据的查询 SQL 结构变了(口径不同,不拆);params_only:SQL 只差字面量(定时运行的日期); + * added / removed:只有本次 / 只有基准有;incomparable:缺比较所需的信息 + */ +export type RunCompareClass = + | 'unchanged' | 'data_only' | 'caliber_and_data' | 'sql_changed' | 'params_only' | 'added' | 'removed' | 'incomparable' + +/** 各类指标的个数 */ +export type RunCompareSummary = Record + +export interface RunCompareTemplateNode { + node_id: string + title: string + change: 'added' | 'removed' | 'config' | (string & {}) + /** 改了的配置项(节点配置里的键) */ + keys: string[] +} + +export interface RunCompareTemplate { + base_version: number | null + target_version: number | null + /** 两次运行的执行图完全相同 */ + same_graph: boolean + nodes: RunCompareTemplateNode[] +} + +/** + * 严格拆分:口径效应 = 新口径(本次的数据) − 旧口径(本次的数据),数据效应 = 旧口径(本次的数据) − 旧口径(基准的数据), + * 两者之和正好是总变化。*_rendered 是服务端按口径卡格式写好的(带正负号) + */ +export interface RunCompareDecomposition { + caliber_effect: number + data_effect: number + caliber_rendered: string + data_rendered: string +} + +/** 一个输入在两次运行里的值(容器写大概,缺的是 null) */ +export interface RunCompareInput { + path: string + base: unknown + target: unknown +} + +export interface RunCompareAttributionRow { + /** 各维度列上的取值,和 columns 一一对应 */ + key: unknown[] + base: number | null + target: number | null + delta: number | null + /** 这一项的变化占总变化的比例(小数,0.4 是 40%)。度量列之和对不上指标的输入时为 null,原因在 note */ + share: number | null +} + +/** 维度归因:按键对齐两边查询结果的行,变化最大的排在前面 */ +export interface RunCompareAttribution { + columns: string[] + measure: string + rows: RunCompareAttributionRow[] + total_delta: number | null + note: string | null +} + +export interface RunCompareMetric { + card_id: string + card_title: string + metric_id: string + name: string + unit: string + decimals: number | null + base: number | null + target: number | null + base_rendered: string | null + target_rendered: string | null + delta: number | null + /** 带正负号;比率、百分数写「个百分点」。没变化、不是数时可能为 null */ + delta_rendered: string | null + /** 相对基准的变化比例(小数,0.125 是 +12.5%);基准为 0、缺值时为 null */ + delta_pct: number | null + classification: RunCompareClass | (string & {}) + reasons: string[] + decomposition: RunCompareDecomposition | null + /** 没法严格拆分的原因;能拆、或者用不着拆(只数据变、未变)时为 null */ + decomposition_note: string | null + inputs: RunCompareInput[] + attribution: RunCompareAttribution | null +} + +/** same:完全相同;params_only:规范化之后相同,只差字面量;changed:结构不同;added / removed:只有一边有 */ +export type RunCompareStructure = 'same' | 'params_only' | 'changed' | 'added' | 'removed' + +export interface RunCompareQuery { + node_id: string + node_title: string + /** 同一节点里的第几条查询(服务端的序号,只用来排序和区分,界面另按 1 起数) */ + ordinal: number + structure: RunCompareStructure | (string & {}) + base_sql: string | null + target_sql: string | null + /** 不同的字面量(SQL 写法,字符串带引号),按出现的顺序 */ + literals: { base: unknown; target: unknown }[] + base_rows: number | null + target_rows: number | null + base_truncated: boolean | null + target_truncated: boolean | null +} + +/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */ +export interface RunComparison { + target: RunCompareRef + base: RunCompareRef + summary: RunCompareSummary + template: RunCompareTemplate + metrics: RunCompareMetric[] + queries: RunCompareQuery[] + notes: string[] +} + +/** 运行接口回的运行带着发起它的定时任务(RunOut.schedule_id);Run 上没写这一项,对比页的运行卡要用 */ +export type RunWithSchedule = Run & { schedule_id?: string | null } diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index bad24d65..44550943 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -1,8 +1,726 @@ -// 运行对比的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。 -// 运行对比的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行 -// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。 +// 运行对比页(/runs/compare?target=…&base=…,pages/runs/ComparePage.tsx)的回归检查。 +// +// 守的是这些「看着没坏、其实用不了」的地方:运行详情「更多」里没有入口、点了不先找基准就乱跳;摘要的个数和服务端 +// 给的对不上;分类标签张冠李戴;口径效应和数据效应只摆两个数,不说两者之和等于总变化(或者自己算了一遍、和服务端 +// 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标; +// 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、 +// 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。 +// +// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 +// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。 +// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。 // // node scripts/check-compare.mjs -console.log('=== 运行对比 ===') -console.log(' ✓ 占位:功能开发中') -console.log('\n✓ 运行对比:占位通过') +// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs +// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。 +import { mkdirSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const SHOTS = process.env.COMPARE_SHOTS ?? '' + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +const opened = new Set() +async function section(name, fn) { + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } finally { + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +} + +// ---------------------------------------------------------------- 夹具(虚构) + +const WF = 'wf0cmp0sample' +const T = 'c0ffee0target00000000000000000001' +const B = 'c0ffee0base0000000000000000000002' +const B2 = 'c0ffee0older000000000000000000003' +const U = 'c0ffee0open0000000000000000000004' +const NAME = '区域销售周报' +const day = (d, h = 9) => `2026-${d}T0${h - 8}:00:00Z` + +const runOf = (id, over = {}) => ({ + id, workflow_id: WF, workflow_name: NAME, status: 'succeeded', input: {}, output: {}, error: null, + usage: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 }, run_class: 'formal', version: 4, version_hash: 'ab'.repeat(32), + manifest_hash: 'cd'.repeat(32), manifest_seq: 40, started_by: '值班分析员', error_node_id: null, memory_scope: null, + collection: null, schedule_id: 'sch0weekly', created_at: day('10-05'), started_at: day('10-05'), + finished_at: '2026-10-05T01:02:00Z', ...over, +}) +const RUNS = { + [T]: runOf(T), + [B]: runOf(B, { version: 3, created_at: day('09-28'), started_at: day('09-28'), finished_at: '2026-09-28T01:02:00Z' }), + [B2]: runOf(B2, { version: 3, created_at: day('09-21'), started_at: day('09-21'), finished_at: '2026-09-21T01:02:00Z' }), + // 没封存的一次:能出现在下拉里,但不能选 + [U]: runOf(U, { run_class: 'exploratory', version: null, manifest_hash: null, manifest_seq: null, schedule_id: null, + created_at: day('10-03'), started_at: day('10-03'), finished_at: '2026-10-03T01:02:00Z' }), +} +const refOf = (id) => { + const r = RUNS[id] + return { id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class, started_at: r.started_at, + finished_at: r.finished_at, schedule_id: r.schedule_id } +} + +const REGIONS = ['青禾区', '白石区', '云杉区', '赤松区', '蓝湾区', '金穗区', '银杏区', '翠竹区', '月湖区', '星港区', '石桥区', '柳岸区'] +// 各区的变化之和正好是总变化 2,531.5;占比按服务端的约定给小数(0.4 是 40%) +const DELTAS = [1012.6, 506.3, 379.7, 253.2, 126.6, 101.3, 63.3, 38.0, 25.3, 12.6, 7.6, 5.0] +const ATTR_ROWS = REGIONS.map((name, i) => ({ + key: [name], base: 3000 + i * 100, target: 3000 + i * 100 + DELTAS[i], delta: DELTAS[i], + share: Math.round(DELTAS[i] / 2531.5 * 10000) / 10000, +})) + +const metric = (over) => ({ + card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null, + decomposition_note: null, inputs: [], attribution: null, delta_pct: null, ...over, +}) +const METRICS = [ + metric({ metric_id: 'gmv', name: '销售额', unit: '元', base: 45678.5, target: 48210, base_rendered: '45,678.5元', + target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 0.0554, classification: 'data_only', + reasons: ['口径与 SQL 均未改动,变化来自数据'], + inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }], + attribution: { columns: ['区域'], measure: 'amount', rows: ATTR_ROWS, total_delta: 2531.5, note: null } }), + metric({ metric_id: 'orders', name: '订单数', unit: '单', decimals: 0, base: 1234, target: 1301, base_rendered: '1,234单', + target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 0.0543, classification: 'data_only' }), + metric({ metric_id: 'aov', name: '客单价', unit: '元', base: 37.0, target: 41.5, base_rendered: '37.0元', target_rendered: '41.5元', + delta: 4.5, delta_rendered: '+4.5元', delta_pct: 0.1216, classification: 'caliber_and_data', + reasons: ['口径卡「周报口径」中客单价的计算式有改动'], + decomposition: { caliber_effect: 5.8, data_effect: -1.3, caliber_rendered: '+5.8元', data_rendered: '-1.3元' }, + inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }], + attribution: { columns: ['区域', '渠道'], measure: 'order_cnt', total_delta: 67, + note: '各区域订单数之和与指标的输入对不上,不给占比', + rows: [{ key: ['青禾区', '门店'], base: 400, target: 440, delta: 40, share: null }, + { key: ['白石区', '线上'], base: 300, target: 327, delta: 27, share: null }] } }), + metric({ metric_id: 'refund_rate', name: '退货率', unit: '%', decimals: 2, base: 0.0235, target: 0.0198, base_rendered: '2.35%', + target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -0.1574, classification: 'sql_changed', + reasons: ['所依据的查询「退货明细」的筛选条件有改动'], + decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }), + metric({ metric_id: 'visitors', name: '日均访客', unit: '人', decimals: 0, base: 5200, target: 5480, base_rendered: '5,200人', + target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 0.0538, classification: 'params_only', + reasons: ['「拉取销量」只有日期参数不同'] }), + metric({ metric_id: 'new_users', name: '新客数', unit: '人', decimals: 0, base: null, target: 320, base_rendered: '—', + target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: ['本次新增的指标'] }), + metric({ metric_id: 'members', name: '会员数', unit: '人', decimals: 0, base: 8800, target: null, base_rendered: '8,800人', + target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: ['本次没有这个指标'] }), + metric({ metric_id: 'margin', name: '毛利率', unit: '%', decimals: 1, base: 0.31, target: 0.33, base_rendered: '31.0%', + target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 0.0645, classification: 'incomparable', + reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }), + ...[['stores', '门店数', '家', 56], ['complaints', '客诉数', '件', 12], ['sku', 'SKU 数', '个', 1500]].map(([id, name, unit, v]) => + metric({ metric_id: id, name, unit, decimals: 0, base: v, target: v, base_rendered: `${v.toLocaleString('en-US')}${unit}`, + target_rendered: `${v.toLocaleString('en-US')}${unit}`, delta: 0, delta_rendered: null, delta_pct: 0, classification: 'unchanged' })), +] +const SUMMARY = { unchanged: 3, data_only: 2, caliber_and_data: 1, sql_changed: 1, params_only: 1, added: 1, removed: 1, incomparable: 1 } +const PULL_BASE = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-21' AND sold_on < '2026-09-28' GROUP BY region" +const PULL_TARGET = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-28' AND sold_on < '2026-10-05' GROUP BY region" +const QUERIES = [ + { node_id: 'pull', node_title: '拉取销量', ordinal: 0, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET, + literals: [{ base: "'2026-09-21'", target: "'2026-09-28'" }, { base: "'2026-09-28'", target: "'2026-10-05'" }], + base_rows: 12, target_rows: 12, base_truncated: false, target_truncated: false }, + { node_id: 'refund', node_title: '退货明细', ordinal: 0, structure: 'changed', + base_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.created_at >= '2026-09-21'", + target_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.paid_at >= '2026-09-28' AND r.status = 'done'", + literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false }, + { node_id: 'stores', node_title: '门店清单', ordinal: 0, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores', + target_sql: 'SELECT COUNT(*) AS n FROM stores', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false }, + { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits', + target_sql: 'SELECT COUNT(*) AS n FROM visits', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false }, + { node_id: 'newbie', node_title: '新客查询', ordinal: 0, structure: 'added', base_sql: null, + target_sql: "SELECT COUNT(*) AS n FROM users WHERE first_order_on >= '2026-09-28'", literals: [], base_rows: null, target_rows: 1, + base_truncated: null, target_truncated: false }, + { node_id: 'member', node_title: '会员查询', ordinal: 0, structure: 'removed', base_sql: 'SELECT COUNT(*) AS n FROM members', + target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null }, +] +const TEMPLATE = { base_version: 3, target_version: 4, same_graph: false, nodes: [ + { node_id: 'caliber', title: '周报口径', change: 'config', keys: ['metrics'] }, + { node_id: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'] }, + { node_id: 'newbie', title: '新客查询', change: 'added', keys: [] }, + { node_id: 'member', title: '会员查询', change: 'removed', keys: [] }, +] } +const NOTE = '基准运行中「退货明细」的查询结果已截断(只保存了前 500 行),按已保存的行比较' +/** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */ +const NOTE_B2 = '以更早的一次运行为基准' + +function comparison(target, base) { + return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES, + notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] } +} + +const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data: '口径与数据都变', sql_changed: 'SQL 结构变', + params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' } +const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged'] +const REASON = '默认基准:同一定时任务的上一次运行' + +// ---------------------------------------------------------------- 假接口 + +function events() { + const t0 = Date.parse(day('10-05')) / 1000 + return [ + { seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 2 } }, + { seq: 2, type: 'run.finished', node_id: null, ts: t0 + 4, data: { output: {}, usage: {}, timing: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 } } }, + ] +} +const GRAPH = { nodes: [ + { id: 'pull', type: 'tool', position: { x: 0, y: 0 }, data: { label: '拉取销量', config: {} } }, + { id: 'caliber', type: 'metrics', position: { x: 200, y: 0 }, data: { label: '周报口径', config: {} } }, +], edges: [{ source: 'pull', target: 'caliber' }] } + +/** + * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) 可以换掉对应接口的答复,返回 {status, json, wait} + * (wait 是一个 Promise:等它落定再答,用来压住某一次请求) + */ +function defaults(opts) { + return async (method, url, n) => { + const p = url.pathname + if (method !== 'GET') return null + if (p === '/api/runs') { + const wf = url.searchParams.get('workflow_id') + const list = Object.values(RUNS).filter((r) => !wf || r.workflow_id === wf) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + return { json: list } + } + let m = p.match(/^\/api\/runs\/([^/]+)\/compare\/base$/) + if (m) { + if (opts.base) return opts.base(m[1]) + return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } } + : { status: 404, json: { detail: '没有找到可作为基准的运行' } } + } + m = p.match(/^\/api\/runs\/([^/]+)\/compare$/) + if (m) { + const base = url.searchParams.get('base') + if (opts.compare) return opts.compare(m[1], base, n) + return { json: comparison(m[1], base) } + } + m = p.match(/^\/api\/runs\/([^/]+)(\/[a-z]+)?$/) + if (m && RUNS[m[1]]) { + if (!m[2]) return { json: RUNS[m[1]] } + if (m[2] === '/events') return { json: events() } + if (m[2] === '/graph') return { json: { graph: GRAPH, workflow_id: WF, version: RUNS[m[1]].version } } + if (m[2] === '/artifacts') return { json: [] } + } + if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] } + return null + } +} + +const browser = await chromium.launch({ executablePath: CHROME }) + +/** + * 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。 + * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败 + */ +async function open(path, { width = 1280, height = 900, theme = 'dark', waitUntil = 'networkidle', ...opts } = {}) { + const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) + opened.add(ctx) + ctx.setDefaultTimeout(6000) + ctx.setDefaultNavigationTimeout(30000) + await ctx.addInitScript((t) => { + try { + localStorage.setItem('agentlab.theme', t) + localStorage.removeItem('agentlab.health') + } catch { /* noop */ } + }, theme) + const page = await ctx.newPage() + const log = [] + const blocked = [] + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) + const answer = defaults(opts) + let n = 0 + await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { + const req = route.request() + const url = new URL(req.url()) + const key = `${req.method()} ${url.pathname}${url.search}` + log.push(key) + const out = await answer(req.method(), url, ++n) + if (out) { + if (out.wait) await out.wait + return route.fulfill({ status: out.status ?? 200, json: out.json }).catch(() => {}) + } + if (req.method() === 'GET') return route.continue() + blocked.push(key) + return route.abort() + }) + await page.goto(`${WEB}${path}`, { waitUntil }) + // 设置接口回来时会按服务端存的主题再切一次:之后照 lib/theme.ts 的 applyTheme 切到要查的这一套——切的那一帧关掉 + // 过渡,不然带 transition 的按钮、摘要格还在慢慢变色,量出来、截下来的是半路的颜色 + await page.evaluate((t) => { + const root = document.documentElement + root.classList.add('theme-switching') + root.setAttribute('data-theme', t) + void getComputedStyle(root).backgroundColor + requestAnimationFrame(() => root.classList.remove('theme-switching')) + }, theme) + return { ctx, page, log, blocked, errors } +} + +const comparePath = (target, base) => `/runs/compare?target=${target}${base ? `&base=${base}` : ''}` +/** 等对比页落定:state 是 ok / error / no-target / finding… */ +const settled = (page, state = 'ok') => page.locator(`[data-compare-page][data-compare-state="${state}"]`).waitFor({ timeout: 8000 }) +const text = (page, sel) => page.locator(sel).first().innerText() +const texts = (page, sel) => page.locator(sel).allInnerTexts() +const has = (page, sel) => page.locator(sel).count().then((c) => c > 0) +const compareHits = (log, target, base) => log.filter((k) => k === `GET /api/runs/${target}/compare?base=${base}`).length +const until = async (fn, ms = 6000, step = 100) => { + const end = Date.now() + ms + let v = await fn() + while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() } + return v +} +const shot = async (page, name) => { + if (!SHOTS) return + mkdirSync(SHOTS, { recursive: true }) + await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false }) +} + +// ---------------------------------------------------------------- 检查 + +await section('从运行详情进入', async () => { + const { page, log, blocked, errors } = await open(`/runs/${T}`) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比') + && !(await item.isDisabled())) + await item.click() + await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) + await settled(page) + const url = new URL(page.url()) + check('跳到 /runs/compare,带着本次和基准', url.searchParams.get('target') === T && url.searchParams.get('base') === B, url.search) + const iBase = log.indexOf(`GET /api/runs/${T}/compare/base`) + const iCmp = log.indexOf(`GET /api/runs/${T}/compare?base=${B}`) + check('先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`) + check('compare/base 只请求一次(页面不再自己找一遍)', log.filter((k) => k.startsWith(`GET /api/runs/${T}/compare/base`)).length === 1) + check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => '')) + check('没有发出写请求', blocked.length === 0, blocked.join(';')) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('未封存的运行:入口不可点并说明原因', async () => { + const saved = RUNS[T] + RUNS[T] = { ...saved, manifest_hash: null, manifest_seq: null } + try { + const { page, log } = await open(`/runs/${T}`) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + check('未封存:「与上一次对比」不可点,写明要先封存', await item.isDisabled() && (await item.innerText()).includes('封存')) + check('未封存:没有请求 compare/base', !log.some((k) => k.includes('/compare'))) + } finally { + RUNS[T] = saved + } +}) + +await section('两次运行与摘要', async () => { + const { page, errors } = await open(comparePath(T, B)) + await settled(page) + const baseCard = page.locator('[data-compare-run="base"]') + const targetCard = page.locator('[data-compare-run="target"]') + check('基准、本次各一张卡,写着名字、版本和类别', (await baseCard.innerText()).includes(NAME) + && (await baseCard.innerText()).includes('正式运行 v3') && (await targetCard.innerText()).includes('正式运行 v4'), + `${(await baseCard.innerText()).replace(/\s+/g, ' ')} | ${(await targetCard.innerText()).replace(/\s+/g, ' ')}`) + check('两张卡都能点进各自的运行详情', await baseCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${B}` + && await targetCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${T}`) + check('定时任务发起的运行标出来', (await targetCard.innerText()).includes('定时任务发起')) + check('直接打开带基准的地址:不写「默认基准」', await page.locator('[data-compare-reason]').count() === 0) + const tiles = await page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) => + els.map((e) => [e.getAttribute('data-compare-class'), e.querySelector('[data-compare-count]')?.textContent?.trim(), e.textContent])) + check('摘要按约定的八类排好', JSON.stringify(tiles.map((t) => t[0])) === JSON.stringify(ORDER), tiles.map((t) => t[0]).join(',')) + check('摘要的个数和服务端给的一致', tiles.every(([k, n]) => Number(n) === SUMMARY[k]), tiles.map(([k, n]) => `${k}=${n}`).join(' ')) + check('摘要的标签写对', tiles.every(([k, , t]) => t.includes(LABEL[k]))) + await shot(page, 'wide-dark') + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('指标表', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + const rows = page.locator('tr[data-compare-metric]') + check('每个指标一行', await rows.count() === METRICS.length, String(await rows.count())) + const chips = await rows.evaluateAll((els) => els.map((e) => [e.getAttribute('data-compare-metric'), + e.getAttribute('data-class'), e.querySelector('[data-compare-chip]')?.textContent?.trim()])) + check('分类标签和分类对得上', chips.every(([id, k, label]) => METRICS.find((m) => `caliber:${m.metric_id}` === id)?.classification === k + && label === LABEL[k]), chips.map((c) => c.join('/')).join(' ')) + const gmv = page.locator('tr[data-compare-metric="caliber:gmv"]') + check('基准 → 本次、变化量用服务端渲染的写法', (await gmv.locator('[data-compare-base]').innerText()) === '45,678.5元' + && (await gmv.locator('[data-compare-target]').innerText()) === '48,210.0元' + && (await gmv.locator('[data-compare-delta]').innerText()) === '+2,531.5元') + check('变化百分比带正负号(0.0554 → +5.5%)', (await gmv.locator('[data-compare-pct]').innerText()) === '+5.5%', + await gmv.locator('[data-compare-pct]').innerText().catch(() => '')) + const refund = page.locator('tr[data-compare-metric="caliber:refund_rate"]') + check('下降的百分比写负号', (await refund.locator('[data-compare-pct]').innerText()) === '-15.7%') + const aov = page.locator('tr[data-compare-metric="caliber:aov"]') + check('能拆分的:口径效应、数据效应并列', (await aov.locator('[data-compare-caliber]').innerText()).includes('+5.8元') + && (await aov.locator('[data-compare-data]').innerText()).includes('-1.3元'), + (await aov.locator('[data-compare-decomp]').innerText().catch(() => '')).replace(/\s+/g, ' ')) + check('能拆分的:画了两段条形', await aov.locator('[data-compare-decomp] [data-compare-bar] > i').count() === 2) + check('表头说明两者之和等于总变化', (await text(page, '[data-compare-legend]')).includes('两者之和等于总变化')) + check('不能拆的:写出服务端给的原因', (await refund.locator('[data-compare-decomp-note]').innerText()) + === '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分') + check('无法比较的也写原因', (await page.locator('tr[data-compare-metric="caliber:margin"] [data-compare-decomp-note]').innerText()) + .includes('没有记录输入')) + check('新增的:基准写「—」,本次照写', (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-base]').innerText()) === '—' + && (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-target]').innerText()) === '320人') + check('未变的:变化写「无变化」', (await page.locator('tr[data-compare-metric="caliber:stores"] [data-compare-delta]').innerText()) === '无变化') + check('只数据变、不拆分的:不编造拆分', await gmv.locator('[data-compare-decomp], [data-compare-decomp-note]').count() === 0) +}) + +await section('展开:输入对比与维度归因', async () => { + const { page, errors } = await open(comparePath(T, B)) + await settled(page) + const gmvBtn = page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]') + check('默认收起', (await gmvBtn.getAttribute('aria-expanded')) === 'false' && await page.locator('[data-compare-detail]').count() === 0) + await gmvBtn.click() + const detail = page.locator('tr[data-compare-detail="caliber:gmv"]') + await detail.waitFor() + check('展开后标为展开、指向明细', (await gmvBtn.getAttribute('aria-expanded')) === 'true' + && (await gmvBtn.getAttribute('aria-controls')) === await detail.getAttribute('id')) + check('列出变化说明', (await detail.locator('[data-compare-reasons]').innerText()).includes('变化来自数据')) + const input = detail.locator('[data-compare-inputs] tr[data-compare-input="vars.kpi.gmv"]') + check('输入对比:路径、两边的值,变了的标出来', (await input.innerText()).includes('45,678.5') && (await input.innerText()).includes('48,210') + && (await input.getAttribute('data-changed')) !== null, (await input.innerText().catch(() => '')).replace(/\s+/g, ' ')) + const attrRows = detail.locator('[data-compare-attr] tr[data-compare-attr-row]') + check('维度归因只列前 10 项', await attrRows.count() === 10, String(await attrRows.count())) + check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('2')) + const first = attrRows.first() + check('归因一行:键、两边的值、变化、占比', (await first.innerText()).includes('青禾区') && (await first.innerText()).includes('+1,012.6') + && (await first.locator('[data-compare-share]').innerText()) === '40.0%', (await first.innerText()).replace(/\s+/g, ' ')) + check('写明按哪一列归因', (await detail.locator('[data-compare-attr]').innerText()).includes('amount')) + check('有占比时不写缺占比的原因', await detail.locator('[data-compare-attr-note]').count() === 0) + + // 键盘:Tab 到客单价的展开按钮,回车展开 + const aovBtn = page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]') + await aovBtn.focus() + await page.keyboard.press('Enter') + const aov = page.locator('tr[data-compare-detail="caliber:aov"]') + await aov.waitFor() + check('键盘回车也能展开', (await aovBtn.getAttribute('aria-expanded')) === 'true') + check('拆分写成一句:口径效应、数据效应,两者之和等于总变化', + (await aov.locator('[data-compare-decomp-sum]').innerText()).trim() === '口径效应 +5.8元,数据效应 -1.3元,两者之和等于总变化 +4.5元', + await aov.locator('[data-compare-decomp-sum]').innerText().catch(() => '')) + check('没有变的输入不标', (await aov.locator('tr[data-compare-input="vars.kpi.order_cnt"]').getAttribute('data-changed')) === null) + const shares = await aov.locator('[data-compare-attr] [data-compare-share]').allInnerTexts() + check('没有占比:占比一栏写「—」', shares.length === 2 && shares.every((s) => s.trim() === '—'), shares.join(',')) + check('没有占比:写出原因', (await aov.locator('[data-compare-attr-note]').innerText()).includes('各区域订单数之和与指标的输入对不上')) + check('多个维度列:键按列写全', (await aov.locator('[data-compare-attr] tr[data-compare-attr-row]').first().innerText()).includes('门店')) + await shot(page, 'expanded-dark') + + // 再按一次收起 + await aovBtn.press('Enter') + check('再按一次收起', await page.locator('tr[data-compare-detail="caliber:aov"]').count() === 0) + // 没有归因、没有输入的指标:照实说 + await page.locator('tr[data-compare-metric="caliber:orders"] button[data-compare-expand]').click() + const orders = page.locator('tr[data-compare-detail="caliber:orders"]') + check('没有维度归因、没有输入时照实说', (await orders.locator('[data-compare-attr-none]').count()) === 1 + && (await orders.locator('[data-compare-inputs-none]').count()) === 1) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('摘要筛选', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + const tile = page.locator('[data-compare-summary] [data-compare-class="sql_changed"]') + await tile.click() + check('点一类:只剩这一类的指标,按钮标为按下', await page.locator('tr[data-compare-metric]').count() === 1 + && (await tile.getAttribute('aria-pressed')) === 'true' + && await page.locator('tr[data-compare-metric="caliber:refund_rate"]').count() === 1) + check('写明正在筛选', (await text(page, '[data-compare-filter]')).includes('SQL 结构变')) + await tile.press('Enter') + check('再按一次(键盘)回到全部', await page.locator('tr[data-compare-metric]').count() === METRICS.length + && (await tile.getAttribute('aria-pressed')) === 'false') + await page.locator('[data-compare-summary] [data-compare-class="unchanged"]').click() + await page.locator('[data-compare-filter-clear]').click() + check('「显示全部」清掉筛选', await page.locator('tr[data-compare-metric]').count() === METRICS.length) +}) + +await section('查询', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + const qs = page.locator('[data-compare-query]') + check('每条查询一项', await qs.count() === QUERIES.length, String(await qs.count())) + const refund = page.locator('[data-compare-query="refund:0"]') + check('结构变:标「结构变」', (await refund.getAttribute('data-structure')) === 'changed' + && (await refund.locator('[data-compare-structure]').innerText()).trim() === '结构变') + const baseSql = refund.locator('[data-compare-sql="base"]') + const targetSql = refund.locator('[data-compare-sql="target"]') + const [bb, tb] = [await baseSql.boundingBox(), await targetSql.boundingBox()] + check('结构变:两条 SQL 并排(基准在左、本次在右)', !!bb && !!tb && Math.abs(bb.y - tb.y) < 2 && bb.x + bb.width <= tb.x + 1, + JSON.stringify({ bb, tb })) + check('两边都是原文', (await baseSql.innerText()).includes('r.created_at') && (await targetSql.innerText()).includes("r.status = 'done'")) + const del = (await baseSql.locator('mark[data-diff="del"]').allInnerTexts()).join(' ') + const add = (await targetSql.locator('mark[data-diff="add"]').allInnerTexts()).join(' ') + check('高亮基准独有的部分', del.includes('created_at') && del.includes("'2026-09-21'") && !del.includes('SELECT'), del) + check('高亮本次独有的部分', add.includes('paid_at') && add.includes("r.status = 'done'") && !add.includes('FROM'), add) + check('相同的部分不高亮', !(await baseSql.locator('mark').allInnerTexts()).join(' ').includes('refunds')) + check('截断照实标', (await refund.locator('[data-compare-rows]').innerText()).includes('已截断')) + const pull = page.locator('[data-compare-query="pull:0"]') + const lits = await pull.locator('[data-compare-literals] li').allInnerTexts() + check('只参数变:列出变化的参数', lits.length === 2 && lits[0].includes("'2026-09-21'") && lits[0].includes("'2026-09-28'") + && lits[1].includes("'2026-10-05'"), lits.join(' | ')) + check('只参数变:默认不并排摆两条 SQL', await pull.locator('[data-compare-sql]').count() === 0) + const same = page.locator('[data-compare-query="stores:0"]') + check('相同的:默认不展开 SQL', (await same.locator('[data-compare-structure]').innerText()).trim() === '相同' + && await same.locator('[data-compare-sql]').count() === 0) + await same.locator('button[data-compare-sql-toggle]').click() + check('相同的:点开能看 SQL', (await same.locator('[data-compare-sql]').first().innerText()).includes('FROM stores')) + check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:1"] [data-compare-nth]').innerText()).includes('第 2 条') + && (await pull.locator('[data-compare-nth]').innerText()).includes('第 1 条')) + check('只有一条查询的节点不写第几条', await refund.locator('[data-compare-nth]').count() === 0) + const added = page.locator('[data-compare-query="newbie:0"]') + check('新增、移除的查询各只有一边的 SQL', await added.locator('[data-compare-sql="target"]').count() === 1 + && await added.locator('[data-compare-sql="base"]').count() === 0 + && await page.locator('[data-compare-query="member:0"] [data-compare-sql="base"]').count() === 1) + await refund.scrollIntoViewIfNeeded() + await shot(page, 'queries-dark') +}) + +await section('模板与说明', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + check('模板:版本 v3 → v4', (await text(page, '[data-compare-versions]')).includes('v3') && (await text(page, '[data-compare-versions]')).includes('v4')) + const node = page.locator('[data-compare-node="refund"]') + check('变化的节点:名字、变化类型、配置项', (await node.getAttribute('data-change')) === 'config' && (await node.innerText()).includes('退货明细') + && (await node.innerText()).includes('配置变更') + && JSON.stringify(await node.locator('[data-compare-key]').allInnerTexts()) === JSON.stringify(['sql', 'timeout'])) + check('新增、移除的节点写明', (await text(page, '[data-compare-node="newbie"]')).includes('新增节点') + && (await text(page, '[data-compare-node="member"]')).includes('移除节点')) + check('说明照实列出', JSON.stringify(await texts(page, '[data-compare-notes] li')) === JSON.stringify([NOTE])) +}) + +await section('换基准、互换', async () => { + const { page, log } = await open(comparePath(T, B)) + await settled(page) + const picker = page.locator('select[data-compare-base-picker]') + const options = await picker.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.disabled, o.textContent])) + check('下拉列出同一工作流的其他运行(不含本次)', options.some(([v]) => v === B) && options.some(([v]) => v === B2) + && !options.some(([v]) => v === T), options.map((o) => o[0].slice(0, 12)).join(',')) + const open_ = options.find(([v]) => v === U) + check('没封存的那次不能选,写明原因', !!open_ && open_[1] === true && open_[2].includes('未封存'), JSON.stringify(open_)) + check('当前基准是选中的', await picker.inputValue() === B) + check('列运行用的是运行列表接口(按工作流)', log.some((k) => k.startsWith('GET /api/runs?') && k.includes(`workflow_id=${WF}`))) + await picker.selectOption(B2) + await until(async () => new URL(page.url()).searchParams.get('base') === B2) + await until(async () => (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2)) + check('换基准:地址跟着变', new URL(page.url()).searchParams.get('base') === B2) + check('换基准:重新请求对比', compareHits(log, T, B2) === 1) + check('换基准:页面换成新的一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2)) + check('换基准:基准卡跟着换', (await text(page, '[data-compare-run="base"]')).includes('9/21')) + await page.locator('button[data-compare-swap]').click() + await until(async () => new URL(page.url()).searchParams.get('target') === B2) + await until(async () => compareHits(log, B2, T) === 1) + const u = new URL(page.url()) + check('互换:本次和基准对调', u.searchParams.get('target') === B2 && u.searchParams.get('base') === T, u.search) + check('互换:重新请求对比', compareHits(log, B2, T) === 1) + await settled(page) + check('互换:两张卡跟着对调', (await page.locator('[data-compare-run="target"] a[data-compare-run-link]').getAttribute('href')) === `/runs/${B2}`) +}) + +await section('过时的答复不盖掉新的', async () => { + let release + const gate = new Promise((r) => { release = r }) + const { page, log } = await open(comparePath(T, B), { waitUntil: 'domcontentloaded', compare: async (target, base) => { + if (base === B) return { json: comparison(target, base), wait: gate } + return { json: comparison(target, base) } + } }) + await page.locator('[data-compare-page]').waitFor({ timeout: 10000 }) + const loading = await until(() => has(page, '[data-compare-loading][aria-busy="true"]')) + check('加载中:标为忙', loading) + await until(async () => (await page.locator('select[data-compare-base-picker] option').count()) > 1) + await page.locator('select[data-compare-base-picker]').selectOption(B2) + await settled(page) + release() + await page.waitForTimeout(600) + check('慢的旧答复回来后,页面仍是新基准的那一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2) + && new URL(page.url()).searchParams.get('base') === B2) + check('两次都发出去了', compareHits(log, T, B) === 1 && compareHits(log, T, B2) === 1) +}) + +/** 「其他错误」那一节:第一次对比回 500,之后照常 */ +let flakyCalls = 0 +await section('出错与找不到基准', async () => { + const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) }) + await settled(sealed.page, 'error') + const e409 = sealed.page.locator('[data-compare-error="409"]') + check('409:写明未封存或封存核对不一致,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()).includes('封存') + && (await e409.innerText()).includes('基准运行的封存核对不一致,无法对比'), (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ')) + check('409:给出去两次运行详情核对的入口', await e409.locator(`a[href="/runs/${B}"]`).count() === 1 + && await e409.locator(`a[href="/runs/${T}"]`).count() === 1) + check('409:两张运行卡仍在,下拉仍能换基准', await sealed.page.locator('[data-compare-run="base"]').count() === 1 + && await sealed.page.locator('select[data-compare-base-picker]').isEnabled()) + check('409:不显示摘要、指标', await sealed.page.locator('[data-compare-summary], tr[data-compare-metric]').count() === 0) + + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '两次运行不属于同一个工作流' } }) }) + await settled(other.page, 'error') + const e422 = other.page.locator('[data-compare-error="422"]') + check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流') + && (await e422.innerText()).includes('同一工作流'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) + + const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) }) + await settled(gone.page, 'error') + check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在')) + + const flaky = await open(comparePath(T, B), { compare: () => (flakyCalls++ === 0 + ? { status: 500, json: { detail: '对比服务暂时不可用' } } : { json: comparison(T, B) }) }) + await settled(flaky.page, 'error') + await flaky.page.locator('[data-compare-error] button', { hasText: '重试' }).click() + await settled(flaky.page) + check('其他错误:给「重试」,重试后正常显示', await flaky.page.locator('tr[data-compare-metric]').count() === METRICS.length) + + // 只带本次:页面自己找基准,找不到时写原因,下拉照样能选 + const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) }) + await settled(lone.page, 'error') + const noBase = lone.page.locator('[data-compare-error="no-base"]') + check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行') + && (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行')) + check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled()) + await lone.page.locator('select[data-compare-base-picker]').selectOption(B2) + await settled(lone.page) + check('手动选了之后照常对比', new URL(lone.page.url()).searchParams.get('base') === B2 && compareHits(lone.log, T, B2) === 1) + + // 只带本次、找得到:自己补上基准,写明怎么选的 + const auto = await open(comparePath(T)) + await settled(auto.page) + check('只带本次:自己找到基准、补进地址', new URL(auto.page.url()).searchParams.get('base') === B + && (await text(auto.page, '[data-compare-reason]')).trim() === REASON) + + // 运行详情里找不到基准:提示原因,给「手动选择基准」 + const menu = await open(`/runs/${T}`, { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) }) + await menu.page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await menu.page.getByRole('button', { name: '更多操作' }).click() + await menu.page.locator('[data-menu=compare]').click() + const toast = menu.page.locator('[data-toast]', { hasText: '同一工作流没有其他已封存的正式运行' }) + check('运行详情里找不到基准:提示原因、留在原页', await until(() => toast.count().then((c) => c === 1)) + && new URL(menu.page.url()).pathname === `/runs/${T}`) + await toast.getByRole('button', { name: '手动选择基准' }).click() + await menu.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) + check('「手动选择基准」去到对比页,只带本次', new URL(menu.page.url()).searchParams.get('target') === T + && !new URL(menu.page.url()).searchParams.get('base')) + + const none = await open('/runs/compare') + await settled(none.page, 'no-target') + check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比')) +}) + +await section('空的对比', async () => { + const { page } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), metrics: [], queries: [], notes: [], + summary: Object.fromEntries(ORDER.map((k) => [k, 0])), template: { base_version: 4, target_version: 4, same_graph: true, nodes: [] } } }) }) + await settled(page) + check('没有指标:照实说', await page.locator('[data-compare-empty="metrics"]').count() === 1) + check('没有查询:照实说', await page.locator('[data-compare-empty="queries"]').count() === 1) + check('执行图相同:照实说', await page.locator('[data-compare-same-graph]').count() === 1) + check('没有说明时不留空的「说明」一节', await page.locator('[data-compare-notes]').count() === 0) + check('摘要全是 0,不能点', (await page.locator('[data-compare-summary] [data-compare-class]:not([disabled])').count()) === 0) +}) + +/** 对比页自己有没有横向滚动(表格、SQL 在自己的框里横向滚动不算),以及它的右边有没有出视口 */ +const overflow = (page) => page.evaluate(() => { + const root = document.querySelector('[data-compare-page]') + if (!root) return { root: 99, right: 99 } + const r = root.getBoundingClientRect() + const wide = [...root.querySelectorAll('*')].filter((el) => { + const b = el.getBoundingClientRect() + return b.width > 0 && b.right > r.right + 1 && !el.closest('[data-compare-scroll]') + }).map((el) => el.tagName + (el.getAttribute('data-compare-run') ?? '')) + return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) } +}) + +await section('窄屏', async () => { + for (const theme of ['dark', 'light']) { + const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme }) + await settled(page) + let o = await overflow(page) + check(`390px(${theme}):页面不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o)) + await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click() + await page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]').click() + o = await overflow(page) + check(`390px(${theme}):展开归因后也不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o)) + const scroller = await page.evaluate(() => { + const box = document.querySelector('[data-compare-metrics] [data-compare-scroll]') + return box ? { sw: box.scrollWidth, cw: box.clientWidth, focusable: box.tabIndex >= 0 } : null + }) + check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable, + JSON.stringify(scroller)) + const refund = page.locator('[data-compare-query="refund:0"]') + const [bb, tb] = [await refund.locator('[data-compare-sql="base"]').boundingBox(), await refund.locator('[data-compare-sql="target"]').boundingBox()] + check(`390px(${theme}):两条 SQL 上下排、都在屏幕里`, !!bb && !!tb && tb.y >= bb.y + bb.height - 1 && bb.x >= 0 && bb.x + bb.width <= 390, + JSON.stringify({ bb, tb })) + o = await overflow(page) + check(`390px(${theme}):查询区不横向溢出`, o.root <= 1 && !o.wide.length, JSON.stringify(o)) + await shot(page, `narrow-${theme}`) + check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) + } +}) + +/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */ +function contrast(fg, bg, base) { + const parse = (c) => { + const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/) + if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])] + const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/) + return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null + } + const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3])) + const lum = ([r, g, b]) => { + const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) + } + const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg) + if (!b0 || !b1 || !f0) return 0 + const under = over(b1, b0.slice(0, 3)) + const a = lum(over(f0, under)); const b = lum(under) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +await section('深浅色可读', async () => { + for (const theme of ['dark', 'light']) { + const { page } = await open(comparePath(T, B), { theme }) + await settled(page) + const colors = await page.evaluate(() => { + /** 往上找第一个不透明的底色 */ + const baseOf = (el) => { + for (let p = el.parentElement; p; p = p.parentElement) { + const c = getComputedStyle(p).backgroundColor + if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c + } + return getComputedStyle(document.body).backgroundColor + } + const pick = (sel) => { + const el = document.querySelector(sel) + if (!el) return null + const s = getComputedStyle(el) + return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) } + } + return { + chips: Object.fromEntries([...document.querySelectorAll('tr[data-compare-metric] [data-compare-chip]')] + .map((el) => [el.closest('tr').getAttribute('data-class'), (() => { + const s = getComputedStyle(el) + return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) } + })()])), + del: pick('[data-compare-sql="base"] mark[data-diff="del"]'), + add: pick('[data-compare-sql="target"] mark[data-diff="add"]'), + note: pick('[data-compare-decomp-note]'), + } + }) + const low = Object.entries(colors.chips).map(([k, c]) => [k, contrast(c.fg, c.bg, c.base)]).filter(([, r]) => r < 4.5) + check(`${theme}:分类标签的文字对比度都 ≥ 4.5`, Object.keys(colors.chips).length === 8 && low.length === 0, + low.map(([k, r]) => `${k} ${r.toFixed(2)}`).join(',')) + for (const k of ['del', 'add', 'note']) { + const c = colors[k] + const r = c ? contrast(c.fg, c.bg, c.base) : 0 + check(`${theme}:${{ del: '基准独有的高亮', add: '本次独有的高亮', note: '不能拆分的原因' }[k]}对比度 ≥ 4.5`, r >= 4.5, + c ? `${r.toFixed(2)}(${c.fg} on ${c.bg} / ${c.base})` : '没找到') + } + await shot(page, `wide-${theme}`) + } +}) + +await browser.close() +console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过') +process.exit(failed ? 1 : 0)