合入期 2 变化三分法(前端):运行对比页(摘要、指标表、效应拆分、维度归因、查询与模板差异),运行详情加「与上一次对比」

# Conflicts:
#	frontend/src/types.ts
#	scripts/check-all.mjs
This commit is contained in:
ThearchyHelios
2026-10-05 12:50:04 +08:00
17 changed files with 2903 additions and 19 deletions
+20 -2
View File
@@ -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<RunCompareBase>(`${run(runId)}/compare/base`, opts),
/**
* 两次运行的对比(runId 是本次)。任一未封存或封存核对不一致回 409;不是同一工作流、或者是同一次运行回 422;
* 运行不存在回 404。detail 都是一句中文。signal 用来丢掉换了基准之后过时的请求
*/
get: (runId: string, base: string, opts?: RequestOptions) =>
get<RunComparison>(`${run(runId)}/compare?${new URLSearchParams({ base })}`, opts),
}
+193 -4
View File
@@ -3057,12 +3057,201 @@ export const SCHEDULE_TEXT = {
runTagSr: (name?: string | null) => (name ? `由定时任务「${name}」发起` : '由定时任务发起'),
}
/** 运行对比:记录页里两次运行的对照。配方前后对照另有 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: '运行结束并封存后才能对比',
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
menuNoWorkflow: '工作流未保存或已删除,无法对比',
menuFinding: '正在查找上一次运行…',
pickManually: '手动选择基准',
retry: '重试',
// 顶部的两次运行
base: '基准',
target: '本次',
swap: '互换',
swapHint: '互换基准与本次',
openRun: (name: string) => `查看「${name}」这次运行的详情`,
scheduled: '定时任务发起',
unsealed: '未封存',
unsealedHint: '这次运行没有封存清单,不能参与对比',
runFailed: '无法读取这次运行',
runMissing: '运行记录不存在',
noBase: '未选择',
reason: {
same_schedule: '默认基准:同一定时任务的上一次运行',
same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行',
} as Record<string, string>,
pickBase: '更换基准',
pickHint: '同一工作流最近的运行,未封存的不能作为基准',
pickLoading: '正在读取同一工作流的运行…',
pickFailed: '无法读取同一工作流的运行',
pickNone: '同一工作流没有其他运行',
pickUnsaved: '工作流未保存或已删除,没有可对比的运行',
pickPlaceholder: '选择一次运行作为基准',
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
/** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
// 页面状态
noTarget: '未指定要对比的运行',
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行',
toRuns: '前往记录',
finding: '正在查找基准运行…',
loading: '正在对比两次运行…',
// 对比失败:标题说是哪一种,服务端的原因原样跟在后面,最后一句下一步只写界面上真有的入口,服务端已经写了的不再重复
errSealed: '无法对比:有运行未封存或封存核对不一致',
errUnsealed: '无法对比:有运行尚未结束并封存',
errSealBroken: '无法对比:封存核对不一致',
/** 409 时不知道(或者知道不是)本次的问题:服务端的原因里写着是哪一次 */
errSealedHint: '若是基准运行的问题,可在上方更换基准',
errMismatch: '无法对比这两次运行',
errMismatchHint: '可在上方选择同一工作流的另一次运行作为基准',
/** 本次没有 workflow_id:哪一次都比不了,不给下一步 */
errUnsaved: '无法对比:本次运行的工作流未保存或已删除',
errMissing: '运行记录不存在',
errMissingHint: '链接可能已失效,请从记录页重新打开',
/** 本次的记录读得到、对比却回 404:缺的是基准 */
errBaseMissingHint: '可在上方选择另一次运行作为基准',
errNoBase: '未找到可作为基准的运行',
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
/** 下拉里一次能选的都没有(只有本次、或者都没封存) */
errNoBaseNone: '同一工作流还没有其他已封存的运行',
viewBase: '查看基准运行',
viewTarget: '查看本次运行',
// 摘要
summary: '变化摘要',
summaryHint: '点击一类只看这一类的指标',
classLabel: {
data_only: '只数据变',
caliber_and_data: '口径与数据都变',
sql_changed: 'SQL 结构变',
params_only: '只参数变',
added: '新增',
removed: '移除',
incomparable: '无法比较',
unchanged: '未变',
} as Record<string, string>,
// 照服务端(engine/compare.py)的分类口径写:口径变了含小数位、单位、显示格式,SQL 结构变含上游节点改配置
classHint: {
data_only: '口径与 SQL 都没有改动,变化来自数据',
caliber_and_data: '口径(计算式、口径版本、小数位、单位或显示格式)有改动,数据可能也有变化;能严格拆分的分别给出口径效应和数据效应',
sql_changed: '取数的 SQL 或上游节点的配置有改动,无法在口径与数据之间拆分',
params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)或运行输入不同',
added: '本次有、基准没有的指标',
removed: '基准有、本次没有的指标',
incomparable: '缺少比较所需的信息,无法判断变化来自哪里',
unchanged: '两次的值相同,口径与 SQL 也没有改动',
} as Record<string, string>,
classOther: '其他',
filterOn: (label: string, n: number) => `只显示「${label}」的 ${formatNumber(n)} 个指标`,
showAll: '显示全部',
// 指标
metrics: '指标',
metricsEmpty: '两次运行的口径卡上都没有指标',
count: (n: number) => `${formatNumber(n)} 个`,
/** 查询按「条」数,和「第 N 条」一致 */
queryCount: (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: '口径卡没有记录这个指标的输入',
/** 取自设置了遮罩的列:两边的值都不显示 */
masked: '已遮罩',
colInput: '输入',
attribution: '维度归因',
attrBy: (measure: string) => `按「${measure}」的变化,从大到小`,
attrTop: (n: number) => `前 ${formatNumber(n)} 项`,
attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`,
attrTotal: (value: string) => `合计变化 ${value}`,
attrNone: '没有可按维度归因的明细',
/**
* 服务端只对只数据变、只参数变、口径与数据都变的指标做维度归因(两边的行说的是同一回事)。别的类不是「没有明细」,
* 是有意不做:写明为什么
*/
attrSkipped: {
sql_changed: '取数的 SQL 或上游配置有改动,两边的行不能直接对照,不做维度归因',
incomparable: '无法判断变化来源,不做维度归因',
added: '基准没有这个指标,不做维度归因',
removed: '本次没有这个指标,不做维度归因',
unchanged: '数值没有变化,不做维度归因',
} as Record<string, string>,
/** 服务端没给占比、也没说为什么时的说法 */
attrNoShare: '无法计算各项占比',
colKey: '维度',
colShare: '占比',
// 查询
queries: '查询',
queriesEmpty: '两次运行都没有执行查询',
structure: {
same: '相同',
params_only: '只参数变',
changed: '结构变',
added: '新增',
removed: '移除',
} as Record<string, string>,
structureHint: {
same: 'SQL 完全相同',
params_only: 'SQL 结构相同,只有字面量不同',
// 服务端在只换了数据源、有一边的 SQL 读不出来时也标「结构变」
changed: 'SQL 结构或数据源不同,或有一边的 SQL 无法读取',
added: '本次新增的查询,基准中没有',
removed: '基准中有、本次没有的查询',
} as Record<string, string>,
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: '变化的参数',
/** 参数前后两个值之间的箭头,读屏念这个 */
changedTo: '改为',
baseSql: '基准的 SQL',
targetSql: '本次的 SQL',
bothSql: '两次相同的 SQL',
/** 快照读不出来、又拿不到执行时的参数:这一边没有 SQL,不能拿空串去和另一边比 */
sqlUnreadable: '这一边的 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<string, string>,
keys: '配置项',
// 说明
notes: '说明',
}
/**
+114 -6
View File
@@ -1,21 +1,129 @@
import { useEffect, useRef, 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, isGone } from './compare/model'
import { CompareError, type CompareErrorContext } 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 lastWorkflow = useRef<string | null | undefined>(undefined)
if (targetRun.state === 'ok') lastWorkflow.current = targetRun.data?.workflow_id ?? null
const workflowId = targetRun.state === 'ok' ? targetRun.data?.workflow_id : lastWorkflow.current
const candidates = useCandidates(workflowId)
const cmp = useComparison(target, base)
// 不属于已保存的工作流:没保存过,或者工作流删掉之后 workflow_id 被置空(名字还是原来的)。服务端两种都不比
const unsaved = targetRun.state === 'ok' && !workflowId
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'
// 错误说明的下一步按页面已经知道的挑(parts.tsx 的 errorCopy):下拉里没有能选的就不叫人去上方选
const errorContext: CompareErrorContext = {
targetKnown: targetRun.state === 'ok',
targetUnsaved: unsaved,
targetUnsealed: targetRun.state === 'ok' && !targetRun.data?.manifest_hash,
targetGone: targetRun.state === 'error' && isGone(targetRun.error),
baseGone: baseRun.state === 'error' && isGone(baseRun.error),
pickable: candidates.state === 'ok' ? (candidates.data ?? []).some((r) => r.id !== target && !!r.manifest_hash) : null,
}
return (
<div className="flex h-full flex-col">
<PageHeader icon={<GitCompareArrows size={13} />} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} />
<div className="min-h-0 flex-1 overflow-y-auto">
{/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */}
<EmptyState icon={<GitCompareArrows size={22} />} title={RUN_COMPARE_TEXT.pending}
body={RUN_COMPARE_TEXT.pendingHint} offline={false} />
<div className="min-h-0 flex-1 overflow-y-auto" data-compare-page="" data-compare-state={state}>
{!target ? (
<EmptyState icon={<GitCompareArrows size={22} />} title={RUN_COMPARE_TEXT.noTarget} body={RUN_COMPARE_TEXT.noTargetHint}
offline={false} action={<Link className="btn btn-sm" to="/runs">{RUN_COMPARE_TEXT.toRuns}</Link>} />
) : (
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-3 px-3 py-3 sm:px-4 sm:py-4">
<RunPair
target={target} base={base} targetRun={targetRun} baseRun={baseRun} candidates={candidates}
unsaved={unsaved} reason={reason}
onPick={(b) => go(target, b)} onSwap={() => { if (base) go(base, target) }}
/>
{state === 'finding' && <Pending text={RUN_COMPARE_TEXT.finding} />}
{state === 'loading' && <Pending text={RUN_COMPARE_TEXT.loading} />}
{state === 'error' && (
<CompareError
kind={compareErrorKind(base ? cmp.error : finder.error, !base)}
error={base ? cmp.error : finder.error}
onRetry={base ? cmp.reload : finder.reload}
target={target} base={base} context={errorContext}
/>
)}
{state === 'ok' && cmp.data && <Comparison key={`${target}|${base}`} data={cmp.data} />}
</div>
)}
</div>
</div>
)
}
/** 找基准、对比的请求在路上:一句在做什么,下面是占位的骨架 */
function Pending({ text }: { text: string }) {
return (
<div className="flex flex-col gap-3" data-compare-loading="" aria-busy="true">
<div role="status" className="flex items-center gap-2 text-xs text-faint"><Spinner size={12} /> {text}</div>
<Skeleton rows={1} cols={4} height={44} />
<Skeleton rows={6} height={20} />
</div>
)
}
/** 对比结果。按「本次|基准」换 key:换了一对运行,筛选、展开都从头来 */
function Comparison({ data }: { data: RunComparison }) {
const [filter, setFilter] = useState<string | null>(null)
return (
<>
<Summary summary={data.summary} filter={filter} onFilter={setFilter} />
<NotesSection notes={data.notes ?? []} />
<Metrics metrics={data.metrics ?? []} filter={filter} onClearFilter={() => setFilter(null)} />
<Queries queries={data.queries ?? []} />
<TemplateSection template={data.template} />
</>
)
}
@@ -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<Feedback | null>(null)
const streamBox = useRef<HTMLDivElement>(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: <Link2 size={12} />,
onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'),
},
compareEntry(run, live),
{
key: 'delete', label: '删除记录…', icon: <Trash2 size={12} />, danger: true, onSelect: () => void remove(),
disabled: live || busy === 'delete',
+341
View File
@@ -0,0 +1,341 @@
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 { changeText, metricKey, sameValue, shareText, signedNumber, splitReason, 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)]'
/**
* 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段
* 两截的条形),不能拆、比不了的写原因(splitReason)。每行能展开:变化说明、输入对比、维度归因前 10 项。
*
* 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的
* 可见宽度:窄屏上不用横着滚也读得全
*/
export function Metrics({ metrics, filter, onClearFilter }: {
metrics: RunCompareMetric[]; filter: string | null; onClearFilter: () => void
}) {
const id = useId()
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set())
const scroller = useRef<HTMLDivElement>(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 (
<Block id={`${id}-metrics`} title={T.metrics} count={T.count(metrics.length)} attr={{ 'data-compare-metrics': '' }}>
{!metrics.length ? (
<p className="py-4 text-center text-xs text-faint" data-compare-empty="metrics">{T.metricsEmpty}</p>
) : (
<>
{filter && (
<div className="mb-2 flex flex-wrap items-center gap-2 text-2xs text-dim" data-compare-filter="">
<span>{T.filterOn(T.classLabel[filter] ?? T.classOther, shown.length)}</span>
<button type="button" className="btn btn-xs" data-compare-filter-clear="" onClick={onClearFilter}>{T.showAll}</button>
</div>
)}
{split && (
<p className="mb-2 flex gap-2 text-2xs leading-relaxed text-faint" data-compare-legend="">
<span className="mt-1 flex shrink-0 gap-1" aria-hidden>
<i className="block h-2 w-2 rounded-sm" style={{ background: CALIBER_COLOR }} />
<i className="block h-2 w-2 rounded-sm" style={{ background: DATA_COLOR }} />
</span>
<span>{T.legend}</span>
</p>
)}
<div ref={scroller} className="overflow-x-auto rounded border focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
data-compare-scroll="" tabIndex={0} role="region" aria-label={T.metrics}>
<table className="w-full min-w-[760px] border-collapse text-xs">
<thead>
<tr className="border-b text-left text-2xs text-faint">
<th scope="col" className={clsx(STICKY, 'bg-panel px-2.5 py-1.5 font-medium')}>{T.colMetric}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colBase}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colTarget}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colDelta}</th>
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.colClass}</th>
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.colSplit}</th>
</tr>
</thead>
<tbody>
{shown.map((m) => {
const k = metricKey(m)
return <MetricRows key={k} m={m} k={k} detailId={`${id}-d-${k}`} open={open.has(k)} onToggle={() => toggle(k)} />
})}
</tbody>
</table>
</div>
</>
)}
</Block>
)
}
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 ? changeText(m.delta_pct) : null
const why = m.decomposition ? null : splitReason(m)
return (
<Fragment>
<tr className={clsx('border-b align-top last:border-0', open && 'bg-hover')} data-compare-metric={k} data-class={m.classification}>
<th scope="row" className={clsx(STICKY, open ? 'bg-hover' : 'bg-panel', 'px-2.5 py-2 text-left font-normal')}>
<button type="button" data-compare-expand="" aria-expanded={open} aria-controls={open ? detailId : undefined}
title={open ? T.collapse(m.name) : T.expand(m.name)} onClick={onToggle}
className="group flex max-w-[220px] items-start gap-1 rounded-sm text-left focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]">
<ChevronRight size={12} aria-hidden
className={clsx('mt-0.5 shrink-0 text-faint transition-transform group-hover:text-dim', open && 'rotate-90')} />
<span className="min-w-0">
<span className="block font-medium text-fg [overflow-wrap:anywhere]">{m.name}</span>
{m.card_title && <span className="block text-2xs text-faint [overflow-wrap:anywhere]">{m.card_title}</span>}
</span>
</button>
</th>
<td className="tnum whitespace-nowrap px-2.5 py-2 text-right text-dim" data-compare-base="">{m.base_rendered || NONE}</td>
<td className="tnum whitespace-nowrap px-2.5 py-2 text-right font-medium text-fg" data-compare-target="">{m.target_rendered || NONE}</td>
<td className="whitespace-nowrap px-2.5 py-2 text-right">
<div className="tnum" data-compare-delta="">{deltaText(m)}</div>
{pct && <div className="tnum text-2xs text-faint" data-compare-pct="">{pct}</div>}
</td>
<td className="px-2.5 py-2"><ClassChip value={m.classification} /></td>
<td className="px-2.5 py-2">
{m.decomposition ? (
<Split d={m.decomposition} total={totalText(m)} />
) : why ? (
<p className="max-w-[280px] text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-decomp-note=""
data-from={m.decomposition_note ? 'note' : 'reasons'}>
{why}
</p>
) : (
<span className="text-faint">{NONE}</span>
)}
</td>
</tr>
{open && (
<tr id={detailId} data-compare-detail={k} className="border-b bg-bg last:border-0">
<td colSpan={6} className="p-0">
<MetricDetail m={m} />
</td>
</tr>
)}
</Fragment>
)
}
/** 变化量:服务端渲染的写法;没给(没变化、缺一边)时,是 0 的写「无变化」,其余写「—」 */
function deltaText(m: RunCompareMetric): string {
if (m.delta_rendered) return m.delta_rendered
return m.delta === 0 ? T.noChange : NONE
}
/**
* 拆分那句话里的总变化:口径变了、数值却碰巧没变时服务端不给变化量的写法(两效应正好抵消),写「0」,
* 不写成「两者之和等于总变化 无变化」
*/
function totalText(m: RunCompareMetric): string {
if (m.delta_rendered) return m.delta_rendered
return m.delta === 0 ? '0' : 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 (
<div data-compare-decomp="" title={T.splitSum(d.caliber_rendered, d.data_rendered, total)}>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs">
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-compare-caliber="">
<i className="block h-2 w-2 rounded-sm" style={{ background: CALIBER_COLOR }} aria-hidden />
<span className="text-faint">{T.caliber}</span>
<span className="tnum font-medium text-fg">{d.caliber_rendered}</span>
</span>
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-compare-data="">
<i className="block h-2 w-2 rounded-sm" style={{ background: DATA_COLOR }} aria-hidden />
<span className="text-faint">{T.data}</span>
<span className="tnum font-medium text-fg">{d.data_rendered}</span>
</span>
</div>
{sum > 0 && (
<span className="mt-1 flex h-1.5 w-32 overflow-hidden rounded-sm bg-hover" data-compare-bar="" aria-hidden>
<i className="block h-full" style={{ width: `${(a / sum) * 100}%`, background: fill(CALIBER_COLOR, d.caliber_effect < 0) }} />
<i className="block h-full" style={{ width: `${(b / sum) * 100}%`, background: fill(DATA_COLOR, d.data_effect < 0) }} />
</span>
)}
</div>
)
}
/**
* 展开的明细:变化说明、拆分写成一句话(不能拆的写服务端说的为什么)、输入对比、维度归因。贴着滚动框的左边,宽度
* 等于框的可见宽度:窄屏上表格里「拆分或原因」那一格比钉在左边的指标名右侧剩下的地方宽,横着滚到哪儿都看不全,
* 这里读得全
*/
function MetricDetail({ m }: { m: RunCompareMetric }) {
return (
<div className="sticky left-0 box-border grid gap-4 px-3 py-3 lg:grid-cols-2" style={{ width: 'var(--compare-view, 100%)' }}>
<div className="min-w-0 space-y-3">
{!!m.reasons.length && (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.reasons}</h3>
<ul className="list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-reasons="">
{m.reasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{m.decomposition ? (
<p className="text-xs leading-relaxed text-fg" data-compare-decomp-sum="">
{T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, totalText(m))}
</p>
) : m.decomposition_note ? (
<p className="text-xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-detail-note="">{m.decomposition_note}</p>
) : null}
<Inputs m={m} />
</div>
<div className="min-w-0">
<Attribution a={m.attribution} classification={m.classification} />
</div>
</div>
)
}
function Inputs({ m }: { m: RunCompareMetric }) {
return (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.inputs}</h3>
{!m.inputs.length ? (
<p className="text-2xs text-faint" data-compare-inputs-none="">{T.inputsNone}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs" data-compare-inputs="">
<thead>
<tr className="border-b text-left text-faint">
<th scope="col" className="py-1 pr-2 font-medium">{T.colInput}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colBase}</th>
<th scope="col" className="py-1 pl-2 text-right font-medium">{T.colTarget}</th>
</tr>
</thead>
<tbody>
{m.inputs.map((inp) => {
// 遮罩列:两边的值服务端都不给,写「已遮罩」,也不说变没变
const changed = !inp.masked && !sameValue(inp.base, inp.target)
const shown = (v: unknown) => (inp.masked ? T.masked : valueText(v))
return (
<tr key={inp.path} className="border-b last:border-0" data-compare-input={inp.path} data-changed={changed ? '' : undefined}
data-masked={inp.masked ? '' : undefined}>
<th scope="row" className="mono py-1 pr-2 text-left font-normal text-dim [overflow-wrap:anywhere]">{inp.path}</th>
<td className="tnum px-2 py-1 text-right text-dim [overflow-wrap:anywhere]">{shown(inp.base)}</td>
<td className={clsx('tnum py-1 pl-2 text-right [overflow-wrap:anywhere]', changed ? 'font-medium text-fg' : 'text-dim')}>
{changed && <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full align-middle" style={{ background: DATA_COLOR }} aria-hidden />}
{shown(inp.target)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
/**
* 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有,
* 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、
* 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话。服务端有意不做归因的几类(SQL 结构变、无法
* 比较、新增、移除、未变)写为什么不做,不说成「没有明细」
*/
function Attribution({ a, classification }: { a: RunCompareAttribution | null; classification: string }) {
if (!a) {
return (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.attribution}</h3>
<p className="text-2xs leading-relaxed text-faint [overflow-wrap:anywhere]" data-compare-attr-none="">
{T.attrSkipped[classification] ?? T.attrNone}
</p>
</div>
)
}
const rows = a.rows.slice(0, ATTR_TOP)
const more = a.rows.length - rows.length
const note = a.note || (!rows.length ? T.attrNone : a.rows.some((r) => r.share == null) ? T.attrNoShare : null)
return (
<div data-compare-attr="">
<h3 className="mb-1 flex flex-wrap items-baseline gap-x-2 text-2xs">
<span className="font-medium text-faint">{T.attribution}</span>
<span className="text-faint [overflow-wrap:anywhere]">{T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''}</span>
</h3>
{!!rows.length && <div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="border-b text-left text-faint">
<th scope="col" className="py-1 pr-2 font-medium">{a.columns.length ? a.columns.join(' / ') : T.colKey}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colBase}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colTarget}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colDelta}</th>
<th scope="col" className="py-1 pl-2 text-right font-medium">{T.colShare}</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b last:border-0" data-compare-attr-row="">
<th scope="row" className="min-w-[4.5em] py-1 pr-2 text-left font-normal text-fg [overflow-wrap:anywhere]">
{r.key.map(valueText).join(' / ')}
</th>
<td className="tnum px-2 py-1 text-right text-dim">{valueText(r.base)}</td>
<td className="tnum px-2 py-1 text-right text-dim">{valueText(r.target)}</td>
<td className="tnum px-2 py-1 text-right text-fg">{signedNumber(r.delta)}</td>
<td className="tnum py-1 pl-2 text-right text-dim" data-compare-share="" title={r.share == null ? note ?? undefined : undefined}>
{r.share == null ? NONE : shareText(r.share)}
</td>
</tr>
))}
</tbody>
</table>
</div>}
<div className="mt-1 space-y-0.5 text-2xs text-faint">
{more > 0 && <p data-compare-attr-more="">{T.attrMore(more)}</p>}
{!!rows.length && a.total_delta != null && (
<p className="tnum" data-compare-attr-total="">{T.attrTotal(signedNumber(a.total_delta))}</p>
)}
{note && <p className="leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-attr-note="">{note}</p>}
</div>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
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 { queryItems, 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 items = useMemo(() => queryItems(queries), [queries])
return (
<Block id={`${id}-queries`} title={T.queries} count={T.queryCount(queries.length)} attr={{ 'data-compare-queries': '' }}>
{!queries.length ? (
<p className="py-4 text-center text-xs text-faint" data-compare-empty="queries">{T.queriesEmpty}</p>
) : (
<ul className="divide-y">
{items.map((it) => <QueryItem key={it.key} q={it.q} k={it.key} nth={it.nth} />)}
</ul>
)}
</Block>
)
}
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 (
<li className="py-2.5 first:pt-0 last:pb-0" data-compare-query={k} data-structure={q.structure}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" title={q.node_id}>{q.node_title || q.node_id}</span>
{nth != null && <span className="text-2xs text-faint" data-compare-nth="">{T.nth(nth)}</span>}
<StructureChip value={q.structure} />
<span className="tnum text-2xs text-faint" data-compare-rows="">{rows}</span>
{foldable && (
<button type="button" className="btn btn-xs btn-ghost text-faint" data-compare-sql-toggle=""
aria-expanded={showSql} aria-controls={showSql ? sqlId : undefined} onClick={() => setShowSql((v) => !v)}>
<ChevronRight size={11} aria-hidden className={clsx('transition-transform', showSql && 'rotate-90')} />
{showSql ? T.hideSql : T.showSql}
</button>
)}
</div>
{q.problem && (
<p className="mt-1 text-2xs leading-relaxed [overflow-wrap:anywhere]" style={{ color: 'var(--warn)' }} data-compare-query-problem="">
{q.problem}
</p>
)}
{q.structure === 'changed' && !!q.differences?.length && (
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-differences="">
{q.differences.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
{q.structure === 'params_only' && !!q.literals.length && (
<div className="mt-1.5">
<div className="mb-0.5 text-2xs text-faint">{T.literals}</div>
<ul className="space-y-0.5 text-2xs" data-compare-literals="">
{q.literals.map((l, i) => (
<li key={i} className="flex min-w-0 flex-wrap items-center gap-1.5">
<code className="mono rounded px-1 [overflow-wrap:anywhere]" style={MARK.del}>{valueText(l.base)}</code>
{/* 箭头只给眼睛看,读屏念「改为」 */}
<span className="text-faint" aria-hidden>→</span>
<span className="sr-only">{T.changedTo}</span>
<code className="mono rounded px-1 [overflow-wrap:anywhere]" style={MARK.add}>{valueText(l.target)}</code>
</li>
))}
</ul>
</div>
)}
{q.structure === 'changed' && <SideBySide base={q.base_sql} target={q.target_sql} />}
{q.structure === 'added' && <SqlPane which="target" label={T.targetSql} sql={q.target_sql} />}
{q.structure === 'removed' && <SqlPane which="base" label={T.baseSql} sql={q.base_sql} />}
{foldable && showSql && (
<div id={sqlId}>
{q.structure === 'same'
? <SqlPane which="both" label={T.bothSql} sql={q.target_sql ?? q.base_sql} />
: <SideBySide base={q.base_sql} target={q.target_sql} />}
</div>
)}
</li>
)
}
/**
* 两条 SQL 并排,逐词标出各自独有的部分。有一边拿不到 SQL(快照读不出来、又没有执行时的参数)时不比:拿空串去比,
* 另一边会整条被标成「独有」,像是整条新写的,其实只是不知道。这时两边原样摆着,拿不到的那一边写明无法读取
*/
function SideBySide({ base, target }: { base: string | null; target: string | null }) {
if (base == null || target == null) {
return (
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<SqlPane which="base" label={T.baseSql} sql={base} />
<SqlPane which="target" label={T.targetSql} sql={target} />
</div>
)
}
return <Diffed base={base} target={target} />
}
function Diffed({ base, target }: { base: string; target: string }) {
const diff = useMemo(() => diffSql(base, target), [base, target])
return (
<div className="mt-2">
<div className="mb-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-2xs text-faint" aria-hidden>
<span><mark className="rounded px-1" style={MARK.del}>{T.onlyBase}</mark></span>
<span><mark className="rounded px-1" style={MARK.add}>{T.onlyTarget}</mark></span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<SqlPane which="base" label={T.baseSql} parts={diff.base} />
<SqlPane which="target" label={T.targetSql} parts={diff.target} />
</div>
{diff.coarse && <p className="mt-1 text-2xs text-faint">{T.diffCoarse}</p>}
</div>
)
}
/**
* 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦。sql 为 null(这一边拿不到 SQL)时
* 框里写明无法读取,不摆一个空框
*/
function SqlPane({ which, label, sql, parts }: {
which: 'base' | 'target' | 'both'; label: string; sql?: string | null; parts?: DiffPart[]
}) {
const kind = which === 'base' ? 'del' : 'add'
const missing = !parts && sql == null
return (
<figure className="m-0 mt-1.5 min-w-0">
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
<pre className={clsx('max-h-64 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]',
missing ? 'font-sans text-faint' : 'mono text-dim')}
data-compare-sql={which} data-unreadable={missing ? '' : undefined} tabIndex={0} aria-label={label}>
{parts
? parts.map((p, i) => (p.changed
? <mark key={i} data-diff={kind} className="rounded-sm" style={MARK[kind]}>{p.text}</mark>
: <Fragment key={i}>{p.text}</Fragment>))
: missing ? T.sqlUnreadable : sql}
</pre>
</figure>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftRight, CalendarClock, RotateCw } 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'
import { isGone } from './model'
/**
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
* 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。
*/
export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: {
target: string
base: string | null
targetRun: Loaded<RunWithSchedule>
baseRun: Loaded<RunWithSchedule>
candidates: Loaded<RunWithSchedule[]>
/** 本次不属于已保存的工作流(没保存过、或者工作流已删除):没有同一工作流的其他运行可选 */
unsaved: boolean
/** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */
reason: string | null
onPick: (id: string) => void
onSwap: () => void
}) {
return (
<div className="flex flex-col gap-1.5">
<div className="grid grid-cols-1 gap-2 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<RunCard role="base" id={base} run={baseRun}>
<BasePicker target={target} base={base} baseRun={baseRun} targetRun={targetRun} candidates={candidates}
unsaved={unsaved} onPick={onPick} />
</RunCard>
<div className="flex items-center justify-center">
<button type="button" className="btn btn-sm" data-compare-swap="" disabled={!base} onClick={onSwap}
aria-label={T.swapHint} title={T.swapHint}>
{/* 窄屏两张卡上下排,箭头跟着竖过来 */}
<ArrowLeftRight size={12} className="rotate-90 md:rotate-0" aria-hidden /> {T.swap}
</button>
</div>
<RunCard role="target" id={target} run={targetRun} />
</div>
{reason && T.reason[reason] && (
<p className="text-2xs text-faint" data-compare-reason="">{T.reason[reason]}</p>
)}
</div>
)
}
function RunCard({ role, id, run, children }: {
role: 'base' | 'target'; id: string | null; run: Loaded<RunWithSchedule>; children?: ReactNode
}) {
const now = useTicker(60_000)
const r = run.data
const when = r ? r.started_at ?? r.created_at ?? null : null
const gone = run.state === 'error' && isGone(run.error)
return (
<div className="min-w-0 rounded-lg border bg-panel px-3 py-2.5" data-compare-run={role}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-2xs">
<span className="font-medium text-faint">{role === 'base' ? T.base : T.target}</span>
{r?.schedule_id && (
<span className="chip" title={T.scheduled}><CalendarClock size={10} aria-hidden /> {T.scheduled}</span>
)}
{r && !r.manifest_hash && (
<span className="chip" title={T.unsealedHint} data-compare-unsealed=""
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
{T.unsealed}
</span>
)}
</div>
{!id ? (
<div className="mt-1 text-sm text-faint">{T.noBase}</div>
) : r ? (
<>
<Link to={`/runs/${id}`} data-compare-run-link="" title={T.openRun(runName(r))}
className="mt-1 block truncate rounded-sm text-sm font-semibold hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--accent)]">
{runName(r)}
</Link>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-2xs text-faint">
<time className="tnum" title={formatDateTime(when)}>{formatTime(when, new Date(now))}</time>
<span aria-hidden>·</span>
<RunClassChip runClass={r.run_class} version={r.version} always />
<span aria-hidden>·</span>
<span className="mono" title={id}>{shortId(id)}</span>
</div>
</>
) : run.state === 'error' ? (
// 读不到运行记录:对比本身照样请求,能不能比由对比接口说。记录不存在的重试也没用,只写不存在;
// 别的(连不上、服务端出错)给重试
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint" data-compare-run-failed="">
{gone
? <span className="mono" title={id}>{shortId(id)}</span>
: <Link to={`/runs/${id}`} data-compare-run-link="" className="mono hover:underline">{shortId(id)}</Link>}
<span>{gone ? T.runMissing : T.runFailed}</span>
{!gone && <RetryButton onClick={run.reload} />}
</div>
) : (
<div className="mt-1.5" aria-busy="true"><Skeleton rows={2} height={12} gap={8} /></div>
)}
{children}
</div>
)
}
function RetryButton({ onClick }: { onClick: () => void }) {
return (
<button type="button" className="btn btn-xs" onClick={onClick}>
<RotateCw size={10} aria-hidden /> {T.retry}
</button>
)
}
/**
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
*
* 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中
* 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的
* 写短 id 加原因,下拉如实反映当前选的是哪一次,其余每一项都能触发 change。
*
* 列不出候选时说清楚、给重试,不停在「正在读取」:同一工作流的运行没取到;或者本次的记录没读到,不知道是哪个工作流
* (候选的键是空的,不会自己去取)
*/
function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onPick }: {
target: string; base: string | null; baseRun: Loaded<RunWithSchedule>; targetRun: Loaded<RunWithSchedule>
candidates: Loaded<RunWithSchedule[]>; unsaved: boolean; onPick: (id: string) => void
}) {
const id = useId()
const now = useTicker(60_000)
if (unsaved) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="unsaved">{T.pickUnsaved}</p>
const failed = candidates.state === 'error' ? candidates.reload
: candidates.state === 'idle' && targetRun.state === 'error' ? targetRun.reload : null
if (failed) {
return (
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-faint" data-compare-pick-failed="">
<span>{T.pickFailed}</span>
<RetryButton onClick={failed} />
</div>
)
}
const loading = candidates.state !== 'ok'
const list = (candidates.data ?? []).filter((r) => r.id !== target)
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="none">{T.pickNone}</p>
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)
const current = base && !list.some((r) => r.id === base) ? base : null
const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data)
: baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b))
return (
<div className="mt-2.5 border-t pt-2">
<label className="label" htmlFor={id}>{T.pickBase}</label>
<select id={id} className="field" data-compare-base-picker="" value={base ?? ''} disabled={loading}
aria-describedby={`${id}-hint`} onChange={(e) => { if (e.target.value) onPick(e.target.value) }}>
{loading && <option value={base ?? ''}>{T.pickLoading}</option>}
{!loading && !base && <option value="" disabled>{T.pickPlaceholder}</option>}
{!loading && current && <option value={current}>{currentLabel(current)}</option>}
{!loading && list.map((r) => (
// 当前的基准即使没封存也要能显示出来(选中项);别的没封存的一律不能选
<option key={r.id} value={r.id} disabled={!r.manifest_hash && r.id !== base}>{label(r)}</option>
))}
</select>
<p id={`${id}-hint`} className="mt-1 text-2xs text-faint">{T.pickHint}</p>
</div>
)
}
@@ -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 (
<section aria-label={T.summary} data-compare-summary="">
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border bg-[var(--border)] sm:grid-cols-4">
{CLASS_ORDER.map((k) => {
const n = Number(summary?.[k] ?? 0)
const on = filter === k
const tone = classTone(k)
return (
<button
key={k}
type="button"
data-compare-class={k}
aria-pressed={on}
disabled={n === 0}
title={`${T.classHint[k]}${n ? `\n${T.summaryHint}` : ''}`}
onClick={() => onFilter(on ? null : k)}
className={clsx(
'relative flex min-w-0 flex-col items-start gap-0.5 px-3 py-2 text-left outline-none transition-colors',
'focus-visible:z-10 focus-visible:shadow-[inset_0_0_0_2px_var(--accent)] disabled:cursor-default',
on ? 'bg-hover shadow-[inset_0_-2px_0_var(--accent)]' : 'bg-panel enabled:hover:bg-hover',
)}
>
<span data-compare-count="" className={clsx('mono tnum text-lg leading-6', !n && 'text-faint')}
style={n ? { color: tone.color === 'var(--text-faint)' ? 'var(--text)' : tone.color } : undefined}>
{formatNumber(n)}
</span>
<span className="truncate text-2xs text-dim">{T.classLabel[k]}</span>
</button>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,69 @@
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<string, string> = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' }
/**
* 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项的叫法用
* 服务端按节点类型翻好的 labels(同一个键在不同类型的节点里叫法不同,界面没有节点类型猜不准);没给、或者和键对不上
* 时照写键名,宁可露键名也不编一个界面上没有的名字。键名放在悬停提示里
*/
export function TemplateSection({ template }: { template: RunCompareTemplate }) {
const id = useId()
return (
<Block id={`${id}-template`} title={T.template} attr={{ 'data-compare-template': '' }}>
<p className="tnum text-xs text-dim" data-compare-versions="">
{T.versions(T.version(template.base_version), T.version(template.target_version))}
</p>
{template.same_graph ? (
<p className="mt-1.5 text-xs text-faint" data-compare-same-graph="">{T.sameGraph}</p>
) : !template.nodes.length ? (
<p className="mt-1.5 text-xs text-faint">{T.nodesNone}</p>
) : (
<ul className="mt-2 divide-y rounded border">
{template.nodes.map((n) => {
const color = CHANGE_COLOR[n.change] ?? 'var(--text-dim)'
return (
<li key={`${n.node_id}:${n.change}`} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs"
data-compare-node={n.node_id} data-change={n.change}>
<span className="min-w-0 font-medium [overflow-wrap:anywhere]" title={n.node_id}>{n.title || n.node_id}</span>
<span className="chip shrink-0"
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)` }}>
{T.nodeChange[n.change] ?? n.change}
</span>
{!!n.keys.length && (
<span className="flex min-w-0 flex-wrap items-center gap-1 text-2xs text-faint">
<span>{T.keys}</span>
{n.keys.map((k, i) => {
const label = n.labels?.length === n.keys.length ? n.labels[i] : undefined
return label && label !== k ? (
<span key={k} className="rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" title={k} data-compare-key={k}>{label}</span>
) : (
<code key={k} className="mono rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" data-compare-key={k}>{k}</code>
)
})}
</span>
)}
</li>
)
})}
</ul>
)}
</Block>
)
}
/** 服务端给的说明(截断、缺快照这类会影响读数的事),照原样列出;没有就不画这一节 */
export function NotesSection({ notes }: { notes: string[] }) {
const id = useId()
if (!notes.length) return null
return (
<Block id={`${id}-notes`} title={T.notes} attr={{ 'data-compare-notes': '' }}>
<ul className="list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-dim [overflow-wrap:anywhere]">
{notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
</Block>
)
}
+77
View File
@@ -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<T> {
/** idle:键是空的,不取 */
state: 'idle' | 'loading' | 'ok' | 'error'
data?: T
error?: unknown
reload: () => void
}
type Slot<T> = { key: string; state: 'loading' | 'ok' | 'error'; data?: T; error?: unknown }
/**
* 按键取一份数据。请求晚一拍(下一个宏任务)才发:开发模式的 StrictMode 会把挂载时的 effect 先跑一遍、立即撤掉再跑,
* 撤掉时把还没发的那一拍清掉,同一份对比就只算一次(对比要读两次运行的工件、核对封存,不便宜)。键变了、卸载了
* 都会取消在路上的请求;答复对不上当前的键就丢掉
*/
function useKeyed<T>(key: string | null, load: (signal: AbortSignal) => Promise<T>): Loaded<T> {
const [slot, setSlot] = useState<Slot<T> | 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<RunCompareBase> {
return useKeyed(target, (signal) => api.compare.base(target!, { signal }))
}
/** 一次运行的记录:运行卡上的名字、时间、版本、类别、封存与否,和它属于哪个工作流 */
export function useRunRecord(id: string | null): Loaded<RunWithSchedule> {
return useKeyed(id, () => api.runs.get(id!) as Promise<RunWithSchedule>)
}
/** 换基准的下拉:同一工作流最近的运行(新的在前)。没保存的工作流(workflow_id 为空)不取 */
export const CANDIDATE_LIMIT = 50
export function useCandidates(workflowId: string | null | undefined): Loaded<RunWithSchedule[]> {
return useKeyed(workflowId || null, () =>
api.runs.list({ workflow_id: workflowId!, limit: CANDIDATE_LIMIT }) as Promise<RunWithSchedule[]>)
}
/** 对比本身 */
export function useComparison(target: string | null, base: string | null): Loaded<RunComparison> {
return useKeyed(target && base ? JSON.stringify([target, base]) : null,
(signal) => api.compare.get(target!, base!, { signal }))
}
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, 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 })}`
/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */
const NOTE_DELAY_MS = 300
/**
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的
* 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
*
* 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、
* 收起提示,晚到的答复不再把人带去对比页。
*
* 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上
*/
export function useCompareEntry(): (run: Run, live: boolean) => MenuItem {
const navigate = useNavigate()
const [busy, setBusy] = useState(false)
// 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示
const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null)
useEffect(() => () => {
const p = pending.current
pending.current = null
p?.ctrl.abort()
p?.stop()
}, [])
return useCallback((run: Run, live: boolean): MenuItem => {
const orphan = !run.workflow_id
const unsealed = live || !run.manifest_hash
return {
key: 'compare',
label: T.menu,
icon: <GitCompareArrows size={12} />,
disabled: orphan || unsealed || busy,
hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined,
onSelect: () => {
const ctrl = new AbortController()
let note: number | null = null
const timer = setTimeout(() => {
note = toast.info(T.menuFinding, { sticky: true, key: `compare-finding:${run.id}` })
}, NOTE_DELAY_MS)
const stop = () => {
clearTimeout(timer)
if (note != null) toast.dismiss(note)
}
pending.current = { ctrl, stop }
setBusy(true)
api.compare.base(run.id, { signal: ctrl.signal }).then((found) => {
if (ctrl.signal.aborted) return
const via: CompareVia = { base: found.base_run_id, reason: found.reason }
navigate(comparePath(run.id, found.base_run_id), { state: via })
}, (e: unknown) => {
if (ctrl.signal.aborted) return
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(() => {
stop()
if (pending.current?.ctrl !== ctrl) return
pending.current = null
setBusy(false)
})
},
}
}, [navigate, busy])
}
+157
View File
@@ -0,0 +1,157 @@
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<RunCompareClass, { color: string; soft: boolean }> = {
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<RunCompareMetric, 'card_id' | 'metric_id'>) => `${m.card_id}:${m.metric_id}`
/**
* 「拆分或原因」一栏没有拆分时写的那句话:服务端写了「为什么没拆」就用它。无法比较、新增、移除的服务端不写这一项,
* 原因只在 reasons 里——退回写 reasons 的第一句(全部在展开的变化说明里),不让这一栏只剩一个「—」、要展开才知道
* 为什么。SQL 结构变的服务端一向写着,万一没写,reasons 说的就是哪条查询变了,也是不拆的原因。只数据变、只参数变、
* 未变的本来就用不着拆;口径与数据都变的服务端总会给拆分或原因,它的 reasons 说的是口径改了什么、不是为什么不拆,
* 都不退回
*/
const REASON_IN_CELL = new Set<string>(['incomparable', 'added', 'removed', 'sql_changed'])
export function splitReason(m: Pick<RunCompareMetric, 'classification' | 'decomposition_note' | 'reasons'>): string | null {
if (m.decomposition_note) return m.decomposition_note
return REASON_IN_CELL.has(m.classification) && m.reasons?.length ? m.reasons[0] : null
}
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))}`
}
/** 百分数(已经乘过 100)写成一位小数;不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」 */
function pct(abs: number, sign: string): string {
return abs !== 0 && abs < 0.05 ? `${sign}<0.1%` : `${sign}${abs.toFixed(1)}%`
}
/** 变化百分比:服务端给的 delta_pct 已经乘过 100(12.5 是涨了 12.5%),带正负号:「+12.5%」「-3.0%」 */
export function changeText(v: number | null | undefined): string {
if (!isNum(v)) return NONE
return pct(Math.abs(v), v > 0 ? '+' : v < 0 ? '-' : '')
}
/** 归因的占比:服务端给的是小数(0.4 是 40%)。和总变化反向的一项占比是负的:「-5.0%」 */
export function shareText(v: number | null | undefined): string {
if (!isNum(v)) return NONE
return pct(Math.abs(v) * 100, v < 0 ? '-' : '')
}
/** 两个值算不算一样(输入对比里标「变了」用):数按值比,别的按 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
}
}
/** 查询区的一项:位置键(同一对比里唯一)和同一节点里的「第 N 条」(节点只有一条时为 null) */
export interface QueryItem {
q: RunCompareQuery
key: string
nth: number | null
}
/**
* 查询区按服务端给的先后列:服务端已经按本次的先后排好,基准多出来的接在后面。同一节点有几条时,按在列表里出现的
* 先后从 1 数「第 N 条」,键是「节点:第几」。
*
* 不拿服务端的 ordinal 当键、也不按它编号:配上的一对取本次的序号,落单的基准查询取基准的序号,Agent 换了查询的先后
* 时两者会撞上(同一节点两条都是 1),按它当键 React 会报键冲突、编号会重复或缺号
*/
export function queryItems(queries: RunCompareQuery[]): QueryItem[] {
const total = new Map<string, number>()
for (const q of queries) total.set(q.node_id, (total.get(q.node_id) ?? 0) + 1)
const seen = new Map<string, number>()
return queries.map((q) => {
const n = (seen.get(q.node_id) ?? 0) + 1
seen.set(q.node_id, n)
return { q, key: `${q.node_id}:${n}`, nth: (total.get(q.node_id) ?? 0) > 1 ? n : null }
})
}
/**
* 对比失败归哪一类:409 未封存或封存核对不一致、422 不是同一工作流或同一次运行、404 运行不存在;找默认基准时的 404
* 是「找不到基准」。其余(网络、服务端出错)按通用的出错态画,带重试
*/
export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other'
/** 服务端「运行记录不存在」的机读码(api/evidence.py 的 RUN_NOT_FOUND,运行对比的接口沿用) */
const RUN_NOT_FOUND = 'run_not_found'
/** 409 的两种机读码(api/compare.py):没结束封存、封存核对不一致。标题按它分开写 */
export const UNSEALED = 'compare_unsealed'
export const SEAL_BROKEN = 'compare_seal_broken'
/** 请求回的是 404(记录不存在):和连不上、服务端出错分开说,前者重试也没用 */
export function isGone(e: unknown): boolean {
return e instanceof ApiError && e.kind === 'http' && e.status === 404
}
export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind {
if (!(e instanceof ApiError) || e.kind !== 'http') return 'other'
// 找基准时的 404 有两种:本次这条运行就不存在(机读码 run_not_found),和找不到可比的上一次
if (finding && e.status === 404) return e.code === RUN_NOT_FOUND ? '404' : 'no-base'
if (finding) return 'other'
if (e.status === 409) return '409'
if (e.status === 422) return '422'
if (e.status === 404) return '404'
return 'other'
}
+153
View File
@@ -0,0 +1,153 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import { ApiError } from '../../../api/client'
import { ErrorState } from '../../../components/ui'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import { SEAL_BROKEN, UNSEALED, 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<string, string>
}) {
return (
<section aria-labelledby={`${id}-title`} className="min-w-0 rounded-lg border bg-panel" {...attr}>
<header className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b px-3 py-2">
<h2 id={`${id}-title`} className="text-sm font-semibold">{title}</h2>
{count && <span className="tnum text-2xs text-faint">{count}</span>}
{hint && <p className="min-w-0 text-2xs leading-relaxed text-faint">{hint}</p>}
</header>
<div className="min-w-0 p-3">{children}</div>
</section>
)
}
/**
* 标签的配色:字和边框用这个颜色;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 (
<span className="chip shrink-0" data-compare-chip="" title={T.classHint[value]} style={toneStyle(tone.color, tone.soft)}>
{T.classLabel[value] ?? T.classOther}
</span>
)
}
/** 查询的结构状态。结构变了用警示色并铺底(数不能直接比),新增用强调色,其余中性色 */
export function StructureChip({ value }: { value: string }) {
const warn = value === 'changed'
const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)'
return (
<span className="chip shrink-0" data-compare-structure="" title={T.structureHint[value]} style={toneStyle(color, warn)}>
{T.structure[value] ?? value}
</span>
)
}
/** 页面上已经知道的几件事:错误说明的下一步按它挑,只指向这一刻真走得通的路 */
export interface CompareErrorContext {
/** 本次的运行记录读到了 */
targetKnown: boolean
/** 本次不属于已保存的工作流(没保存过、或者工作流已删除):和哪一次都比不了 */
targetUnsaved: boolean
/** 本次的运行记录上没有封存清单 */
targetUnsealed: boolean
/** 本次、基准的运行记录是不是 404 */
targetGone: boolean
baseGone: boolean
/** 换基准的下拉里有没有能选的运行(已封存、不是本次);还不知道(在读、读失败)时为 null */
pickable: boolean | null
}
/**
* 标题和下一步。标题说是哪一种;服务端的原因原样跟在标题后面(CompareError 里),下一步只写界面上真有的入口、
* 服务端已经写了的不再重复:
* - 409:按机读码分没结束封存、封存核对不一致。本次自己没封存时换基准也没用,不给下一步;否则说出在基准上时可以
* 在上方换(服务端的原因里写着是哪一次)。去记录页核对的入口是下面两个「查看」按钮,服务端的原因里也写了「核对」;
* - 422:本次不属于已保存的工作流时哪一次都比不了,标题直说、不给下一步;否则在上方另选同一工作流的一次;
* - 404:缺的是本次(targetGone)才是链接失效;缺的是基准(基准的记录 404,或者本次的记录读得到)就在上方另选;
* 还不知道是哪一边时先不说;
* - 找不到默认基准:本次不属于已保存的工作流同 422;下拉里有能选的才说可以在上方选,一次都没有就照实说
*/
function errorCopy(kind: Exclude<CompareErrorKind, 'other'>, error: unknown, targetGone: boolean, ctx: CompareErrorContext): {
title: string; hint: string | null
} {
const code = error instanceof ApiError ? error.code : undefined
switch (kind) {
case '409': {
const title = code === SEAL_BROKEN ? T.errSealBroken : code === UNSEALED ? T.errUnsealed : T.errSealed
return { title, hint: code === UNSEALED && ctx.targetUnsealed ? null : T.errSealedHint }
}
case '422':
return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } : { title: T.errMismatch, hint: T.errMismatchHint }
case '404':
return {
title: T.errMissing,
hint: targetGone ? T.errMissingHint : ctx.baseGone || ctx.targetKnown ? T.errBaseMissingHint : null,
}
case 'no-base':
if (ctx.targetUnsaved) return { title: T.errUnsaved, hint: null }
return { title: T.errNoBase, hint: ctx.pickable == null ? null : ctx.pickable ? T.errNoBaseHint : T.errNoBaseNone }
}
}
/**
* 对比失败时的说明:先说是哪一种(未封存、封存核对不一致、不是同一工作流、运行不存在、找不到基准),再原样带上
* 服务端的原因,最后一句下一步(errorCopy)。未封存、封存核对不一致的,给两次运行详情的入口(运行详情上有「核对」);
* 本次的记录不存在时给去记录页的入口。别的错误按通用的出错态画,带重试
*/
export function CompareError({ kind, error, onRetry, target, base, context }: {
kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null
context: CompareErrorContext
}) {
if (kind === 'other') {
return (
<div data-compare-error="other">
<ErrorState error={error} onRetry={onRetry} compact />
</div>
)
}
// 缺的是不是本次:只带本次的地址(还没有基准)出的错来自找默认基准,那里的 404 只会是本次
const targetGone = !base || context.targetGone
const { title, hint } = errorCopy(kind, error, targetGone, context)
const reason = error instanceof Error ? error.message : ''
return (
<div
role="alert"
data-compare-error={kind}
className="flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-relaxed"
style={{
borderColor: 'color-mix(in srgb, var(--warn) 40%, var(--border))',
background: 'color-mix(in srgb, var(--warn) 7%, transparent)',
}}
>
<AlertTriangle size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]">
<div className="font-medium text-fg" data-compare-error-title="">{title}</div>
{reason && reason !== title && <div className="mt-0.5 text-dim">{reason}</div>}
{hint && <div className="mt-0.5 text-faint" data-compare-error-hint="">{hint}</div>}
{kind === '409' && (target || base) && (
<div className="mt-1.5 flex flex-wrap gap-2">
{base && <Link className="btn btn-sm" to={`/runs/${base}`}>{T.viewBase}</Link>}
{target && <Link className="btn btn-sm" to={`/runs/${target}`}>{T.viewTarget}</Link>}
</div>
)}
{kind === '404' && targetGone && (
<div className="mt-1.5"><Link className="btn btn-sm" to="/runs">{T.toRuns}</Link></div>
)}
</div>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 两条 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
// 空白、注释(-- 到行尾、/* … */,没收尾的到末尾)、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、
// 数、两个字符的运算符、其余单个字符。注释整段算一个词,而且排在字符串前面:模型写的英文注释里常有 it's、user's,
// 把里面的撇号当成字符串的开头,会一直吞到下一个引号,后面没改的语句全被标成不同
const TOKEN = /\s+|--[^\n]*|\/\*[\s\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 }
}
+155
View File
@@ -3325,3 +3325,158 @@ export interface ScheduleWebhookTest {
http_status: number | null
message: string
}
// 运行对比(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<RunCompareClass, number>
export interface RunCompareTemplateNode {
node_id: string
title: string
change: 'added' | 'removed' | 'config' | (string & {})
/** 改了的配置项(节点配置里的键) */
keys: string[]
/** 约定之外、服务端顺带给的:各配置项在界面上的叫法,和 keys 一一对应。没给(或对不上)时照写键名 */
labels?: 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
/** 约定之外、服务端顺带给的:取自设置了遮罩的列,两边的值都不给 */
masked?: boolean
}
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
/** 相对基准变化了百分之几(已乘 100,保留两位:12.5 是 +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
/** 约定之外、服务端顺带给的:结构变了时差在哪,一处一句中文(按对结果的影响从大到小) */
differences?: string[]
/** 约定之外、服务端顺带给的:快照读不出来、与哈希不一致时的一句说明 */
problem?: string
}
/** 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 }
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
schedules: 60, compare: 45, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+1040 -6
View File
File diff suppressed because it is too large Load Diff