W4-d 前端:数据目录页的「变化」视图、表详情「结构与取值变化」一节、后台任务条、数据源的角标和定时维护

第 3 波的结构变化、取值观察、变化记录、定时维护都只有接口,这一提交给它们界面,能走通(收口):

- 数据目录页左栏加第三个视图「变化」(view=changes):按要处理 / 提醒 / 说明分组(组内照服务端排好的先后),能按开着的 /
  处理过的、结构变化 / 取值变化筛,筛选和打开的那一条记在地址里;视图标签上写几条要处理。点开一条,右栏写服务端的一句说法、
  依据(谁、经什么测到的;取值变化写观察口径,结构变化写比对的两版结构)、前后各是什么、影响面(说到这些列的目录项和它们
  现在的状态、写死了这张表的已发布模板、「这条 SQL 现在会报列不存在」、有效记录条件)、能做的处理。动作和做不了的原因都照
  服务端给的 actions 画;清理目录项先确认,加入码值可以先填含义,误报必须写理由(写之前按钮不能点,服务端也会拒)。处理完
  照实写结果:谁、什么时候、目录第几版、建了的任务(点得进起草、剖析的进度)。
- 表详情加「结构与取值变化」一节:这张表的变化(点「去处理」到「变化」视图里那一条)、撤销过的「已列全」、最近几次取值观察
  (行数、码值分布、最新业务日期、按日行数)和各自的口径,口径换过的说一句不互相比较;按日行数的小柱图只给眼睛看,数字写在
  字里和读屏名称里。
- 后台任务条(05 (g)):页头的「起草」「数据剖析」改回纯入口(进行中转圈、排队中时钟,读屏名称写状态、不写数字);DraftStrip
  换成 JobStrip,按服务端给的同源队先后列出探查、起草、剖析,每项一行加进度条和提醒(剖析的「最近查询多数超时」也挪到这里,
  窄屏、触屏看得见全文),「查看」打开对应的对话框,探查的到数据源卡片上(#source-<id> 滚过去)。
- 数据源卡片:要处理的变化条数的角标(读屏写全,点开到「变化」视图);「定时维护」对话框(和「探查范围」一样是卡片上的
  独立对话框,不塞进编辑表单:两个保存混在一起出错时说不清哪一样没存上):开关缺省关、开启时提示选低峰时段,选步骤(PUT
  /maintenance),频率用 components/FreqPicker,保存成一条 target_kind=source_maintenance 的定时任务(新建 ScheduleTargetCreate,
  改频率、开关 SchedulePatch 带版本);照实写下一次什么时候、最近一次建了哪些任务、哪几步没执行和原因(触发记录的 inputs),
  剖析没开、调度没开时说到点会怎样。上传表格、记录表不给这一节。开着的维护在卡片上带一个标签写频率。
- 地址里带 pjob / djob 时打开那个剖析、起草任务的进度(定时维护的「最近一次」从数据源页链过来)。

新文件:typesSource.ts(接口约定)、api/sourceChanges.ts、lib/termsSource.ts(terms.ts 末尾转出)、pages/catalog/changes.ts、
ChangesQueue.tsx、ChangeDetail.tsx、TableChanges.tsx、JobStrip.tsx、pages/SourceMaintenanceDialog.tsx。变化的叫法和服务端逐字
一致,tests/test_source_change_terms.py 守着(维护步骤的叫法同样)。观察的按日行数服务端补上窗口里没数据的日子(不然小柱图
看不出断档)。
This commit is contained in:
ThearchyHelios
2026-10-16 19:46:14 +08:00
parent 238330b9c1
commit aa3bd65cfc
17 changed files with 2165 additions and 106 deletions
+10 -1
View File
@@ -556,7 +556,16 @@ def _shown(kind: str, data: Mapping[str, Any]) -> dict[str, Any]:
"truncated": bool(data.get("truncated"))}
if kind == "daily_volume":
days = data.get("days") if isinstance(data.get("days"), Mapping) else {}
series = sorted((str(d), int(n)) for d, n in days.items() if isinstance(n, (int, float)))
counts = {str(d)[:10]: int(n) for d, n in days.items() if isinstance(n, (int, float))}
# 窗口里没数据的日子补 0:只列有数据的日子,断了的那几天在图上就看不出来
try:
first, last = date.fromisoformat(str(data.get("since"))[:10]), date.fromisoformat(str(data.get("until"))[:10])
except ValueError:
first = last = None
if first and last and 0 <= (last - first).days <= WINDOW_DAYS + 7:
for i in range((last - first).days + 1):
counts.setdefault((first + timedelta(days=i)).isoformat(), 0)
series = sorted(counts.items())
return {"since": data.get("since"), "until": data.get("until"), "days": [list(p) for p in series],
"total": sum(n for _, n in series), "days_with_data": sum(1 for _, n in series if n > 0)}
if kind == "freshness":
+44
View File
@@ -0,0 +1,44 @@
"""变化的叫法前后端一致(总方案 X10:一种说法)。
服务端给人看的一句(source_changes.summary)用 KIND_LABEL,前端「变化」标签页、表详情的那一节用 lib/termsSource.ts 的
SOURCE_CHANGE_KIND_LABEL:两边逐字对照,一边改了另一边没跟上,同一条变化在接口和界面上就是两种说法。写法同
test_notify_events.py 对照通知事件的叫法。
"""
from __future__ import annotations
import re
from pathlib import Path
import app.db.models # noqa: F401 - 模型都登记上(source_objects 的外键指向 data_sources)
from app.data import source_changes as sc
from app.data import source_maintenance as sm
TERMS = Path(__file__).resolve().parents[2] / "frontend" / "src" / "lib" / "termsSource.ts"
def _block(source: str, head: str) -> str:
start = source.index(head)
start = source.index("{", start) + 1
depth, i = 1, start
while depth:
depth += {"{": 1, "}": -1}.get(source[i], 0)
i += 1
return source[start:i - 1]
def _entries(block: str) -> dict[str, str]:
return dict(re.findall(r"'?([a-z_]+)'?:\s*'([^']*)'", block))
def test_change_kind_labels_match_word_for_word():
source = TERMS.read_text(encoding="utf-8")
labels = _entries(_block(source, "export const SOURCE_CHANGE_KIND_LABEL"))
assert labels == sc.KIND_LABEL, "「变化」标签页的叫法和服务端的对不上"
assert set(labels) == set(sc.KINDS)
def test_maintenance_step_labels_match_word_for_word():
source = TERMS.read_text(encoding="utf-8")
block = _block(source, "export const MAINTENANCE_TEXT")
steps = _entries(_block(block, "step:"))
assert steps == sm.STEP_LABEL, "定时维护的步骤叫法和服务端的对不上(没执行的原因里用的是服务端那一份)"
+20
View File
@@ -341,3 +341,23 @@ def test_schedule_target_columns_are_the_ones_the_page_uses():
"""数据源设置里建的维护定时:target_kind / target_id 写法和调度那一套同一处封装(X4)。"""
cols = scheduler_service.target_columns("source_maintenance", "src-x")
assert cols["target_kind"] == "source_maintenance" and cols["target_id"] == "src-x" and cols["workflow_id"] == ""
async def test_daily_volume_fills_the_days_without_data(client, shop):
"""按日行数只记了有数据的日子:窗口里断了的那几天补 0,界面上的小柱图才看得出断档。"""
sid = shop["id"]
async with SessionLocal() as session:
session.add(TableObservation(source_id=sid, table_name="orders", column_name="paid_on", kind="daily_volume",
basis={"method": "window", "column": "paid_on", "days": 35, "since": "2026-10-01"},
basis_key="window:paid_on:35",
data={"since": "2026-10-01", "until": "2026-10-05",
"days": {"2026-10-01": 30, "2026-10-02": 28, "2026-10-05": 31}},
at=utcnow()))
await session.commit()
body = (await client.get(f"/api/datasources/{sid}/observations", params={"table": "orders"})).json()
[daily] = [s for s in body["series"] if s["kind"] == "daily_volume"]
point = daily["points"][0]
assert point["data"]["days"] == [["2026-10-01", 30], ["2026-10-02", 28], ["2026-10-03", 0], ["2026-10-04", 0],
["2026-10-05", 31]]
assert point["data"]["days_with_data"] == 3 and point["data"]["total"] == 89
assert point["basis_text"] == "业务日期 paid_on 最近 35 天"
+3
View File
@@ -23,6 +23,7 @@ import { bundle, dispositionName } from './bundle'
import { compare } from './compare'
import { inquiry } from './inquiry'
import { schedules } from './schedules'
import { sourceChanges } from './sourceChanges'
import { whatif } from './whatif'
import { reportExport } from './reportExport'
// 应用平台:各自的接口文件,这里只挂上去
@@ -948,6 +949,8 @@ export const api = {
// ---- 复核套件:各功能的接口在自己的文件里(api/<名字>.ts),这里只挂上去 ----
schedules,
// 数据源的变化、取值观察、定时维护的步骤、后台任务条(下一轮第 4 波,typesSource.ts 是接口约定)
sourceChanges,
compare,
bundle,
inquiry,
+41
View File
@@ -0,0 +1,41 @@
/**
* 数据源的变化、取值观察、定时维护的步骤、后台任务条的接口(服务端 app/api/source_changes.py),挂在 api.sourceChanges 上。
* 定时本身(cron、时区、开关)走 api.schedules(target_kind=source_maintenance,总方案 X4:定时只有一套)。
*
* 请求函数从 ./http 导入,不从 ./client 导入(client.ts 要导入这个文件,反过来就成了循环,见 http.ts 开头的说明)。
* 误报没写理由 422、处理过的再处理 409、目录被别人刚改过 409,message 是服务端给的中文
*/
import type {
MaintenanceStep, SourceBackgroundJob, SourceChange, SourceChangeList, SourceChangeQuery, SourceChangeResolveBody,
SourceChangeResolveOut, SourceMaintenance, SourceUpkeep, TableObservations,
} from '../typesSource'
import { get, post, put, qs, type RequestOptions } from './http'
const at = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}`
export const sourceChanges = {
/** 数据源列表一次取齐:各源要处理的变化条数(卡片角标)、指向它的维护定时 */
upkeep: (opts?: RequestOptions) => get<{ items: SourceUpkeep[] }>('/datasources/upkeep', opts),
/** 一个源的变化:缺省开着的(要处理的先);status=handled 是处理过的。category 只看结构 / 取值变化 */
list: (sourceId: string, q: SourceChangeQuery = {}, opts?: RequestOptions) =>
get<SourceChangeList>(`${at(sourceId)}/changes${qs({
status: q.status ?? 'pending', category: q.category ?? undefined, table: q.table ?? undefined,
limit: q.limit ?? 100, offset: q.offset ?? 0,
})}`, opts),
get: (sourceId: string, changeId: string, opts?: RequestOptions) =>
get<{ change: SourceChange }>(`${at(sourceId)}/changes/${encodeURIComponent(changeId)}`, opts),
/** 处理一条变化:动作落到目录已有的写入接口并记目录版本、建了的任务编号 */
resolve: (sourceId: string, changeId: string, body: SourceChangeResolveBody) =>
post<SourceChangeResolveOut>(`${at(sourceId)}/changes/${encodeURIComponent(changeId)}/resolve`, body),
/** 表详情「结构与取值变化」一节:这张表最近几次观察,各带口径 */
observations: (sourceId: string, table: string, opts?: RequestOptions) =>
get<TableObservations>(`${at(sourceId)}/observations${qs({ table })}`, opts),
/** 这个源进行中和排队的后台任务,按同源队的先后排好 */
backgroundJobs: (sourceId: string, opts?: RequestOptions) =>
get<{ items: SourceBackgroundJob[] }>(`${at(sourceId)}/background-jobs`, opts),
/** 定时维护:步骤、剖析开没开、调度开没开、指向这个源的维护定时(定时任务页同一个形状,最近一次触发带 inputs) */
maintenance: (sourceId: string, opts?: RequestOptions) => get<SourceMaintenance>(`${at(sourceId)}/maintenance`, opts),
/** 改要做哪几步(至少一步)。上传表格、记录表 409 */
putMaintenance: (sourceId: string, steps: MaintenanceStep[]) =>
put<SourceMaintenance>(`${at(sourceId)}/maintenance`, { steps }),
}
+2
View File
@@ -6278,3 +6278,5 @@ export const CAPABILITY_REVIEW_TEXT: typeof CATALOG_REVIEW_TEXT = {
// 应用平台使用面的文案(FE-5 维护)。调用方照旧从 lib/terms 导入
export * from './termsApp'
export * from './termsBlueprint'
// 数据源的变化、定时维护、后台任务条(下一轮第 4 波 W4-d)
export * from './termsSource'
+263
View File
@@ -0,0 +1,263 @@
/**
* 数据源的变化(结构变化、取值变化)、表详情的「结构与取值变化」一节、定时维护、目录页的后台任务条的文案(next-round 03 B2–B4、
* 05 (g))。规矩同 terms.ts 开头的术语表。
*
* 叫法(总方案 X10):「结构变化」「取值变化」,和已经在用的「目录有变化」(目录版本变了,CATALOG_DRIFT_TEXT)分开;严重程度
* 「要处理 / 提醒 / 说明」同 SCHEMA_DRIFT_TEXT.severity。
*
* terms.ts 末尾 `export * from './termsSource'`:调用方照旧从 lib/terms 导入。
*/
import type { MaintenanceStep, SourceChangeAction, SourceChangeCategory, SourceChangeSeverity, SourceChangeStatus } from '../typesSource'
import type { ObservationKind, SourceJobKind } from '../typesSource'
import { formatNumber } from './format'
const pct = (share: number) => {
const v = share * 100
return `${v >= 10 || v === 0 ? Math.round(v) : v.toFixed(1)}%`
}
/**
* 变化的种类:和服务端 app/data/source_changes.KIND_LABEL 逐字一致(tests/test_source_change_terms.py 守着),服务端给人看
* 的一句(summary)也用它
*/
export const SOURCE_CHANGE_KIND_LABEL: Record<string, string> = {
table_added: '新出现的表', table_removed: '表已删除', table_left_scope: '表离开了探查范围',
table_reflect_failed: '这次读不到表结构', column_added: '新加了列', column_removed: '删除了列',
column_type_changed: '列的类型变了', column_nullable_changed: '列的可空变了', key_changed: '键变了', fk_changed: '外键变了',
comment_changed: '注释变了', scope_changed: '探查范围的规则变了',
codes_new: '出现了新码值', codes_shift: '码值的比例变化大', date_stalled: '最新业务日期没有前进',
date_gap: '有的日子没有数据', volume_drop: '按日行数大幅下跌', rows_drop: '行数大幅下跌', rows_jump: '行数大幅上涨',
coverage_drop: '关联的覆盖率下降',
}
export const SOURCE_CHANGE_TEXT = {
/** 数据目录页左栏的第三个视图 */
view: '变化',
viewHint: '探查、取值观察测到的结构变化和取值变化,按要不要处理分组',
category: { struct: '结构变化', value: '取值变化' } as Record<SourceChangeCategory, string>,
categoryAll: '全部',
categoryLabel: '按类别筛选',
severity: { action: '要处理', warning: '提醒', info: '说明' } as Record<SourceChangeSeverity, string>,
severityHint: {
action: '现在大概率已经不对了:已确认的目录项或写死的 SQL 指向了被删的列,或者「已列全」被撤销了',
warning: '值得核对:可能影响 SQL 的写法或结果',
info: '知道一下:一般不影响已有的目录和模板',
} as Record<SourceChangeSeverity, string>,
status: { open: '开着', acknowledged: '知道了,还没处理', resolved: '已处理', dismissed: '标为误报' } as Record<SourceChangeStatus, string>,
statusFilter: { pending: '开着的', handled: '处理过的' } as Record<'pending' | 'handled', string>,
statusLabel: '按状态筛选',
listLabel: '数据源的变化',
loading: '正在载入…',
refresh: '刷新',
more: (n: number) => `再显示 ${formatNumber(n)} 条`,
total: (n: number) => `${formatNumber(n)} 条`,
group: (label: string, n: number) => `${label} ${formatNumber(n)} 条`,
empty: '没有开着的变化',
emptyBody: '探查结构、取值观察测到表结构或取值有变化时,会列在这里。',
emptyFiltered: '没有符合筛选的变化',
emptyHandled: '还没有处理过的变化',
/** 视图标签上的计数:要处理的条数 */
badge: (n: number) => `${formatNumber(n)} 条要处理`,
seen: (n: number) => `测到 ${formatNumber(n)} 次`,
detectedAt: (when: string) => `测到于 ${when}`,
overviewTitle: '从左侧挑一条变化开始处理',
overviewBody: '要处理的排在最前:已确认的目录项、写死的 SQL 说到了被删的列,或者「已列全」被撤销了。点开一条,右侧写依据、影响面和能做的处理;每个处理都记下谁、什么时候、目录第几版。',
backToList: '返回变化列表',
missing: '这条变化不存在,可能已随数据源删除',
openTable: '查看这张表',
// ---- 依据
basisTitle: '依据',
detectedBy: (job: string, trigger: string, actor: string | null) =>
`${trigger}的${job}${actor ? `(${actor})` : ''}`,
jobKind: { introspect: '探查结构', profile: '取值观察', on_demand: '按需加入' } as Record<string, string>,
trigger: { manual: '手动发起', schedule: '定时维护', on_demand: '助手点名' } as Record<string, string>,
structBasis: (before: number | null, after: number | null) => (before && after
? `比对的是这张表的结构第 ${formatNumber(before)} 版和第 ${formatNumber(after)} 版`
: before ? `比对的是这张表的结构第 ${formatNumber(before)} 版和这次探查读到的` : '比对的是上一次和这次探查读到的结构'),
valueBasis: (text: string) => `观察口径:${text}`,
valueBasisNote: '只和口径相同的历史观察比',
// ---- 前后各是什么(按种类)
factsTitle: '前后',
before: '之前',
after: '现在',
none: '(无)',
nullable: (v: boolean) => (v ? '可空' : '不可空'),
rename: (from: string, to: string) => `可能是 ${from} 改名为 ${to}(未确认:库里只看得到删了一列、加了一列)`,
newCodes: (codes: string) => `新出现的码值:${codes}`,
codeShare: (code: string, share: number) => `${code}(占 ${pct(share)})`,
knownCodes: (n: number) => `目录里列出的 ${formatNumber(n)} 个取值之外`,
completeRevoked: (version: number | null) =>
`取值观察撤销了码值项的「已列全」${version ? `(目录第 ${formatNumber(version)} 版)` : ''}:SQL 检查不再按旧的码值表提示,新取值的含义要人来填`,
completeKept: '「已列全」在观察之前已被别人改掉,没有撤销',
shift: (psi: number) => `比例的变化程度(PSI)${psi.toFixed(2)}`,
shiftTop: (code: string, from: number, to: number) => `${code}:${pct(from)} → ${pct(to)}`,
stalled: (max: string, age: number, threshold: number) =>
`最新业务日期停在 ${max},已经 ${formatNumber(age)} 天(以前通常 ${formatNumber(threshold)} 天之内会前进)`,
gap: (n: number, days: string) => `窗口里有 ${formatNumber(n)} 天没有数据:${days}`,
volume: (day: string, rows: number, median: number) =>
`${day} 只有 ${formatNumber(rows)} 行,前 28 天的中位数是 ${formatNumber(median)} 行`,
rows: (before: number, after: number, estimated: boolean) =>
`${formatNumber(before)} 行 → ${formatNumber(after)} 行${estimated ? '(数据库统计的估算值,连续两次都这样才报)' : ''}`,
coverage: (target: string, before: number, after: number) => `指向 ${target} 的关联覆盖率 ${pct(before)} → ${pct(after)}`,
scopeRules: (entered: number, left: number) => `这次探查新进范围 ${formatNumber(entered)} 张,离开范围 ${formatNumber(left)} 张`,
reflectFailed: (error: string) => `读不到结构的原因:${error}`,
// ---- 影响面
impactTitle: '影响面',
impactNone: '目录里没有说到它的项,已发布模板里也没有用到它的。',
impactCatalog: (n: number) => `说到这些列的目录项 ${formatNumber(n)} 项`,
impactNow: '现在',
impactGone: '已不在目录里',
impactNeeds: '人工确认过、还说着被删的列',
impactRules: '有效记录条件用到了这一列:取值有变时,按它过滤的结果可能漏掉新状态的行',
impactTemplates: (n: number) => `用到这张表的已发布模板 ${formatNumber(n)} 个`,
templateImpact: { direct: '写死的 SQL 直接引用', possible: '可能涉及' } as Record<string, string>,
templateVersion: (v: number) => `v${v}`,
breaks: (cols: string[]) => (cols.length
? `这条 SQL 现在会报列不存在(${cols.join('、')})`
: '这条 SQL 现在会报表或列不存在'),
// ---- 处理
actionsTitle: '处理',
action: {
clean_catalog: '清理目录项', add_codes: '加入码值', redraft: '重新起草这张表', reprofile: '重新剖析这张表',
acknowledge: '知道了', dismiss: '误报',
} as Record<SourceChangeAction, string>,
actionHint: {
clean_catalog: '把影响面里说到这些列的目录项从目录里去掉(目录升一版,记下是因为这条变化)',
add_codes: '把新出现的码值加进这一列的码值表,含义可以先空着',
redraft: '建一个只含这张表的起草任务:按新的结构重写推断项,人工确认过的项不动',
reprofile: '建一个只含这张表的剖析任务:对业务库发只读查询,重新核对关系、码值和日期',
acknowledge: '记下已经看过,还留在开着的变化里',
dismiss: '不是真的变化:写明理由后不再算要处理',
} as Record<SourceChangeAction, string>,
cleanTitle: (n: number) => `清理 ${formatNumber(n)} 项目录?`,
cleanConsequences: ['这些项从数据目录里去掉,目录升一版;从已发布版本发起的正式运行会提醒「数据目录有变化」',
'人工确认过的项也会去掉:它们说的列已经不在了', '去掉之后要恢复,只能重新填写'],
cleanConfirm: '清理',
codesTitle: '加入码值',
codesHint: '含义可以先空着,之后在表详情的码值表里补;加入的项记为人工填写、已确认,依据写这条变化的编号',
codesMeaning: (code: string) => `码值 ${code} 的含义`,
codesSubmit: '加入',
noteLabel: '误报的理由',
notePlaceholder: '例如:测试库里故意删的列',
noteRequired: '标为误报要写明理由',
noteSubmit: '标为误报',
cancel: '取消',
done: {
clean_catalog: '已清理目录项', add_codes: '已加入码值', redraft: '已建起草任务', reprofile: '已建剖析任务',
acknowledge: '已记为知道了', dismiss: '已标为误报',
} as Record<SourceChangeAction, string>,
// ---- 处理过的结果
resolutionTitle: '处理结果',
resolvedBy: (action: string, by: string | null, when: string) => `${action} · ${by ?? '未署名'} · ${when}`,
resolvedVersion: (v: number) => `目录第 ${formatNumber(v)} 版`,
resolvedVersions: (pairs: string) => `各表的目录版本:${pairs}`,
resolvedNote: (note: string) => `理由:${note}`,
resolvedJob: { draft: '起草任务', profile: '剖析任务' } as Record<string, string>,
jobLink: (kind: string, id: string) => `${kind} #${id.slice(0, 6)}`,
viewProgress: '查看进度',
/** 不能做的动作的读屏说明:「重新剖析这张表:这个数据源未开启数据剖析」 */
unavailable: (label: string, reason: string) => `${label}:${reason}`,
}
/** 数据源卡片上的角标:要处理的变化条数(读屏写全) */
export const SOURCE_BADGE_TEXT = {
chip: (n: number) => `${formatNumber(n)} 个变化要处理`,
aria: (name: string, n: number) => `数据源「${name}」有 ${formatNumber(n)} 个变化要处理,打开数据目录的「变化」`,
hint: '探查结构、取值观察测到的变化里要处理的条数,点开到数据目录的「变化」处理',
}
/** 表详情的「结构与取值变化」一节 */
export const TABLE_CHANGES_TEXT = {
title: '结构与取值变化',
hint: '这张表的结构变化、取值变化,和最近几次取值观察',
changesHead: '这张表的变化',
changesNone: '这张表还没有测到结构变化或取值变化。',
changesMore: (n: number) => `…另有 ${formatNumber(n)} 条,在「变化」里查看`,
openChange: '去处理',
viewChange: '查看',
observeHead: '最近几次观察',
observeNone: '这张表还没有取值观察:定时维护选了「取值观察」,或者手动剖析时带上观察,才会有。',
observeKind: { rows: '行数', codes_observe: '码值分布', freshness: '最新业务日期', daily_volume: '按日行数' } as Record<ObservationKind, string>,
basis: (text: string) => `口径:${text}`,
basisChanged: '这几次的口径不全相同:口径不同的不互相比较',
at: (when: string) => `观察于 ${when}`,
rows: (rows: number | null, atLeast: boolean) => (rows == null ? '—' : `${atLeast ? '至少 ' : ''}${formatNumber(rows)} 行`),
codesTop: (n: number, rows: number) => `${formatNumber(n)} 个取值,共 ${formatNumber(rows)} 行`,
codesTruncated: '取值太多,只记了最多的 50 个:不判新码值、不算比例',
code: (code: string, share: number) => `${code} ${pct(share)}`,
freshness: (max: string | null) => max ?? '—',
daily: (days: number, total: number) => `有数据的 ${formatNumber(days)} 天,共 ${formatNumber(total)} 行`,
dailyRange: (since: string, until: string) => `${since} 至 ${until}`,
dailyChart: (since: string, until: string, days: number, total: number) =>
`${since} 至 ${until} 每天的行数:有数据的 ${formatNumber(days)} 天,共 ${formatNumber(total)} 行`,
history: (n: number) => `前 ${formatNumber(n)} 次`,
revokedHead: '撤销过「已列全」',
revoked: (column: string, when: string, version: number | null) =>
`${when}:取值观察在 ${column} 上测到目录之外的取值,撤销了「已列全」${version ? `(目录第 ${formatNumber(version)} 版)` : ''}`,
}
/** 目录页的后台任务条(05 (g)):页头只做入口,进度只在这一条上 */
export const JOB_STRIP_TEXT = {
label: '这个数据源的后台任务',
kind: { introspect: '探查', draft: '起草', profile: '剖析' } as Record<SourceJobKind, string>,
running: (kind: string, done: number, total: number) => `${kind} ${formatNumber(done)}/${formatNumber(total)}`,
queued: (kind: string) => `${kind} 排队中`,
queuedBehind: (kind: string, behind: string) => `${kind} 排队中,${behind}结束后开始`,
behind: { introspect: '探查', draft: '起草', profile: '剖析', import: '导入目录', sync_draft: '起草' } as Record<string, string>,
progress: (kind: string, done: number, total: number) => `${kind}:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
view: '查看',
viewHint: { introspect: '到数据源卡片上看探查的进度、停止', draft: '打开起草的进度', profile: '打开剖析的进度' } as Record<SourceJobKind, string>,
/** 页头的入口:读屏名称写状态,不写数字 */
headRunning: (label: string) => `${label}(进行中)`,
headQueued: (label: string) => `${label}(排队中)`,
}
/** 数据源的定时维护(数据源卡片上的「定时维护」) */
export const MAINTENANCE_TEXT = {
open: '定时维护',
openHint: '按设定的时段自动探查结构、剖析需要的表、观察取值,缺省关闭',
title: (name: string) => `「${name}」的定时维护`,
intro: '到点按顺序探查结构 → 剖析需要的表 → 观察取值,和起草、剖析在同一条队里排队,一个接一个做。只发只读查询,每张表的查询次数和时长按剖析设置。',
enable: '开启定时维护',
enableHint: '缺省关闭:开启后会定期对业务库发只读查询,请选业务库的低峰时段(比如凌晨)',
offPeak: '请选业务库的低峰时段(比如凌晨),避开白天的业务高峰',
stepsTitle: '做哪几步',
stepsHint: '至少选一步',
step: { introspect: '探查结构', profile_needed: '剖析需要的表', observe: '取值观察' } as Record<MaintenanceStep, string>,
stepHint: {
introspect: '读取探查范围内的表结构,前后比出结构变化',
profile_needed: '剖析从没剖析过、上次没做完、目录里多出待核实项的表',
observe: '观察码值分布、最新业务日期、按日行数和行数,和同口径的历史比出取值变化',
} as Record<MaintenanceStep, string>,
stepsNone: '至少选一步',
profileOff: '这个数据源未开启数据剖析:到点时「剖析需要的表」「取值观察」两步不执行,只探查结构(照实记下)。剖析在数据源的「编辑」里开启。',
schedulerOff: '这个服务的定时调度没有开启:到点不会自动维护。',
freqTitle: '时段',
scheduleName: (name: string) => `「${name}」定时维护`,
next: (when: string) => `下一次:${when}`,
nextNone: '已暂停,不会触发',
lastTitle: '最近一次',
lastNone: '还没有触发过',
lastAt: (when: string, status: string) => `${when} · ${status}`,
fireStatus: { started: '已开始', skipped_overlap: '已跳过(上一次还没做完)', skipped_misfire: '已跳过(错过了时间)', failed_to_start: '没有执行' } as Record<string, string>,
lastJob: (step: string, kind: string, id: string, tables: number | null) =>
`${step}:${kind} #${id.slice(0, 6)}${tables != null ? `(${formatNumber(tables)} 张表)` : ''}`,
lastJobKind: { introspect: '探查任务', profile: '剖析任务' } as Record<string, string>,
lastSkipped: (step: string, reason: string) => `${step}没有执行:${reason}`,
lastReason: (reason: string) => `原因:${reason}`,
viewJob: '查看进度',
history: '在定时任务页查看触发记录',
extra: (n: number) => `另有 ${formatNumber(n)} 条指向这个数据源的维护定时,在定时任务页管理`,
save: '保存',
cancel: '取消',
saving: '正在保存…',
saved: '已保存定时维护',
savedOff: '已保存:定时维护已关闭',
nothing: '没有改动',
notMaintained: '上传的表格、记录表的结构随版本或表单走,不需要定时维护',
/** 卡片上的标签:开着时写频率 */
chip: (desc: string) => `定时维护 · ${desc}`,
chipPaused: '定时维护 · 已暂停',
chipHint: '到点自动探查结构、剖析需要的表、观察取值',
loadFailed: '无法载入定时维护的设置',
}
+55 -8
View File
@@ -1,8 +1,8 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle, BookMarked, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers, Lock, Plug, Plus,
RefreshCw, ScanSearch, Search, Table2, Upload, X,
AlertOctagon, AlertTriangle, BookMarked, CalendarClock, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers,
Lock, Plug, Plus, RefreshCw, ScanSearch, Search, Table2, Upload, X,
} from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api, uploadDecision } from '../api/client'
@@ -23,8 +23,8 @@ import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } fro
import type { HealthRecord } from '../lib/health'
import { workflowList, workflowsMentioning } from '../lib/mentions'
import {
CATALOG_TEXT, INTROSPECT_SCOPE_TEXT, INTROSPECT_TEXT, MASK_BOUNDARY_TEXT, PROFILE_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL,
RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT,
CATALOG_TEXT, INTROSPECT_SCOPE_TEXT, INTROSPECT_TEXT, MAINTENANCE_TEXT, MASK_BOUNDARY_TEXT, PROFILE_TEXT, RAW_STATE_LABEL,
RECIPE_ORIGIN_LABEL, RECIPE_TEXT, SOURCE_BADGE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT,
} from '../lib/terms'
import { useLoginOn } from '../store/auth'
import { useRunClock } from '../run/useRunClock'
@@ -36,6 +36,8 @@ import {
import type { ProfileForm } from './catalog/profile'
import { ImportWizard } from './import/ImportWizard'
import { IntrospectScopeDialog } from './IntrospectScopeDialog'
import { MaintenanceChip, SourceMaintenanceDialog } from './SourceMaintenanceDialog'
import type { SourceUpkeep } from '../typesSource'
import type { WizardEntry } from './import/ImportWizard'
import { VersionsDialog } from './import/VersionsDialog'
@@ -89,8 +91,13 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
/** 按配方导入的向导(首次、上传新一期、修改配方、继续未完成的导入) */
const [wizard, setWizard] = useState<WizardEntry | null>(null)
const [kick, setKick] = useState<{ id: string; seq: number } | null>(null)
/** 各源要处理的变化条数(角标)和定时维护(next-round 03 B3、B4):一次取齐;取不到只是少了角标,不挡列表 */
const [upkeep, setUpkeep] = useState<Map<string, SourceUpkeep>>(() => new Map())
const loadSeq = useRef(0)
const hasRows = useRef(false)
const loadUpkeep = () => {
api.sourceChanges.upkeep().then((r) => setUpkeep(new Map(r.items.map((i) => [i.source_id, i]))), () => {})
}
// 首次加载才画骨架;之后的刷新保留列表、不卸载——展开的表清单、滚动位置、
// 卡片上的忙碌态都长在卡片上,以前整页换成 Spinner,这些全丢,还跳回顶部
@@ -109,8 +116,18 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
else setLoadError(e)
}
}
useEffect(() => { void load() }, [])
useOnReconnect(load)
useEffect(() => { void load(); loadUpkeep() }, [])
useOnReconnect(() => { void load(); loadUpkeep() })
// 从别处链过来指名一张卡片(#source-<id>,目录页后台任务条上探查的「查看」):列表载入后滚过去
const scrolledTo = useRef('')
useEffect(() => {
const hash = window.location.hash
if (!rows || !hash.startsWith('#source-') || scrolledTo.current === hash) return
const el = document.getElementById(decodeURIComponent(hash.slice(1)))
if (!el) return
scrolledTo.current = hash
el.scrollIntoView({ block: 'center' })
}, [rows])
const upsert = (row: any) => setRows((rs) => {
if (!rs) return [row]
@@ -198,6 +215,8 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
// 版本页里清除原件之后服务端不返回数据源,卡片上的原件状态靠重新取列表
onReload={() => void load()}
kick={kick && kick.id === row.id ? kick.seq : 0}
upkeep={upkeep.get(row.id) ?? null}
onUpkeep={loadUpkeep}
/>
))}
</div>
@@ -271,8 +290,13 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
/** 结构同步超过这么久就提示可能过期:库表会变,Copilot 照旧表写 SQL 只会报错 */
const STALE_SCHEMA_MS = 7 * 24 * 3600_000
function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onRecipeReupload, onRedraft, onResume, onReload, kick }: {
function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onRecipeReupload, onRedraft, onResume, onReload, kick,
upkeep = null, onUpkeep }: {
row: any; meta?: any
/** 要处理的变化条数、定时维护(GET /api/datasources/upkeep);取不到为 null */
upkeep?: SourceUpkeep | null
/** 定时维护存过了:重取角标和维护 */
onUpkeep?: () => void
onChange: (row: any) => void
/** 重新取整个列表(版本页里的写操作没有返回数据源时用) */
onReload: () => void
@@ -324,6 +348,11 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
const [job, setJob] = useState<IntrospectJob | IntrospectJobBrief | null>(row.introspect_job ?? null)
const [stopping, setStopping] = useState(false)
const [scoping, setScoping] = useState(false)
const [maintaining, setMaintaining] = useState(false)
/** 要处理的变化条数(数据源卡片的角标,next-round 03 B3 ③) */
const actionChanges = upkeep?.changes.by_severity.action ?? 0
/** 定时维护只给手工登记的库:上传表格、记录表的结构随版本或表单走 */
const maintainable = (row.origin ?? 'manual') === 'manual'
const jobActive = !!job && (job.status === 'queued' || job.status === 'running')
useEffect(() => {
const next = row.introspect_job ?? null
@@ -530,7 +559,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
const failed = record && !record.ok && !checkingSince
return (
<article className="rounded-lg border bg-panel" data-source={row.name}>
<article className="rounded-lg border bg-panel" data-source={row.name} id={`source-${row.id}`}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 px-3 pt-2.5">
{uploaded
? <FileSpreadsheet size={14} className={row.enabled ? 'text-dim' : 'text-faint'} aria-hidden />
@@ -544,6 +573,14 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
<AlertTriangle size={10} aria-hidden /> 可写
</span>}
{!row.enabled && <span className="chip" title="助手和 Agent 均无法访问该数据源">已停用</span>}
{actionChanges > 0 && (
// 要处理的变化:点开到数据目录的「变化」视图(读屏写全:哪个源、几条、点了去哪)
<button type="button" className="chip tnum hover:border-[var(--err)]" onClick={() => navigate(`/data/catalog/${encodeURIComponent(row.id)}?view=changes`)}
aria-label={SOURCE_BADGE_TEXT.aria(row.name, actionChanges)} title={SOURCE_BADGE_TEXT.hint} data-source-changes={actionChanges}
style={{ color: 'var(--err)', borderColor: 'color-mix(in srgb, var(--err) 45%, transparent)' }}>
<AlertOctagon size={10} aria-hidden /> {SOURCE_BADGE_TEXT.chip(actionChanges)}
</button>
)}
<span className="flex-1" />
<HealthPill record={record} checkingSince={checkingSince} />
<div className="flex items-center gap-1">
@@ -573,6 +610,12 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
<Layers size={11} aria-hidden /> <span className="hidden sm:inline">{INTROSPECT_SCOPE_TEXT.open}</span>
</button>
)}
{!uploaded && maintainable && (
<button className="btn btn-sm btn-ghost" onClick={() => setMaintaining(true)} title={MAINTENANCE_TEXT.openHint}
aria-label={MAINTENANCE_TEXT.open} data-maintenance-open="">
<CalendarClock size={11} aria-hidden /> <span className="hidden sm:inline">{MAINTENANCE_TEXT.open}</span>
</button>
)}
{row.open_staging && (
<button className="btn btn-sm" onClick={onResume} title={RECIPE_TEXT.openStagingHint} data-open-staging={row.open_staging.id}
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 45%, transparent)' }}>
@@ -638,8 +681,12 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
{PROFILE_TEXT.open}
</button>
)}
{maintainable && !uploaded && upkeep && <MaintenanceChip maintenance={upkeep.maintenance} />}
{!!row.tools?.length && <span className="mono">{row.tools.join(' · ')}</span>}
</div>
{maintaining && (
<SourceMaintenanceDialog source={row} onClose={() => setMaintaining(false)} onSaved={() => onUpkeep?.()} />
)}
{scoping && (
<IntrospectScopeDialog source={row} onClose={() => setScoping(false)} onSaved={onReload}
onRun={() => void introspect()} />
@@ -0,0 +1,297 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { AlertTriangle, CalendarClock, Info } from 'lucide-react'
import { ApiError, api } from '../api/client'
import type { SchedulePatch, SchedulePreview, ScheduleTargetCreate } from '../types'
import type { MaintenanceSchedule, MaintenanceStep, SourceMaintenance } from '../typesSource'
import { FreqPicker } from '../components/FreqPicker'
import { ErrorState, Modal, Skeleton, Spinner, toast, useTicker } from '../components/ui'
import { formatDateTime, parseServerTime } from '../lib/format'
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem } from '../lib/freq'
import type { Freq } from '../lib/freq'
import { MAINTENANCE_TEXT as T } from '../lib/terms'
import { EnableSwitch, FireStatusChip, NextRun } from './schedules/parts'
// ===========================================================================
// 数据源的定时维护(docs/design/next-round/03-数据接入与目录维护.md B4,目录-D5):按数据源开、缺省关、只在设定的时段跑。
//
// 定时只有一套(总方案 X4):维护就是一条 target_kind=source_maintenance、target_id=数据源 id 的定时任务,这里是它的快捷入口。
// 频率用定时任务同一个选择器(components/FreqPicker),保存成 POST /api/schedules(ScheduleTargetCreate);已经有了的改频率、
// 开关走 PATCH(SchedulePatch,带读到的版本)。做哪几步存在数据源上(PUT /api/datasources/{id}/maintenance)。下一次什么
// 时候、最近一次建了哪些任务、哪几步没执行和原因都照服务端给的写(触发记录的 inputs);剖析没开、调度没开时照实说到点会怎样。
//
// 放在数据源卡片上的独立对话框(和「探查范围」一样),不塞进编辑表单:编辑表单只有一个「保存」,存的是连接配置;维护是一条
// 单独的定时任务,存法不同,两个保存混在一起出错时说不清哪一样没存上。
// ===========================================================================
const STEPS: readonly MaintenanceStep[] = ['introspect', 'profile_needed', 'observe']
/** 新开的维护缺省在凌晨三点:低峰时段 */
const DEFAULT_MAINTENANCE_FREQ: Freq = { ...DEFAULT_FREQ, preset: 'daily', time: '03:00' }
const PREVIEW_DEBOUNCE_MS = 400
interface Form {
enabled: boolean
steps: MaintenanceStep[]
freq: Freq
timezone: string
}
const formOf = (m: SourceMaintenance, s: MaintenanceSchedule | null): Form => ({
enabled: s?.enabled ?? false,
steps: m.steps,
freq: s ? freqOf(s.cron) : DEFAULT_MAINTENANCE_FREQ,
timezone: s?.timezone ?? browserTimezone(),
})
export function SourceMaintenanceDialog({ source, onClose, onSaved }: {
source: { id: string; name: string }
onClose: () => void
/** 保存过了:卡片上的「定时维护」标签跟着变 */
onSaved: () => void
}) {
const navigate = useNavigate()
const [data, setData] = useState<SourceMaintenance | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [form, setForm] = useState<Form | null>(null)
const initial = useRef('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<{ field?: string; message: string } | null>(null)
const [preview, setPreview] = useState<{ cron: string; tz: string; data?: SchedulePreview; error?: { field?: string; message: string } } | null>(null)
useTicker(30_000)
const schedule = data?.schedules[0] ?? null
const extra = Math.max(0, (data?.schedules.length ?? 0) - 1)
const load = async () => {
const got = await api.sourceChanges.maintenance(source.id)
setData(got)
const f = formOf(got, got.schedules[0] ?? null)
initial.current = JSON.stringify({ ...f, cron: cronOf(f.freq) })
setForm(f)
setLoadError(null)
}
useEffect(() => { load().catch(setLoadError) }, [source.id])
const cron = form ? cronOf(form.freq) : ''
const freqIncomplete = form ? freqProblem(form.freq) : null
const sig = form ? JSON.stringify({ ...form, cron }) : ''
const dirty = !!form && sig !== initial.current
const noSteps = !!form && !form.steps.length
// 频率的说法和接下来几次:服务端算(前端不解析 cron),停手 400 毫秒再问,新的一次发出时作废上一次
useEffect(() => {
if (!form || !cron || freqIncomplete) {
setPreview(null)
return
}
const ctrl = new AbortController()
const tz = form.timezone
const timer = setTimeout(() => {
api.schedules.preview({ cron, timezone: tz, n: 3 }, { signal: ctrl.signal })
.then((d) => setPreview({ cron, tz, data: d }))
.catch((e) => {
if ((e as { name?: string })?.name === 'AbortError') return
setPreview({ cron, tz, error: { field: e instanceof ApiError ? e.field : undefined, message: (e as Error).message } })
})
}, PREVIEW_DEBOUNCE_MS)
return () => { clearTimeout(timer); ctrl.abort() }
}, [cron, form?.timezone, freqIncomplete])
const freqError = error?.field === 'cron' ? error.message : preview?.error && preview.error.field !== 'timezone' ? preview.error.message
: form?.enabled ? freqIncomplete ?? undefined : undefined
const tzError = error?.field === 'timezone' ? error.message : preview?.error?.field === 'timezone' ? preview.error.message : undefined
const save = async () => {
if (!form || !data || saving) return
if (noSteps || (form.enabled && (freqIncomplete || preview?.error))) return
setSaving(true)
setError(null)
try {
const was = JSON.parse(initial.current) as Form & { cron: string }
if (JSON.stringify(form.steps) !== JSON.stringify(was.steps)) await api.sourceChanges.putMaintenance(source.id, form.steps)
if (schedule) {
const body: SchedulePatch = { version: schedule.version }
if (cron && cron !== schedule.cron) body.cron = cron
if (form.timezone !== schedule.timezone) body.timezone = form.timezone
if (form.enabled !== schedule.enabled) body.enabled = form.enabled
if (Object.keys(body).length > 1) await api.schedules.update(schedule.id, body)
} else if (form.enabled) {
const body: ScheduleTargetCreate = {
name: T.scheduleName(source.name), target_kind: 'source_maintenance', target_id: source.id, cron, timezone: form.timezone,
enabled: true, overlap: 'skip', misfire_grace_s: DEFAULT_GRACE_S,
}
await api.schedules.create(body)
}
await load()
toast.ok(form.enabled ? T.saved : T.savedOff)
onSaved()
} catch (e) {
// 频率、时区不合规(422 带 field)落到那一项;版本不符(别人刚改过,409)、别的原因写在底部
if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 409)) setError({ field: e.field, message: e.message })
else toast.error(e)
} finally {
setSaving(false)
}
}
const set = (patch: Partial<Form>) => {
setError(null)
setForm((f) => f && ({ ...f, ...patch }))
}
const toggleStep = (step: MaintenanceStep) => {
if (!form) return
const on = form.steps.includes(step)
set({ steps: STEPS.filter((s) => (s === step ? !on : form.steps.includes(s))) })
}
const profileSteps = !!form && form.steps.some((s) => s !== 'introspect')
const blocked = !form || noSteps || (form.enabled && (!!freqIncomplete || !!preview?.error))
return (
<Modal open onClose={onClose} dirty={dirty} width={600} title={T.title(source.name)}
footer={(
<>
{error && !error.field && <p className="mr-auto min-w-0 text-2xs text-[var(--err)]" role="alert" data-maintenance-error="">{error.message}</p>}
<button className="btn" onClick={onClose}>{T.cancel}</button>
<button className="btn btn-primary" disabled={saving || blocked || !dirty || !data?.maintained} onClick={() => void save()}
title={!dirty ? T.nothing : noSteps ? T.stepsNone : undefined} data-maintenance-save="">
{saving && <Spinner size={11} />} {T.save}
</button>
</>
)}>
{!data || !form
? (loadError ? <ErrorState error={loadError} onRetry={() => void load().catch(setLoadError)} /> : <Skeleton rows={5} height={24} gap={10} />)
: !data.maintained
? <p className="text-xs text-dim" data-maintenance-not="">{T.notMaintained}</p>
: (
<div className="space-y-3" data-maintenance-dialog={form.enabled ? 'on' : 'off'}>
<p className="text-xs leading-relaxed text-dim">{T.intro}</p>
<section className="space-y-2 rounded-lg border bg-panel p-3.5" aria-labelledby="maint-enable-title">
<div className="flex items-center gap-2">
<h2 id="maint-enable-title" className="text-xs font-semibold">{T.enable}</h2>
<span className="flex-1" />
<span data-maintenance-enabled={form.enabled ? 'on' : 'off'}>
<EnableSwitch checked={form.enabled} label={T.enable} onChange={(v) => set({ enabled: v })} />
</span>
</div>
<p className="text-2xs leading-relaxed text-faint">{T.enableHint}</p>
{form.enabled && (
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-[var(--warn)]" data-maintenance-offpeak="">
<AlertTriangle size={11} className="mt-0.5 shrink-0" aria-hidden /> {T.offPeak}
</p>
)}
{!data.scheduler_on && (
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-[var(--warn)]" data-maintenance-scheduler-off="">
<Info size={11} className="mt-0.5 shrink-0" aria-hidden /> {T.schedulerOff}
</p>
)}
</section>
<fieldset className="space-y-2 rounded-lg border bg-panel p-3.5" aria-describedby="maint-steps-hint">
<legend className="px-1 text-xs font-semibold">{T.stepsTitle}</legend>
<p id="maint-steps-hint" className={noSteps ? 'text-2xs text-[var(--err)]' : 'text-2xs text-faint'} role={noSteps ? 'alert' : undefined}>
{noSteps ? T.stepsNone : T.stepsHint}
</p>
{STEPS.map((step) => (
<label key={step} className="flex items-start gap-2 text-xs" data-maintenance-step={step}>
<input type="checkbox" className="mt-0.5" checked={form.steps.includes(step)} onChange={() => toggleStep(step)} />
<span className="min-w-0">
<span className="font-medium">{T.step[step]}</span>
<span className="block text-2xs leading-relaxed text-faint">{T.stepHint[step]}</span>
</span>
</label>
))}
{!data.profile_enabled && profileSteps && (
<p className="text-2xs leading-relaxed text-[var(--warn)]" data-maintenance-profile-off="">{T.profileOff}</p>
)}
</fieldset>
<div className={form.enabled ? undefined : 'opacity-70'} data-maintenance-freq="">
<FreqPicker idPrefix="maint" title={T.freqTitle} freq={form.freq} timezone={form.timezone}
setFreq={(patch) => set({ freq: { ...form.freq, ...patch } })} setTimezone={(tz) => set({ timezone: tz })}
freqError={freqError} tzError={tzError} />
{preview?.data && (
<p className="mt-1.5 text-2xs text-dim" data-maintenance-preview="">
{preview.data.description_text}
{preview.data.next.length > 0 && <span className="tnum text-faint"> · {preview.data.next.map((n) => formatDateTime(n)).join('、')}</span>}
</p>
)}
</div>
{schedule && <LastRun schedule={schedule} onOpenProfileJob={(id) => {
onClose()
navigate(`/data/catalog/${encodeURIComponent(source.id)}?pjob=${encodeURIComponent(id)}`)
}} onClose={onClose} />}
{extra > 0 && <p className="text-2xs text-faint" data-maintenance-extra={extra}>{T.extra(extra)}</p>}
</div>
)}
</Modal>
)
}
/** 已经有维护定时:下一次什么时候、最近一次建了哪些任务、哪几步没执行和原因(触发记录的 inputs),到定时任务页看全部触发 */
function LastRun({ schedule: s, onOpenProfileJob, onClose }: {
schedule: MaintenanceSchedule; onOpenProfileJob: (id: string) => void; onClose: () => void
}) {
const fire = s.last_fire
const inputs = fire?.inputs ?? {}
const at = fire ? parseServerTime(fire.fire_at) : null
return (
<section className="space-y-1.5 rounded-lg border bg-panel p-3.5 text-xs" aria-labelledby="maint-last-title" data-maintenance-last={fire?.status ?? 'none'}>
<p className="flex flex-wrap items-center gap-1.5 text-dim" data-maintenance-next="">
<CalendarClock size={12} aria-hidden />
<span>{s.description_text}</span>
<span className="text-faint">·</span>
<NextRun schedule={s} now={Date.now()} />
</p>
<h3 id="maint-last-title" className="text-2xs font-semibold">{T.lastTitle}</h3>
{!fire
? <p className="text-2xs text-faint">{T.lastNone}</p>
: (
<>
<p className="flex flex-wrap items-center gap-1.5">
<span className="tnum">{at ? formatDateTime(at) : fire.fire_at}</span>
<FireStatusChip fire={fire} />
</p>
{(inputs.jobs ?? []).length > 0 && (
<ul className="space-y-0.5">
{(inputs.jobs ?? []).map((j) => (
<li key={j.id} className="flex flex-wrap items-center gap-2 text-2xs" data-maintenance-job={j.id} data-maintenance-job-kind={j.kind}>
<span className="mono">{T.lastJob(j.step.split('+').map((x) => T.step[x as MaintenanceStep] ?? x).join('、'),
T.lastJobKind[j.kind] ?? j.kind, j.id, j.tables ?? null)}</span>
{j.kind === 'profile'
? <button type="button" className="btn btn-xs" onClick={() => onOpenProfileJob(j.id)} data-maintenance-job-open="">{T.viewJob}</button>
// 探查任务的进度就在这张数据源卡片上:关上对话框就看得到
: <button type="button" className="btn btn-xs" onClick={onClose} data-maintenance-job-open="">{T.viewJob}</button>}
</li>
))}
</ul>
)}
{(inputs.skipped ?? []).map((k) => (
<p key={k.step} className="text-2xs leading-relaxed text-[var(--warn)]" data-maintenance-skipped={k.step}>
{T.lastSkipped(T.step[k.step] ?? k.step, k.reason)}
</p>
))}
{!(inputs.jobs ?? []).length && !(inputs.skipped ?? []).length && fire.reason && (
<p className="text-2xs leading-relaxed text-dim" data-maintenance-reason="">{T.lastReason(fire.reason)}</p>
)}
</>
)}
<Link to={`/schedules/${encodeURIComponent(s.id)}`} className="inline-block text-2xs text-[var(--accent)] underline-offset-2 hover:underline"
data-maintenance-history="">{T.history}</Link>
</section>
)
}
/** 卡片上的「定时维护」标签:开着时写频率、暂停时照实写;没有维护定时不画 */
export function MaintenanceChip({ maintenance }: { maintenance: { enabled: boolean; description_text: string }[] }) {
const first = maintenance[0]
const text = useMemo(() => (first ? (first.enabled ? T.chip(first.description_text) : T.chipPaused) : ''), [first])
if (!first) return null
return (
<span className="chip" title={T.chipHint} data-maintenance-chip={first.enabled ? 'on' : 'paused'}>
<CalendarClock size={10} aria-hidden /> {text}
</span>
)
}
+178 -96
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
ArrowLeft, BookMarked, Database, Download, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert, Upload,
ArrowLeft, BookMarked, Clock, Database, Download, ListFilter as ListFilterIcon, ScanSearch, Sparkles, Upload,
} from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
@@ -14,12 +14,12 @@ import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
import { errorMessage } from '../../lib/errors'
import { formatNumber } from '../../lib/format'
import {
CATALOG_DRAFT_TEXT as DT, CATALOG_REVIEW_TEXT as RT, CATALOG_TEXT as CT, CATALOG_TRANSFER_TEXT as XT, PROFILE_TEXT as PT,
SCHEMA_PARTIAL_TEXT as SPT,
CATALOG_DRAFT_TEXT as DT, CATALOG_REVIEW_TEXT as RT, CATALOG_TEXT as CT, CATALOG_TRANSFER_TEXT as XT, JOB_STRIP_TEXT as JT,
PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT, SOURCE_CHANGE_TEXT as GT,
} from '../../lib/terms'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
import { isJobActive, jobBlank, retryableCount, useDraftJob, waitsForProfile } from './draftJob'
import { isJobActive, jobBlank, retryableCount, useDraftJob } from './draftJob'
import type { DraftPreset, DraftStartBody } from './draftJob'
import { ProfileDialog } from './ProfileDialog'
import { ProfileSettingsDialog } from './ProfileSettings'
@@ -32,7 +32,12 @@ import { TableIndex } from './TableIndex'
import { ReviewOverview, ReviewQueue } from './ReviewQueue'
import { ImportDialog } from './TransferDialog'
import { itemKey, reviewParamsOf, writeReviewParams } from './review'
import type { CatalogView } from './review'
import { ChangesQueue } from './ChangesQueue'
import { ChangeDetail, ChangesOverview } from './ChangeDetail'
import { changesParamsOf, clearChanges, isChangesView, writeChangesParams, writtenTables } from './changes'
import { JobStrip, stripItems, useSourceJobs } from './JobStrip'
import type { StripItem } from './JobStrip'
import type { SourceChangeCategory, SourceChangeCounts, SourceChangeResolveOut, SourceChangeStatusFilter } from '../../typesSource'
import { dueCount, filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
import type { ListFilter, ListParams, ListSort } from './model'
@@ -48,13 +53,17 @@ import type { ListFilter, ListParams, ListSort } from './model'
// 清单的搜索词、筛选、排序和「只看运行中查询过的表」记在地址的查询参数里(q / filter / sort / used),刷新、前进后退
// 都不丢;在清单和表详情之间跳转时带着它们走。
//
// 左栏有两个视图:表清单(逐表看)和审阅队列(view=review:跨表按优先级列出推断项,成批确认、驳回,筛选记在 kind /
// rtable 里)。从队列点开一项,右栏是所在表的详情,地址带着那一项(item),详情滚到那一项、列出用过这张表的运行。
// 左栏有三个视图:表清单(逐表看)、审阅队列(view=review:跨表按优先级列出推断项,成批确认、驳回,筛选记在 kind /
// rtable 里)和变化(view=changes:探查、取值观察测到的结构变化、取值变化,按要处理 / 提醒 / 说明分组,筛选记在 cstatus /
// ccat 里)。从队列点开一项,右栏是所在表的详情,地址带着那一项(item),详情滚到那一项、列出用过这张表的运行;从变化里点开
// 一条(change),右栏是它的依据、影响面和处理。
// 页头的「导出目录」「导入目录」把整理好的目录带到另一个实例(导入先预览,确认后才写)。
//
// 起草、剖析都是服务端的后台任务(draftJob.ts、profileJob.ts),状态在这一页上:关掉对话框照常进行,页头的「起草」「数据
// 剖析」换成进度入口(「起草 3/20」「剖析 12/40」「剖析 排队中」),点开对应的对话框;结束时右下角提示,可以回到结果。
// 同一个数据源的起草和剖析按建立顺序排队:一个在排队、进行中时,另一个建出来是排队,前一个结束后自动开始。
// 起草、剖析都是服务端的后台任务(draftJob.ts、profileJob.ts),状态在这一页上:关掉对话框照常进行;结束时右下角提示,可以
// 回到结果。同一个数据源的探查、起草、剖析在同一条队里排队:一个在排队、进行中时,另一个建出来是排队,前一个结束后自动开始。
// 进度只在页头下方的后台任务条上(JobStrip,按服务端给的队列先后列出,next-round 05 (g));页头的「起草」「数据剖析」只是
// 入口,进行中换转圈、排队中换时钟,读屏名称写状态、不写数字。
// 地址里带 pjob / djob 时打开那个剖析、起草任务的进度(定时维护的「最近一次」从数据源页链过来)。
// ===========================================================================
export function CatalogPage() {
@@ -135,7 +144,12 @@ export function CatalogPage() {
carriedRef.current = carried
/** 审阅视图:view=review 时左栏是审阅队列;kind、rtable 是队列的筛选;item 是右栏从队列打开的那一项 */
const review = useMemo(() => reviewParamsOf(params), [params])
const inReview = review.view === 'review'
/** 变化视图:view=changes 时左栏是变化列表;cstatus、ccat 是筛选;change 是右栏打开的那一条 */
const inChanges = isChangesView(params)
const changesParams = useMemo(() => changesParamsOf(params), [params])
const pageView: PageView = inChanges ? 'changes' : review.view
const inReview = pageView === 'review'
const openChangeId = inChanges ? changesParams.change || null : null
const [selected, setSelected] = useState<Set<string>>(() => new Set())
/**
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
@@ -248,12 +262,12 @@ export function CatalogPage() {
const onGuidanceSaved = useCallback((g: string | null) => {
setData((cur) => cur && ({ ...cur, guidance: g }))
}, [])
// 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
// 起草任务现在的样子:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null
const stripJob: { done: number; total: number; status: string; waiting_for?: string | null } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
const stripJob: { id: string; done: number; total: number; status: string; waiting_for?: string | null } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
? (draftActive ? draftJob : null)
: listActive
// 调用接连没写出来的提醒:关着对话框时也要看得到(上方的进度带着它)
// 调用接连没写出来的提醒:关着对话框时也要看得到(后台任务条上起草那一行带着它)
const stripNotice = draftActive ? draftJob?.notice?.trim() || null : null
const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
@@ -288,7 +302,7 @@ export function CatalogPage() {
const profileActive = isJobActive(profileJob)
// 页头的剖析进度:同起草,跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单
const listProfile = isJobActive(data?.active_profile_job) ? data?.active_profile_job ?? null : null
const profileStrip: { done: number; total: number; status: string; waiting_for?: string | null } | null =
const profileStrip: { id: string; done: number; total: number; status: string; waiting_for?: string | null } | null =
profileJob && (profileActive || profileJob.id === listProfile?.id) ? (profileActive ? profileJob : null) : listProfile
/** 跟着的剖析任务里各表的状态(表清单的「剖析」一格据此写排队、剖析中) */
const profileLive = useMemo(() => new Map<string, CatalogProfileTaskStatus>(
@@ -320,6 +334,70 @@ export function CatalogPage() {
toast.error(e)
}
}, [sourceId, profile.job?.id, profile.track])
/** 打开起草对话框显示某个任务:页面跟着的那一个直接打开;别的(变化里「重新起草」建的)先取来 */
const openDraftJob = useCallback(async (jobId: string) => {
if (draft.job?.id === jobId) {
setDraftOpen({ view: 'job' })
return
}
const src = sourceId
try {
const { job } = await api.dataCatalog.draftJob(src, jobId)
if (sourceRef.current !== src) return
draft.track(job)
setDraftOpen({ view: 'job' })
} catch (e) {
toast.error(e)
}
}, [sourceId, draft.job?.id, draft.track])
const openJob = useCallback((kind: 'draft' | 'profile', id: string) => {
if (kind === 'draft') void openDraftJob(id)
else void openProfileJob(id)
}, [openDraftJob, openProfileJob])
// 地址里带着 pjob / djob(定时维护的「最近一次」从数据源页链过来):打开那个任务的进度,参数用过就去掉
const linkedJob = params.get('pjob') ? `profile:${params.get('pjob')}` : params.get('djob') ? `draft:${params.get('djob')}` : ''
const linkedSeen = useRef('')
useEffect(() => {
if (!linkedJob || linkedSeen.current === linkedJob || !data) return
linkedSeen.current = linkedJob
const [kind, id] = linkedJob.split(':')
openJob(kind as 'draft' | 'profile', id)
const next = new URLSearchParams(window.location.search)
next.delete('pjob')
next.delete('djob')
navigate({ search: next.toString() ? `?${next.toString()}` : '' }, { replace: true })
}, [linkedJob, data, openJob, navigate])
/**
* 后台任务条:服务端排好的队列(探查、起草、剖析),起草、剖析换上页面跟着的任务的数和提醒。页面上的任务换了状态就重取一次
* 队列(排在谁后面跟着变)
*/
const profileNotice = profileActive ? profileJob?.notice?.trim() || null : null
const jobsKick = `${stripJob?.id ?? ''}:${stripJob?.status ?? ''}|${profileStrip?.id ?? ''}:${profileStrip?.status ?? ''}`
const sourceJobs = useSourceJobs(sourceId, jobsKick)
const jobItems = useMemo(() => stripItems(sourceJobs.items, {
draft: stripJob ? { kind: 'draft', id: stripJob.id, status: stripJob.status, done: stripJob.done, total: stripJob.total,
waiting_for: stripJob.waiting_for ?? null, position: null, notice: stripNotice } : null,
profile: profileStrip ? { kind: 'profile', id: profileStrip.id, status: profileStrip.status, done: profileStrip.done,
total: profileStrip.total, waiting_for: profileStrip.waiting_for ?? null, position: null, notice: profileNotice } : null,
}), [sourceJobs.items, stripJob, stripNotice, profileStrip, profileNotice])
const openStripItem = useCallback((item: StripItem) => {
if (item.kind === 'introspect') navigate(`${back}#source-${encodeURIComponent(sourceId)}`)
else openJob(item.kind, item.id)
}, [navigate, back, sourceId, openJob])
// 变化视图:标签上的「N 条要处理」、处理过一条之后左栏重取
const [changeCounts, setChangeCounts] = useState<{ src: string; counts: SourceChangeCounts } | null>(null)
const [changesSeq, setChangesSeq] = useState(0)
const loadChangeCounts = useCallback(() => {
const src = sourceId
api.sourceChanges.list(src, { limit: 1 }).then((r) => setChangeCounts({ src, counts: r.counts }), () => {})
}, [sourceId])
useEffect(() => { loadChangeCounts() }, [loadChangeCounts])
useOnReconnect(loadChangeCounts)
const actionCount = changeCounts?.src === sourceId ? changeCounts.counts.by_severity.action : 0
const onChangeCounts = useCallback((counts: SourceChangeCounts) => setChangeCounts({ src: sourceId, counts }), [sourceId])
const tableProfile: TableProfileProps = useMemo(() => ({
job: profileJob, onStart: profileTable, onShowActive: profile.showActive, onSettings: () => setProfileSettingsOpen(true),
onOpenJob: (id: string | null) => void openProfileJob(id),
@@ -376,10 +454,29 @@ export function CatalogPage() {
const next = fn(new URLSearchParams(window.location.search)).toString()
navigate({ search: next ? `?${next}` : '' }, opts)
}, [navigate])
/** 换视图(表清单 / 审阅队列):记进地址(后退回到上一个视图),右栏正在看的表不动 */
const setView = useCallback((v: CatalogView) => {
patchSearch((prev) => writeReviewParams(prev, { view: v, item: '' }))
/** 换视图(表清单 / 审阅队列 / 变化):记进地址(后退回到上一个视图),右栏正在看的表不动 */
const setView = useCallback((v: PageView) => {
patchSearch((prev) => (v === 'changes'
? writeChangesParams(writeReviewParams(prev, { item: '' }), { open: true })
: clearChanges(writeReviewParams(prev, { view: v, item: '' }))))
}, [patchSearch])
/** 变化视图的筛选:替换当前这条历史 */
const setChangesFilter = useCallback((p: { status?: SourceChangeStatusFilter; category?: SourceChangeCategory | null }) => {
patchSearch((prev) => writeChangesParams(prev, p), { replace: true })
}, [patchSearch])
/** 打开一条变化:右栏是它,地址带着它(表详情里点「去处理」也走这里,切到变化视图) */
const openChange = useCallback((id: string) => {
const q = writeChangesParams(clearChanges(carriedRef.current), { open: true, change: id, status: changesParams.status,
category: changesParams.category }).toString()
navigate(`${base}${q ? `?${q}` : ''}`)
}, [navigate, base, changesParams.status, changesParams.category])
/** 窄屏:从一条变化回到变化列表 */
const closeChange = useCallback(() => patchSearch((prev) => writeChangesParams(prev, { change: '' })), [patchSearch])
/** 从一条变化去看它说的那张表:切回表清单 */
const openChangeTable = useCallback((t: string) => {
const q = clearChanges(writeReviewParams(carriedRef.current, { view: 'list', item: '' })).toString()
navigate(`${base}/${encodeURIComponent(t)}${q ? `?${q}` : ''}`)
}, [navigate, base])
/** 审阅队列的筛选:替换当前这条历史(筛选不该占满后退键) */
const setReviewParams = useCallback((p: { kind?: CatalogReviewKind; table?: string }) => {
patchSearch((prev) => writeReviewParams(prev, p), { replace: true })
@@ -394,6 +491,17 @@ export function CatalogPage() {
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
void load()
}, [load])
/** 处理过一条变化:左栏重取、计数跟着变;动了目录的重取清单、重新载入那几张表;建了任务的跟上(清单带着进行中的任务) */
const onChangeResolved = useCallback((out: SourceChangeResolveOut) => {
setChangesSeq((n) => n + 1)
loadChangeCounts()
const tables = writtenTables(out.change, out.catalog_versions)
if (tables.length) onTablesWritten(tables)
else if (out.job) void load()
if (out.job) sourceJobs.refresh()
}, [loadChangeCounts, onTablesWritten, load, sourceJobs.refresh])
/** 右栏有内容(打开了一张表、一条变化):窄屏一次只显示一栏,这时显示右栏 */
const rightOpen = !!table || !!openChangeId
const activeReviewKey = inReview && table && review.item ? itemKey({ table_name: table, path: review.item }) : null
const proposedTotal = useMemo(() => rows.reduce((n, r) => n + (r.in_schema ? r.counts.proposed : 0), 0), [rows])
@@ -438,36 +546,28 @@ export function CatalogPage() {
<Sparkles size={11} aria-hidden /> {CT.draft}
</button>
)
// 页头的「数据剖析」「起草」:任务在排队、进行中时换成进度入口(「剖析 12/40」「剖析 排队中」),点开对应的对话框
// (关掉对话框任务照常进行);排在另一种任务后面时悬停写明。窄屏只留图标,标题才放得下(进度在读屏的名称里)
const profileQueued = profileStrip?.status === 'queued'
const profileLabel = profileStrip ? (profileQueued ? PT.headerQueued : PT.headerRunning(profileStrip.done, profileStrip.total)) : PT.action
const profileTitle = profileStrip
? (profileQueued ? (profileStrip.waiting_for === 'draft' ? PT.headerQueuedDraft
: profileStrip.waiting_for === 'introspect' ? PT.headerQueuedIntrospect : PT.headerQueuedHint)
: PT.headerRunningHint(profileStrip.done, profileStrip.total))
: PT.actionHint
// 最近查询多数超时的提醒:对话框关着时也要看得到(页头的入口换成提醒色,悬停写全文)
const profileNotice = profileActive ? profileJob?.notice?.trim() || null : null
// 页头的「数据剖析」「起草」只是入口(进度在下方的后台任务条上,05 (g)):进行中换转圈图标、排队中换时钟,读屏名称写状态、
// 不写数字;点开对应的对话框(关掉对话框任务照常进行)。窄屏只留图标,标题才放得下
const headState = (job: { status: string } | null) => (job?.status === 'queued' ? 'queued' : job?.status === 'running' ? 'running' : null)
const headLabel = (label: string, state: 'queued' | 'running' | null) =>
(state === 'running' ? JT.headRunning(label) : state === 'queued' ? JT.headQueued(label) : label)
const headIcon = (state: 'queued' | 'running' | null, idle: ReactNode) =>
(state === 'running' ? <Spinner size={11} /> : state === 'queued' ? <Clock size={11} aria-hidden /> : idle)
const profileState = headState(profileStrip)
const profileLabel = headLabel(PT.action, profileState)
const headerProfile = (
<button type="button" className="btn btn-sm" onClick={openProfile} aria-label={profileLabel} title={profileNotice ? `${profileTitle}。${profileNotice}` : profileTitle}
data-catalog-profile={profileStrip?.status ?? ''} data-progress={profileStrip ? `${profileStrip.done}/${profileStrip.total}` : undefined}>
{profileStrip && !profileQueued ? <Spinner size={11} /> : <ScanSearch size={11} aria-hidden />}
<span className="tnum hidden sm:inline">{profileLabel}</span>
{profileNotice && <TriangleAlert size={11} className="shrink-0 text-[var(--warn)]" aria-hidden data-catalog-profile-notice="" />}
<button type="button" className="btn btn-sm" onClick={openProfile} aria-label={profileLabel} title={profileState ? profileLabel : PT.actionHint}
data-catalog-profile={profileStrip?.status ?? ''}>
{headIcon(profileState, <ScanSearch size={11} aria-hidden />)}
<span className="hidden sm:inline">{PT.action}</span>
</button>
)
const draftQueued = stripJob?.status === 'queued'
const draftLabel = stripJob ? (draftQueued ? DT.headerQueued : DT.headerRunning(stripJob.done, stripJob.total)) : CT.draft
const draftTitle = stripJob
? (draftQueued ? (waitsForProfile(stripJob) ? DT.headerQueuedProfile
: stripJob.waiting_for === 'introspect' ? DT.headerQueuedIntrospect : DT.headerQueuedHint)
: DT.headerRunningHint(stripJob.done, stripJob.total))
: CT.draft
const draftState = headState(stripJob)
const draftLabel = headLabel(CT.draft, draftState)
const headerDraft = (
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={draftLabel} title={draftTitle} data-catalog-draft=""
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={draftLabel} title={draftLabel} data-catalog-draft=""
data-catalog-draft-head={stripJob?.status ?? ''}>
{stripJob && !draftQueued ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />} <span className="tnum hidden sm:inline">{draftLabel}</span>
{headIcon(draftState, <Sparkles size={11} aria-hidden />)} <span className="hidden sm:inline">{CT.draft}</span>
</button>
)
@@ -495,10 +595,15 @@ export function CatalogPage() {
} else {
body = (
<div className="flex min-h-0 flex-1">
<aside className={clsx('min-h-0 w-full flex-col border-r lg:flex lg:w-[380px] lg:shrink-0 xl:w-[420px]', table ? 'hidden' : 'flex')}>
<ViewTabs view={review.view} proposed={proposedTotal} onChange={setView} />
<div role="tabpanel" id="catalog-view-panel" aria-labelledby={`catalog-view-tab-${review.view}`} className="flex min-h-0 flex-1 flex-col">
{inReview
<aside className={clsx('min-h-0 w-full flex-col border-r lg:flex lg:w-[380px] lg:shrink-0 xl:w-[420px]', rightOpen ? 'hidden' : 'flex')}>
<ViewTabs view={pageView} proposed={proposedTotal} changes={actionCount} onChange={setView} />
<div role="tabpanel" id="catalog-view-panel" aria-labelledby={`catalog-view-tab-${pageView}`} className="flex min-h-0 flex-1 flex-col">
{inChanges
? (
<ChangesQueue sourceId={sourceId} params={changesParams} activeId={openChangeId} refreshKey={changesSeq}
onParams={setChangesFilter} onOpen={(c) => openChange(c.id)} onCounts={onChangeCounts} />
)
: inReview
? (
<ReviewQueue sourceId={sourceId} rows={rows} kind={review.kind} table={review.table} activeKey={activeReviewKey}
detailSync={detailSync} onParams={setReviewParams} onOpenItem={openReviewItem} onChanged={onTablesWritten} />
@@ -544,15 +649,22 @@ export function CatalogPage() {
)}
</div>
</aside>
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', table ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
{table
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', rightOpen ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
{openChangeId
? (
<ChangeDetail sourceId={sourceId} changeId={openChangeId} onBack={closeChange} onResolved={onChangeResolved}
onOpenTable={openChangeTable} onOpenJob={openJob} />
)
: table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance}
profileEnabled={profileSettings.enabled} focusPath={inReview ? review.item || null : null} usageOpen={inReview}
profile={tableProfile} />
profile={tableProfile} onOpenChange={openChange} />
)
: inChanges
? <ChangesOverview />
: inReview
? <ReviewOverview />
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable}
@@ -596,7 +708,7 @@ export function CatalogPage() {
scoped={!!data.scope} onGoSource={() => navigate(back)} />
)}
{hasTables && usage.used > 0 && !empty && <UsageSummary {...usage} active={usedOnly ? filter : null} onFocus={focusUsed} />}
{hasTables && stripJob && !draftOpen && <DraftStrip job={stripJob} notice={stripNotice} onOpen={openDraft} />}
{hasTables && <JobStrip items={jobItems} onOpen={openStripItem} />}
{body}
{draftOpen && data && (
<DraftDialog
@@ -660,45 +772,6 @@ export function CatalogPage() {
)
}
/**
* 起草任务在进行:目录页上方一条进度(关掉对话框任务照常进行,每张表起草完就写进目录)。点「查看进度」回到对话框。
* notice:调用接连没写出来时服务端给的提醒,跟着写一行(全文在悬停里,对话框里也有)
*/
function DraftStrip({ job, notice, onOpen }: {
job: { done: number; total: number; status: string; waiting_for?: string | null }
notice: string | null
onOpen: () => void
}) {
// 排在剖析任务后面(同一个数据源的起草和剖析按建立顺序排队):写明剖析结束后自动开始
const text = job.status === 'queued'
? (waitsForProfile(job) ? DT.stripQueuedProfile(job.total)
: job.waiting_for === 'introspect' ? DT.stripQueuedIntrospect(job.total) : DT.stripQueued(job.total))
: DT.strip(job.done, job.total)
return (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-xs"
style={{ background: 'color-mix(in srgb, var(--accent) 6%, var(--bg-panel))' }}
data-catalog-draft-progress={`${job.done}/${job.total}`}>
<Spinner size={12} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium" title={text}>{text}</p>
{notice && (
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-[var(--warn)]" role="status" title={notice}
data-catalog-draft-notice="">
<TriangleAlert size={10} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{notice}</span>
</p>
)}
<div className="mt-1 h-1 max-w-xs overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={text}
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={job.done}>
<div className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-300"
style={{ width: `${job.total ? (job.done / job.total) * 100 : 0}%` }} />
</div>
</div>
<button type="button" className="btn btn-sm shrink-0" onClick={onOpen} data-catalog-draft-open="">{DT.stripOpen}</button>
</div>
)
}
/** 存成文件(同运行记录里的下载):浏览器下载,文件名照给的 */
function saveFile(name: string, blob: Blob) {
const url = URL.createObjectURL(blob)
@@ -709,13 +782,17 @@ function saveFile(name: string, blob: Blob) {
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
const VIEWS: CatalogView[] = ['list', 'review']
/** 数据目录页左栏的三个视图 */
type PageView = 'list' | 'review' | 'changes'
const VIEWS: PageView[] = ['list', 'review', 'changes']
/**
* 左栏上方的两个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项,带着还有几项待确认)。标签页语义,
* ←→ Home End 切换
* 左栏上方的三个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项,带着还有几项待确认)、变化(探查、取值观察测到的
* 结构变化和取值变化,带着几条要处理)。标签页语义,←→ Home End 切换
*/
function ViewTabs({ view, proposed, onChange }: { view: CatalogView; proposed: number; onChange: (v: CatalogView) => void }) {
function ViewTabs({ view, proposed, changes, onChange }: {
view: PageView; proposed: number; changes: number; onChange: (v: PageView) => void
}) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
const move = (e: ReactKeyboardEvent, i: number) => {
const j = rovingTarget(e, i, VIEWS.length, 'horizontal')
@@ -730,12 +807,17 @@ function ViewTabs({ view, proposed, onChange }: { view: CatalogView; proposed: n
const selected = view === v
return (
<button key={v} ref={(el) => { refs.current[v] = el }} type="button" role="tab" id={`catalog-view-tab-${v}`} aria-selected={selected}
aria-controls="catalog-view-panel" tabIndex={selected ? 0 : -1} title={RT.viewHint[v]}
aria-controls="catalog-view-panel" tabIndex={selected ? 0 : -1} title={v === 'changes' ? GT.viewHint : RT.viewHint[v]}
onClick={() => onChange(v)} onKeyDown={(e) => move(e, i)} data-catalog-view-tab={v}
className={clsx('relative px-3 py-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]',
selected ? 'text-fg' : 'text-faint hover:text-dim')}>
{RT.view[v]}
{v === 'changes' ? GT.view : RT.view[v]}
{v === 'review' && proposed > 0 && <span className="tnum ml-1.5 text-2xs text-faint">{formatNumber(proposed)}</span>}
{v === 'changes' && changes > 0 && (
<span className="tnum ml-1.5 text-2xs text-[var(--err)]" data-catalog-changes-count={changes}>
<span aria-hidden>{formatNumber(changes)}</span><span className="sr-only">{GT.badge(changes)}</span>
</span>
)}
{selected && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-[var(--accent)]" aria-hidden />}
</button>
)
+450
View File
@@ -0,0 +1,450 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, Check, ExternalLink, Table2 } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type { SourceChange, SourceChangeAction, SourceChangeActionOption, SourceChangeResolveBody, SourceChangeResolveOut } from '../../typesSource'
import { EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast } from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { CATALOG_STATUS_LABEL, SOURCE_CHANGE_KIND_LABEL as KL, SOURCE_CHANGE_TEXT as T } from '../../lib/terms'
import { SEVERITY_MARK } from './ChangesQueue'
// ===========================================================================
// 一条变化(数据目录页「变化」视图的右栏):服务端的一句说法,依据(谁、经什么测到的,取值变化的观察口径),前后各是什么,
// 影响面(说到这些列的目录项和它们现在的状态、写死了这张表的已发布模板、「这条 SQL 现在会报列不存在」),能做的处理。
//
// 能做哪些动作、做不了的原因都是服务端给的(change.actions),这里只照着画;动作落到目录已有的写入接口上(清理目录项、加入
// 码值),或者建起草、剖析任务。处理完照实写结果:目录第几版、建了的任务编号(点得进进度),谁、什么时候、理由。误报必须写
// 理由(按钮在写了之前不能点,服务端也会拒)。
// ===========================================================================
export function ChangeDetail({ sourceId, changeId, initial, onBack, onResolved, onOpenTable, onOpenJob }: {
sourceId: string
changeId: string
/** 列表里已经有的这一条:先照它画,取回来再换 */
initial?: SourceChange | null
/** 窄屏:回到变化列表 */
onBack: () => void
/** 处理过了:列表重取、计数跟着变;动了目录的,页面重取清单、重新载入那几张表 */
onResolved: (out: SourceChangeResolveOut) => void
onOpenTable: (table: string) => void
/** 打开起草、剖析任务的进度 */
onOpenJob: (kind: 'draft' | 'profile', id: string) => void
}) {
const [change, setChange] = useState<SourceChange | null>(initial?.id === changeId ? initial : null)
const [loadError, setLoadError] = useState<unknown>(null)
const [busy, setBusy] = useState<SourceChangeAction | null>(null)
const [error, setError] = useState<string | null>(null)
/** 正在填的表单:加入码值(码值 → 含义)、误报(理由) */
const [form, setForm] = useState<{ kind: 'add_codes'; codes: Record<string, string> } | { kind: 'dismiss'; note: string } | null>(null)
const seq = useRef(0)
const load = useCallback(async () => {
const mine = ++seq.current
try {
const { change: next } = await api.sourceChanges.get(sourceId, changeId)
if (mine !== seq.current) return
setChange(next)
setLoadError(null)
} catch (e) {
if (mine === seq.current) setLoadError(e)
}
}, [sourceId, changeId])
useEffect(() => {
setChange(initial?.id === changeId ? initial : null)
setLoadError(null)
setError(null)
setForm(null)
void load()
// 只在换了一条时重来:initial 跟着列表刷新会变,不因为它重取
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load, changeId])
const resolve = async (body: SourceChangeResolveBody) => {
if (!change || busy) return
setBusy(body.action)
setError(null)
try {
const out = await api.sourceChanges.resolve(sourceId, change.id, body)
setChange(out.change)
setForm(null)
toast.ok(T.done[body.action])
onResolved(out)
} catch (e) {
// 误报没写理由、建不了任务(422),处理过了、目录被别人刚改过(409):服务端的原话写在处理一节里
if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 409)) setError(e.message)
else toast.error(e)
} finally {
setBusy(null)
}
}
const act = async (a: SourceChangeActionOption) => {
if (!change || !a.ok) return
if (a.action === 'add_codes') {
const codes: string[] = Array.isArray(change.after?.codes) ? change.after.codes.map(String) : []
setForm({ kind: 'add_codes', codes: Object.fromEntries(codes.map((c) => [c, ''])) })
return
}
if (a.action === 'dismiss') {
setForm({ kind: 'dismiss', note: '' })
return
}
if (a.action === 'clean_catalog') {
const n = change.affects.catalog.filter((r) => r.now).length || change.affects.catalog.length
const ok = await confirmDialog({ title: T.cleanTitle(n), consequences: T.cleanConsequences, confirmLabel: T.cleanConfirm, danger: true })
if (!ok) return
}
await resolve({ action: a.action })
}
if (!change) {
return (
<div className="relative min-h-0 flex-1 overflow-y-auto p-4" data-change-detail="loading">
{loadError
? (loadError instanceof ApiError && loadError.status === 404
? <EmptyState title={T.missing} offline={false} action={<button className="btn btn-sm" onClick={onBack}>{T.backToList}</button>} />
: <ErrorState error={loadError} onRetry={() => void load()} />)
: <Skeleton rows={6} height={28} gap={10} />}
</div>
)
}
const mark = SEVERITY_MARK[change.severity]
const done = change.status === 'resolved' || change.status === 'dismissed'
return (
<div className="relative min-h-0 flex-1 overflow-y-auto" data-change-detail={change.id} data-change-status={change.status}>
<div className="mx-auto max-w-3xl space-y-4 p-4">
<div className="flex items-center gap-2 lg:hidden">
<button type="button" className="btn btn-sm btn-ghost" onClick={onBack} data-change-back="">
<ArrowLeft size={12} aria-hidden /> {T.backToList}
</button>
</div>
<header className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 text-2xs">
<span className="inline-flex items-center gap-1 font-semibold" style={{ color: mark.color }} title={T.severityHint[change.severity]}
data-change-severity-label="">
<mark.Icon size={12} aria-hidden /> {T.severity[change.severity]}
</span>
<span className="chip !py-0">{T.category[change.category]}</span>
<span className="chip !py-0">{KL[change.kind] ?? change.kind}</span>
<span className="text-faint" data-change-state="">{T.status[change.status]}</span>
</div>
<h2 className="break-words text-sm font-semibold" data-change-summary="">{change.summary}</h2>
{change.table && (
<button type="button" className="inline-flex items-center gap-1 rounded text-2xs text-[var(--accent)] underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={() => onOpenTable(change.table!)} data-change-open-table={change.table}>
<Table2 size={11} aria-hidden /> {T.openTable}
</button>
)}
</header>
<Block title={T.basisTitle} attr="basis">
<p className="text-xs text-dim" data-change-detected="">
{T.detectedBy(T.jobKind[change.detected_by.job_kind] ?? change.detected_by.job_kind,
T.trigger[change.detected_by.trigger] ?? change.detected_by.trigger, change.detected_by.actor)}
{' · '}<span className="tnum" title={formatDateTime(change.detected_at)}>{T.detectedAt(formatDateTime(change.detected_at))}</span>
{change.seen_count > 1 && <span className="tnum"> · {T.seen(change.seen_count)}</span>}
</p>
{change.category === 'struct'
? <p className="text-xs text-dim" data-change-basis="struct">{T.structBasis(change.struct_version_before, change.struct_version_after)}</p>
: change.basis_text && (
<p className="text-xs text-dim" data-change-basis="value">
{T.valueBasis(change.basis_text)}<span className="text-faint">。{T.valueBasisNote}</span>
</p>
)}
</Block>
<Facts change={change} />
<Impact change={change} />
{done
? <Resolution change={change} onOpenJob={onOpenJob} />
: (
<Block title={T.actionsTitle} attr="actions">
{change.resolution && <ResolutionLine change={change} />}
<div className="flex flex-wrap gap-1.5" data-change-actions="">
{change.actions.map((a) => (
<button key={a.action} type="button" disabled={!a.ok || !!busy || !!form}
className={clsx('btn btn-sm', a.action === 'clean_catalog' && a.ok && 'btn-primary', a.action === 'dismiss' && 'btn-ghost')}
title={a.ok ? T.actionHint[a.action] : T.unavailable(T.action[a.action], a.reason ?? '')}
aria-describedby={!a.ok ? `change-why-${a.action}` : undefined}
onClick={() => void act(a)} data-change-action={a.action} data-change-action-ok={a.ok ? '1' : '0'}>
{busy === a.action && <Spinner size={11} />}{T.action[a.action]}
</button>
))}
</div>
{change.actions.filter((a) => !a.ok && a.reason).map((a) => (
<p key={a.action} id={`change-why-${a.action}`} className="text-2xs leading-relaxed text-faint" data-change-why={a.action}>
{T.unavailable(T.action[a.action], a.reason!)}
</p>
))}
{change.job && <JobLine job={change.job} onOpenJob={onOpenJob} />}
{form?.kind === 'add_codes' && (
<CodesForm codes={form.codes} busy={busy === 'add_codes'} onChange={(codes) => setForm({ kind: 'add_codes', codes })}
onCancel={() => setForm(null)} onSubmit={() => void resolve({ action: 'add_codes', codes: form.codes })} />
)}
{form?.kind === 'dismiss' && (
<DismissForm note={form.note} busy={busy === 'dismiss'} onChange={(note) => setForm({ kind: 'dismiss', note })}
onCancel={() => setForm(null)} onSubmit={() => void resolve({ action: 'dismiss', note: form.note.trim() })} />
)}
{error && <Notice tone="err" attr={{ 'data-change-error': '' }}>{error}</Notice>}
</Block>
)}
</div>
</div>
)
}
function Block({ title, attr, children }: { title: string; attr: string; children: ReactNode }) {
return (
<section className="space-y-1.5" aria-label={title} data-change-section={attr}>
<h3 className="text-xs font-semibold">{title}</h3>
<div className="space-y-1.5 rounded-lg border bg-panel px-3 py-2.5">{children}</div>
</section>
)
}
const str = (v: unknown): string => (v == null || v === '' ? T.none : Array.isArray(v) ? v.join('、') : String(v))
const fk = (v: any): string => (v && typeof v === 'object'
? `${(v.columns ?? []).join('、')} → ${v.to_table}.${(v.to_columns ?? []).join('、')}` : T.none)
/** 前后各是什么:按种类写(服务端记下的 before / after 照原样,数都是服务端算好的) */
function Facts({ change: c }: { change: SourceChange }) {
const b = c.before ?? null
const a = c.after ?? null
const lines: ReactNode[] = []
const pair = (from: string, to: string) => (
<span className="mono break-all">{from} <span className="text-faint">→</span> {to}</span>
)
const rename = (a && a.rename) || (b && b.rename)
switch (c.kind) {
case 'column_type_changed':
lines.push(pair(str(b), str(a)))
break
case 'column_nullable_changed':
lines.push(pair(T.nullable(!!b), T.nullable(!!a)))
break
case 'comment_changed':
lines.push(pair(str(b), str(a)))
break
case 'column_removed':
if (b?.type) lines.push(<span>{T.before}:<span className="mono">{b.type}</span>{b.nullable === false ? `,${T.nullable(false)}` : ''}</span>)
break
case 'column_added':
if (a?.type) lines.push(<span>{T.after}:<span className="mono">{a.type}</span>{a.nullable === false ? `,${T.nullable(false)}` : ''}</span>)
break
case 'key_changed':
lines.push(pair(str(b), str(a)))
break
case 'fk_changed':
lines.push(pair(fk(b), fk(a)))
break
case 'table_reflect_failed':
if (a?.error) lines.push(T.reflectFailed(String(a.error)))
break
case 'scope_changed':
if (a) lines.push(T.scopeRules(Number(a.entered ?? 0), Number(a.left ?? 0)))
break
case 'codes_new': {
const codes: string[] = Array.isArray(a?.codes) ? a.codes.map(String) : []
const shares = a?.shares ?? {}
lines.push(T.newCodes(codes.map((x) => (typeof shares[x] === 'number' ? T.codeShare(x, shares[x]) : x)).join('、')))
if (Array.isArray(b?.known)) lines.push(<span className="text-faint">{T.knownCodes(b.known.length)}</span>)
if (a?.complete_revoked === true) lines.push(<span data-change-revoked="" className="text-[var(--warn)]">{T.completeRevoked(a.catalog_version ?? null)}</span>)
else if (a?.complete_revoked === false) lines.push(<span className="text-faint">{T.completeKept}</span>)
break
}
case 'codes_shift':
if (typeof a?.psi === 'number') lines.push(T.shift(a.psi))
for (const [code, from, to] of (a?.top ?? []) as [string, number, number][]) lines.push(<span className="tnum">{T.shiftTop(code, from, to)}</span>)
break
case 'date_stalled':
if (a?.max) lines.push(T.stalled(a.max, Number(a.age_days ?? 0), Number(a.threshold_days ?? 0)))
break
case 'date_gap':
if (a) lines.push(T.gap(Number(a.gaps ?? 0), (a.days ?? []).join('、')))
break
case 'volume_drop':
if (a?.day) lines.push(T.volume(a.day, Number(a.rows ?? 0), Number(b?.median ?? 0)))
break
case 'rows_drop':
case 'rows_jump':
if (typeof b?.rows === 'number' && typeof a?.rows === 'number') lines.push(<span className="tnum">{T.rows(b.rows, a.rows, !!a.estimated)}</span>)
break
case 'coverage_drop':
if (typeof b?.coverage === 'number' && typeof a?.coverage === 'number') lines.push(T.coverage(String(b.target ?? ''), b.coverage, a.coverage))
break
default:
break
}
if (rename?.from && rename?.to) lines.push(<span className="text-[var(--warn)]" data-change-rename="">{T.rename(rename.from, rename.to)}</span>)
if (!lines.length) return null
return (
<Block title={T.factsTitle} attr="facts">
<ul className="space-y-1 text-xs text-dim" data-change-facts={c.kind}>
{lines.map((l, i) => <li key={i} className="break-words">{l}</li>)}
</ul>
</Block>
)
}
/** 影响面:说到这些列的目录项(测到时和现在的状态)、用到这张表的已发布模板(写死的 SQL 会不会坏)、有效记录条件 */
function Impact({ change: c }: { change: SourceChange }) {
const { catalog, templates, rules_touched: rules } = c.affects
const empty = !catalog.length && !templates.length && !rules
return (
<Block title={T.impactTitle} attr="impact">
{empty && <p className="text-xs text-faint" data-change-impact-none="">{T.impactNone}</p>}
{catalog.length > 0 && (
<div data-change-impact-catalog={catalog.length}>
<p className="text-2xs font-medium text-dim">{T.impactCatalog(catalog.length)}</p>
<ul className="mt-1 space-y-0.5">
{catalog.map((r) => (
<li key={`${r.table}\u0000${r.path}`} className="flex flex-wrap items-baseline gap-x-2 text-xs" data-change-ref={`${r.table}::${r.path}`}
data-change-ref-now={r.now?.status ?? 'gone'}>
<span className="min-w-0 break-words">{r.label}</span>
<span className="mono text-2xs text-faint">{r.table}</span>
<span className="text-2xs text-faint">
{r.status ? CATALOG_STATUS_LABEL[r.status as keyof typeof CATALOG_STATUS_LABEL] ?? r.status : ''}
{' · '}{T.impactNow}:{r.now ? (CATALOG_STATUS_LABEL[r.now.status as keyof typeof CATALOG_STATUS_LABEL] ?? r.now.status) : T.impactGone}
</span>
{r.needs_action && r.now && <span className="text-2xs text-[var(--err)]" data-change-ref-needs="">{T.impactNeeds}</span>}
</li>
))}
</ul>
</div>
)}
{rules && <p className="text-xs text-[var(--warn)]" data-change-rules="">{T.impactRules}</p>}
{templates.length > 0 && (
<div data-change-impact-templates={templates.length}>
<p className="text-2xs font-medium text-dim">{T.impactTemplates(templates.length)}</p>
<ul className="mt-1 space-y-1">
{templates.map((t) => (
<li key={`${t.workflow_id}:${t.version}`} className="text-xs" data-change-template={t.workflow_id}>
<Link to={`/studio/${encodeURIComponent(t.workflow_id)}${t.nodes[0] ? `?focus=${encodeURIComponent(t.nodes[0].node_id)}` : ''}`}
className="inline-flex items-center gap-1 text-[var(--accent)] underline-offset-2 hover:underline">
{t.name} <span className="tnum text-faint">{T.templateVersion(t.version)}</span> <ExternalLink size={10} aria-hidden />
</Link>
<span className="ml-2 text-2xs text-faint">{T.templateImpact[t.impact] ?? t.impact}</span>
<ul className="ml-3 mt-0.5 space-y-0.5">
{t.nodes.map((n) => (
<li key={n.node_id} className="text-2xs text-dim" data-change-node={n.node_id} data-change-breaks={n.breaks ? '1' : '0'}>
<span>{n.label || n.node_id}</span>
<span className="ml-1.5 text-faint">{T.templateImpact[n.impact] ?? n.impact}</span>
{n.breaks && <span className="ml-1.5 text-[var(--err)]">{T.breaks(n.unknown_columns ?? [])}</span>}
</li>
))}
</ul>
</li>
))}
</ul>
</div>
)}
</Block>
)
}
/** 知道了还没处理的那一行 */
function ResolutionLine({ change: c }: { change: SourceChange }) {
const r = c.resolution!
return (
<p className="text-2xs text-faint" data-change-acknowledged="">
{T.resolvedBy(T.done[r.action] ?? r.action, r.by, formatDateTime(r.at))}
</p>
)
}
function JobLine({ job, onOpenJob }: { job: { kind: 'draft' | 'profile'; id: string }; onOpenJob: (kind: 'draft' | 'profile', id: string) => void }) {
return (
<p className="flex flex-wrap items-center gap-2 text-xs" data-change-job={job.id} data-change-job-kind={job.kind}>
<span className="mono">{T.jobLink(T.resolvedJob[job.kind], job.id)}</span>
<button type="button" className="btn btn-xs" onClick={() => onOpenJob(job.kind, job.id)} data-change-job-open="">{T.viewProgress}</button>
</p>
)
}
/** 处理过的:谁、什么时候、什么动作、目录第几版、建了的任务、理由 */
function Resolution({ change: c, onOpenJob }: { change: SourceChange; onOpenJob: (kind: 'draft' | 'profile', id: string) => void }) {
const r = c.resolution
return (
<Block title={T.resolutionTitle} attr="resolution">
{r ? (
<div className="space-y-1 text-xs" data-change-resolution={r.action}>
<p className="flex items-center gap-1.5 font-medium">
<Check size={12} style={{ color: 'var(--st-done)' }} aria-hidden />
{T.resolvedBy(T.done[r.action] ?? r.action, r.by, formatDateTime(r.at))}
</p>
{r.catalog_version != null && <p className="tnum text-dim" data-change-resolved-version={r.catalog_version}>{T.resolvedVersion(r.catalog_version)}</p>}
{r.catalog_versions && Object.keys(r.catalog_versions).length > 1 && (
<p className="tnum text-dim">{T.resolvedVersions(Object.entries(r.catalog_versions).map(([t, v]) => `${t} ${T.resolvedVersion(v)}`).join('、'))}</p>
)}
{r.note && <p className="break-words text-dim" data-change-resolved-note="">{T.resolvedNote(r.note)}</p>}
{c.job && <JobLine job={c.job} onOpenJob={onOpenJob} />}
</div>
) : <p className="text-xs text-faint">{T.status[c.status]}</p>}
</Block>
)
}
function CodesForm({ codes, busy, onChange, onCancel, onSubmit }: {
codes: Record<string, string>; busy: boolean
onChange: (codes: Record<string, string>) => void; onCancel: () => void; onSubmit: () => void
}) {
const keys = Object.keys(codes)
return (
<form className="space-y-2 rounded-md border px-2.5 py-2" data-change-codes="" onSubmit={(e) => { e.preventDefault(); onSubmit() }}>
<p className="text-xs font-medium">{T.codesTitle}</p>
<p className="text-2xs leading-relaxed text-faint">{T.codesHint}</p>
<div className="space-y-1.5">
{keys.map((k) => (
<label key={k} className="flex items-center gap-2 text-xs">
<span className="mono w-28 shrink-0 truncate" title={k}>{k}</span>
<input className="field min-w-0 flex-1 !py-0.5 !text-xs" value={codes[k]} aria-label={T.codesMeaning(k)} data-change-code-input={k}
onChange={(e) => onChange({ ...codes, [k]: e.target.value })} />
</label>
))}
</div>
<div className="flex justify-end gap-1.5">
<button type="button" className="btn btn-sm btn-ghost" onClick={onCancel}>{T.cancel}</button>
<button type="submit" className="btn btn-sm btn-primary" disabled={busy || !keys.length} data-change-codes-submit="">
{busy && <Spinner size={11} />}{T.codesSubmit}
</button>
</div>
</form>
)
}
function DismissForm({ note, busy, onChange, onCancel, onSubmit }: {
note: string; busy: boolean; onChange: (note: string) => void; onCancel: () => void; onSubmit: () => void
}) {
const empty = !note.trim()
return (
<form className="space-y-2 rounded-md border px-2.5 py-2" data-change-dismiss="" onSubmit={(e) => { e.preventDefault(); if (!empty) onSubmit() }}>
<label className="label" htmlFor="change-note">{T.noteLabel}<span className="ml-0.5 text-[var(--err)]" aria-hidden>*</span></label>
<textarea id="change-note" className="field min-h-[60px] w-full !text-xs" value={note} placeholder={T.notePlaceholder} maxLength={500}
required aria-describedby="change-note-hint" onChange={(e) => onChange(e.target.value)} data-change-note="" autoFocus />
<p id="change-note-hint" className="text-2xs text-faint">{T.noteRequired}</p>
<div className="flex justify-end gap-1.5">
<button type="button" className="btn btn-sm btn-ghost" onClick={onCancel}>{T.cancel}</button>
<button type="submit" className="btn btn-sm" disabled={busy || empty} data-change-note-submit="">
{busy && <Spinner size={11} />}{T.noteSubmit}
</button>
</div>
</form>
)
}
/** 「变化」视图里没打开哪一条时的右栏 */
export function ChangesOverview() {
return (
<div className="relative min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-xl space-y-3 p-6" data-changes-overview="">
<h2 className="text-sm font-semibold">{T.overviewTitle}</h2>
<p className="text-xs leading-relaxed text-dim">{T.overviewBody}</p>
</div>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertOctagon, AlertTriangle, CheckCheck, Info, RotateCw } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import type { SourceChange, SourceChangeCategory, SourceChangeCounts, SourceChangeSeverity, SourceChangeStatusFilter } from '../../typesSource'
import { EmptyState, ErrorState, Skeleton, Spinner, toast, useRadioGroup } from '../../components/ui'
import { formatDateTime, formatRelative } from '../../lib/format'
import { SOURCE_CHANGE_TEXT as T } from '../../lib/terms'
import { CATEGORY_FILTERS, CHANGES_PAGE, STATUS_FILTERS, groupBySeverity } from './changes'
import type { ChangesParams } from './changes'
// ===========================================================================
// 数据目录页的「变化」视图(左栏):这个数据源测到的结构变化、取值变化,按要处理 / 提醒 / 说明分组,组内按服务端排好的
// 先后(开着的:表的使用次数 × 严重程度;处理过的:最近测到的在前)。能按状态(开着的、处理过的)和类别(结构、取值)筛,
// 筛选记在地址里。点开一条,右栏是它的依据、影响面和处理(ChangeDetail)。
// ===========================================================================
/** 严重程度的图标和颜色(列表、详情同一套) */
export const SEVERITY_MARK: Record<SourceChangeSeverity, { Icon: typeof Info; color: string }> = {
action: { Icon: AlertOctagon, color: 'var(--err)' },
warning: { Icon: AlertTriangle, color: 'var(--warn)' },
info: { Icon: Info, color: 'var(--accent)' },
}
export function ChangesQueue({ sourceId, params, activeId, refreshKey, onParams, onOpen, onCounts }: {
sourceId: string
params: ChangesParams
/** 右栏正打开的那一条 */
activeId: string | null
/** 处理过一条(右栏)之后加一:重取这一页 */
refreshKey: number
onParams: (p: { status?: SourceChangeStatusFilter; category?: SourceChangeCategory | null }) => void
onOpen: (change: SourceChange) => void
/** 各级别的计数(视图标签上的「N 条要处理」跟着它) */
onCounts?: (counts: SourceChangeCounts) => void
}) {
const [items, setItems] = useState<SourceChange[] | null>(null)
const [total, setTotal] = useState(0)
const [loadError, setLoadError] = useState<unknown>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [reloadSeq, setReloadSeq] = useState(0)
const loadSeq = useRef(0)
const onCountsRef = useRef(onCounts)
onCountsRef.current = onCounts
const { status, category } = params
const statusRadio = useRadioGroup(STATUS_FILTERS, status, (s) => onParams({ status: s }))
const catValue = category ?? 'all'
const catRadio = useRadioGroup(CATEGORY_FILTERS, catValue, (c) => onParams({ category: c === 'all' ? null : c }))
// 第一页:筛选变了、处理过一条、点了刷新就重取。处理过一条时不清空列表(右栏还开着那一条),取回来再换
const shownKey = `${sourceId}|${status}|${category ?? ''}`
const lastKey = useRef('')
useEffect(() => {
const mine = ++loadSeq.current
if (lastKey.current !== shownKey) {
lastKey.current = shownKey
setItems(null)
}
setLoadError(null)
api.sourceChanges.list(sourceId, { status, category, limit: CHANGES_PAGE }).then((r) => {
if (mine !== loadSeq.current) return
setItems(r.items)
setTotal(r.total)
onCountsRef.current?.(r.counts)
}, (e) => { if (mine === loadSeq.current) setLoadError(e) })
}, [sourceId, status, category, shownKey, refreshKey, reloadSeq])
const loadMore = useCallback(async () => {
const list = items ?? []
if (loadingMore) return
setLoadingMore(true)
try {
const r = await api.sourceChanges.list(sourceId, { status, category, limit: CHANGES_PAGE, offset: list.length })
setItems((cur) => {
const have = new Set((cur ?? []).map((c) => c.id))
return [...(cur ?? []), ...r.items.filter((c) => !have.has(c.id))]
})
setTotal(r.total)
} catch (e) {
toast.error(e)
} finally {
setLoadingMore(false)
}
}, [items, loadingMore, sourceId, status, category])
const filtered = !!category
const groups = groupBySeverity(items ?? [])
const remaining = Math.max(0, total - (items?.length ?? 0))
return (
<div className="flex min-h-0 flex-1 flex-col" data-changes-queue={status} data-changes-category={category ?? 'all'}>
<div className="space-y-2 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<div role="radiogroup" aria-label={T.statusLabel} className="flex flex-wrap gap-1" data-changes-status-filter={status}>
{STATUS_FILTERS.map((s) => (
<button key={s} type="button" {...statusRadio(s)} onClick={() => onParams({ status: s })} data-changes-status={s}
className={chipClass(status === s)}>
{T.statusFilter[s]}
</button>
))}
</div>
<span className="flex-1" />
<button type="button" className="btn btn-sm btn-ghost shrink-0 !px-1.5" onClick={() => setReloadSeq((n) => n + 1)}
aria-label={T.refresh} title={T.refresh} data-changes-refresh="">
<RotateCw size={12} aria-hidden />
</button>
</div>
<div role="radiogroup" aria-label={T.categoryLabel} className="flex flex-wrap gap-1" data-changes-category-filter={catValue}>
{CATEGORY_FILTERS.map((c) => (
<button key={c} type="button" {...catRadio(c)} onClick={() => onParams({ category: c === 'all' ? null : c })}
data-changes-cat={c} className={chipClass(catValue === c)}>
{c === 'all' ? T.categoryAll : T.category[c]}
</button>
))}
</div>
<p className="tnum text-2xs text-faint" data-changes-total="">{items ? T.total(total) : T.loading}</p>
</div>
<div className="relative min-h-0 flex-1 overflow-y-auto">
{loadError
? <div className="p-3"><ErrorState error={loadError} onRetry={() => setReloadSeq((n) => n + 1)} compact /></div>
: !items
? <div className="p-3"><Skeleton rows={6} height={40} gap={8} /></div>
: !items.length
? (
<EmptyState icon={<CheckCheck size={20} />} offline={false} className="!py-10"
title={status === 'handled' ? T.emptyHandled : filtered ? T.emptyFiltered : T.empty}
body={status === 'handled' || filtered ? undefined : T.emptyBody} />
)
: groups.map((g) => {
const mark = SEVERITY_MARK[g.severity]
const headId = `changes-group-${g.severity}`
return (
<section key={g.severity} aria-labelledby={headId} data-change-group={g.severity}>
<h3 id={headId} className="sticky top-0 z-[1] flex items-center gap-1.5 border-b bg-panel px-3 py-1.5 text-2xs font-semibold"
title={T.severityHint[g.severity]}>
<mark.Icon size={11} style={{ color: mark.color }} aria-hidden />
<span className="tnum">{T.group(T.severity[g.severity], g.items.length)}</span>
</h3>
<ul>
{g.items.map((c) => <ChangeRow key={c.id} change={c} active={activeId === c.id} onOpen={onOpen} />)}
</ul>
</section>
)
})}
{items && remaining > 0 && (
<div className="border-t px-3 py-2">
<button type="button" className="btn btn-sm w-full" onClick={() => void loadMore()} disabled={loadingMore} data-changes-more="">
{loadingMore ? <><Spinner size={11} /> {T.loading}</> : T.more(Math.min(CHANGES_PAGE, remaining))}
</button>
</div>
)}
</div>
</div>
)
}
const chipClass = (on: boolean) => clsx(
'inline-flex items-center rounded-md border px-2 py-0.5 text-2xs outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
on ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover hover:text-fg',
)
/** 列表里的一条:服务端的一句说法、类别、状态(知道了的、处理过的写出来)、测到几次、什么时候 */
function ChangeRow({ change: c, active, onOpen }: { change: SourceChange; active: boolean; onOpen: (c: SourceChange) => void }) {
const mark = SEVERITY_MARK[c.severity]
return (
<li className={clsx('relative border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
data-change-item={c.id} data-change-kind={c.kind} data-change-severity={c.severity} data-change-status={c.status}>
{active && <span className="absolute inset-y-0 left-0 w-0.5 bg-[var(--accent)]" aria-hidden />}
<button type="button" onClick={() => onOpen(c)} aria-current={active ? 'true' : undefined} data-change-open=""
className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]">
<mark.Icon size={12} className="mt-0.5 shrink-0" style={{ color: mark.color }} aria-hidden />
<span className="min-w-0 flex-1">
<span className="sr-only">{T.severity[c.severity]}:</span>
<span className="block break-words text-xs font-medium text-fg" data-change-summary="">{c.summary}</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-faint">
<span className="chip !py-0">{T.category[c.category]}</span>
{c.status !== 'open' && <span data-change-state="">{T.status[c.status]}</span>}
{c.seen_count > 1 && <span className="tnum">{T.seen(c.seen_count)}</span>}
<span className="tnum" title={formatDateTime(c.last_seen_at)}>{T.detectedAt(formatRelative(c.last_seen_at))}</span>
</span>
</span>
</button>
</li>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Clock, TriangleAlert } from 'lucide-react'
import { api } from '../../api/client'
import type { SourceBackgroundJob, SourceJobKind } from '../../typesSource'
import { Spinner } from '../../components/ui'
import { JOB_STRIP_TEXT as T } from '../../lib/terms'
// ===========================================================================
// 目录页的后台任务条(next-round 05 (g)):这个数据源进行中和排队的探查、起草、剖析,每项一行(「起草 3/20」「剖析 排队中,
// 起草结束后开始」),带进度条和提醒(起草调用接连没写出来、剖析最近查询多数超时),右侧「查看」打开对应的对话框(探查到
// 数据源卡片上看)。进度只在这一处:页头的「起草」「数据剖析」只是入口(进行中转圈、排队中时钟,读屏写状态,不写数字)。
//
// 先后由服务端排好(GET /background-jobs:同源队里的位置,排在谁后面取各任务的 waiting_for),这里不自己排。起草、剖析的
// 数取页面跟着的那个任务(每 1.5 秒取一次,比这条队列新);页面刚建的任务队列还没取到时先接在后面,下一次取回来就按服务端的。
// ===========================================================================
/** 有任务在排队、进行中时多久取一次队列 */
export const STRIP_POLL_MS = 1500
/** 这一行要画的:服务端的那一项,起草、剖析换上页面跟着的任务的数和提醒 */
export interface StripItem extends SourceBackgroundJob {
notice?: string | null
}
const isActive = (s: string) => s === 'queued' || s === 'running'
/**
* 取这个源的后台任务队列:载入时取一次;有排队、进行中的任务时每 STRIP_POLL_MS 取一次;页面上的起草、剖析任务换了状态
* (kick 变了)马上取一次
*/
export function useSourceJobs(sourceId: string, kick: string): { items: SourceBackgroundJob[]; refresh: () => void } {
const [held, setHeld] = useState<{ src: string; items: SourceBackgroundJob[] } | null>(null)
const items = held?.src === sourceId ? held.items : []
const seq = useRef(0)
const [tick, setTick] = useState(0)
const refresh = useCallback(() => setTick((n) => n + 1), [])
useEffect(() => {
const mine = ++seq.current
const src = sourceId
api.sourceChanges.backgroundJobs(src).then((r) => {
if (mine === seq.current) setHeld({ src, items: r.items })
}, () => { /* 取不到这一次:下一拍再取(断网时页面顶上有横幅) */ })
}, [sourceId, kick, tick])
const active = items.some((i) => isActive(i.status))
useEffect(() => {
if (!active) return
const t = setTimeout(refresh, STRIP_POLL_MS)
return () => clearTimeout(t)
}, [active, items, refresh])
return { items, refresh }
}
/**
* 服务端的队列和页面跟着的起草、剖析任务合起来:同一个任务换上页面的数和提醒;页面上进行中、队列里还没有的(刚建的)
* 接在后面;队列里已经结束的不画
*/
export function stripItems(server: SourceBackgroundJob[], live: Partial<Record<SourceJobKind, StripItem | null>>): StripItem[] {
const out: StripItem[] = []
const seen = new Set<string>()
for (const s of server) {
const mine = live[s.kind]
const item: StripItem = mine && mine.id === s.id ? { ...s, status: mine.status, done: mine.done, total: mine.total, notice: mine.notice } : s
seen.add(s.id)
if (isActive(item.status)) out.push(item)
}
for (const kind of ['introspect', 'draft', 'profile'] as SourceJobKind[]) {
const mine = live[kind]
if (mine && !seen.has(mine.id) && isActive(mine.status)) out.push(mine)
}
return out
}
export function JobStrip({ items, onOpen }: { items: StripItem[]; onOpen: (item: StripItem) => void }) {
if (!items.length) return null
return (
<ul className="shrink-0 divide-y divide-[var(--hairline)] border-b text-xs" aria-label={T.label} data-catalog-job-strip={items.map((i) => i.kind).join(',')}
style={{ background: 'color-mix(in srgb, var(--accent) 6%, var(--bg-panel))' }}>
{items.map((item) => <JobRow key={item.id} item={item} onOpen={onOpen} />)}
</ul>
)
}
function JobRow({ item, onOpen }: { item: StripItem; onOpen: (item: StripItem) => void }) {
const kind = T.kind[item.kind]
const queued = item.status === 'queued'
const behind = queued && item.waiting_for ? T.behind[item.waiting_for] ?? null : null
const text = queued ? (behind ? T.queuedBehind(kind, behind) : T.queued(kind)) : T.running(kind, item.done, item.total)
const notice = item.notice?.trim() || null
return (
<li className="flex items-center gap-2 px-4 py-2" data-catalog-job={item.kind} data-catalog-job-status={item.status}
data-progress={`${item.done}/${item.total}`}>
{queued ? <Clock size={12} className="shrink-0 text-faint" aria-hidden /> : <Spinner size={12} />}
<div className="min-w-0 flex-1">
<p className="tnum break-words font-medium" data-catalog-job-text="">{text}</p>
{notice && (
// 提醒写全文、可以折行:窄屏、触屏上没有悬停
<p className="mt-0.5 flex min-w-0 items-start gap-1 text-2xs leading-relaxed text-[var(--warn)]" role="status" data-catalog-job-notice="">
<TriangleAlert size={10} className="mt-0.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{notice}</span>
</p>
)}
<div className="mt-1 h-1 max-w-xs overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={T.progress(kind, item.done, item.total)}
aria-valuemin={0} aria-valuemax={item.total} aria-valuenow={item.done}>
<div className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-300"
style={{ width: `${item.total ? (item.done / item.total) * 100 : 0}%` }} />
</div>
</div>
<button type="button" className="btn btn-sm shrink-0" onClick={() => onOpen(item)} title={T.viewHint[item.kind]} data-catalog-job-open="">
{T.view}
</button>
</li>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { api } from '../../api/client'
import type { ObservationPoint, ObservationSeries, SourceChange } from '../../typesSource'
import { ErrorState, Skeleton } from '../../components/ui'
import { formatDateTime, formatRelative } from '../../lib/format'
import { SOURCE_CHANGE_TEXT as CT, TABLE_CHANGES_TEXT as T } from '../../lib/terms'
import { SEVERITY_MARK } from './ChangesQueue'
// ===========================================================================
// 表详情的「结构与取值变化」一节(next-round 03 B2、B3):这张表测到的变化(开着的在前,点「去处理」到「变化」视图里那一条),
// 撤销过「已列全」的照实说;最近几次取值观察(行数、码值分布、最新业务日期、按日行数),每次写观察的口径(整表、业务日期
// 窗口、块采样、按存储顺序的前 N 行),口径换过的说一句「口径不同的不互相比较」。数都是服务端给的(占比、合计),这里只照写;
// 现在被遮罩的列服务端不给。
// ===========================================================================
/** 这张表开着的变化最多列几条(多的在「变化」视图里看) */
const PENDING_SHOWN = 8
/** 处理过的最多列几条 */
const HANDLED_SHOWN = 5
export function TableChanges({ sourceId, table, refreshKey, onOpenChange }: {
sourceId: string
table: string
/** 目录版本:处理过一条、目录变了时重取 */
refreshKey: number
onOpenChange: (id: string) => void
}) {
const [data, setData] = useState<{ pending: SourceChange[]; pendingTotal: number; handled: SourceChange[]; series: ObservationSeries[] } | null>(null)
const [error, setError] = useState<unknown>(null)
const [seq, setSeq] = useState(0)
useEffect(() => {
let alive = true
setError(null)
Promise.all([
api.sourceChanges.list(sourceId, { status: 'pending', table, limit: PENDING_SHOWN }),
api.sourceChanges.list(sourceId, { status: 'handled', table, limit: HANDLED_SHOWN }),
api.sourceChanges.observations(sourceId, table),
]).then(([pending, handled, obs]) => {
if (!alive) return
setData({ pending: pending.items, pendingTotal: pending.total, handled: handled.items, series: obs.series })
}, (e) => { if (alive) setError(e) })
return () => { alive = false }
}, [sourceId, table, refreshKey, seq])
// 撤销过「已列全」的:取值观察测到目录之外的取值时,机器唯一会改的目录事实(目录-D6)
const revoked = data ? [...data.pending, ...data.handled].filter((c) => c.kind === 'codes_new' && c.after?.complete_revoked === true) : []
return (
<section aria-labelledby="catalog-sec-changes" data-catalog-section="changes">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<h3 id="catalog-sec-changes" className="text-xs font-semibold">{T.title}</h3>
<span className="text-2xs text-faint">{T.hint}</span>
</div>
<div className="space-y-3 rounded-lg border bg-panel px-3 py-2.5" data-table-changes={data ? `${data.pendingTotal},${data.handled.length}` : 'loading'}>
{error
? <ErrorState error={error} onRetry={() => setSeq((n) => n + 1)} compact />
: !data
? <Skeleton rows={3} height={14} gap={8} />
: (
<>
<div>
<h4 className="text-2xs font-medium text-dim">{T.changesHead}</h4>
{!data.pending.length && !data.handled.length
? <p className="mt-1 text-xs text-faint" data-table-changes-none="">{T.changesNone}</p>
: (
<ul className="mt-1 space-y-1">
{[...data.pending, ...data.handled].map((c) => <ChangeLine key={c.id} change={c} onOpen={onOpenChange} />)}
</ul>
)}
{data.pendingTotal > data.pending.length && (
<p className="mt-1 text-2xs text-faint">{T.changesMore(data.pendingTotal - data.pending.length)}</p>
)}
</div>
{revoked.length > 0 && (
<div data-table-revoked={revoked.length}>
<h4 className="text-2xs font-medium text-dim">{T.revokedHead}</h4>
<ul className="mt-1 space-y-0.5">
{revoked.map((c) => (
<li key={c.id} className="text-xs text-[var(--warn)]">
{T.revoked(c.column ?? '', formatDateTime(c.detected_at), c.after?.catalog_version ?? null)}
</li>
))}
</ul>
</div>
)}
<div>
<h4 className="text-2xs font-medium text-dim">{T.observeHead}</h4>
{!data.series.length
? <p className="mt-1 text-xs text-faint" data-table-observe-none="">{T.observeNone}</p>
: <div className="mt-1 divide-y divide-[var(--hairline)]">{data.series.map((s) => <SeriesRow key={`${s.kind}:${s.column}`} series={s} />)}</div>}
</div>
</>
)}
</div>
</section>
)
}
function ChangeLine({ change: c, onOpen }: { change: SourceChange; onOpen: (id: string) => void }) {
const mark = SEVERITY_MARK[c.severity]
const pending = c.status === 'open' || c.status === 'acknowledged'
return (
<li className="flex items-start gap-2 text-xs" data-table-change={c.id} data-table-change-status={c.status}>
<mark.Icon size={11} className="mt-0.5 shrink-0" style={{ color: mark.color }} aria-hidden />
<span className="min-w-0 flex-1">
<span className="sr-only">{CT.severity[c.severity]}:</span>
<span className={clsx('break-words', !pending && 'text-dim')}>{c.summary}</span>
<span className="ml-1.5 text-2xs text-faint">{CT.category[c.category]} · {CT.status[c.status]} · {formatRelative(c.last_seen_at)}</span>
</span>
<button type="button" className="btn btn-xs shrink-0" onClick={() => onOpen(c.id)} data-table-change-open="">
{pending ? T.openChange : T.viewChange}
</button>
</li>
)
}
/** 一种观察(行数、某一列的码值分布、最新业务日期、按日行数):最近一次的结论、口径、什么时候,再往前几次收在下面 */
function SeriesRow({ series: s }: { series: ObservationSeries }) {
const [latest, ...older] = s.points
if (!latest) return null
return (
<div className="py-1.5" data-table-observe={`${s.kind}:${s.column}`} data-observe-basis-changed={s.basis_changed ? '1' : '0'}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
<span className="font-medium">{T.observeKind[s.kind]}</span>
{s.column && <span className="mono text-2xs text-faint">{s.column}</span>}
<span className="min-w-0 flex-1 break-words text-dim" data-observe-value="">
<PointValue kind={s.kind} point={latest} />
</span>
</div>
<p className="mt-0.5 text-2xs text-faint">
<span data-observe-basis="">{T.basis(latest.basis_text)}</span>
{latest.at && <span title={formatDateTime(latest.at)}> · {T.at(formatRelative(latest.at))}</span>}
</p>
{s.basis_changed && <p className="mt-0.5 text-2xs text-[var(--warn)]" data-observe-basis-note="">{T.basisChanged}</p>}
{s.kind === 'daily_volume' && <DailyBars point={latest} />}
{older.length > 0 && (
<details className="mt-1 text-2xs text-faint">
<summary className="cursor-pointer select-none">{T.history(older.length)}</summary>
<ul className="mt-1 space-y-0.5">
{older.map((p, i) => (
<li key={i} className="flex flex-wrap gap-x-2" data-observe-history="">
<span className="tnum">{p.at ? formatDateTime(p.at) : '—'}</span>
<span className="text-dim"><PointValue kind={s.kind} point={p} /></span>
<span>{T.basis(p.basis_text)}</span>
</li>
))}
</ul>
</details>
)}
</div>
)
}
function PointValue({ kind, point }: { kind: ObservationSeries['kind']; point: ObservationPoint }) {
const d = point.data ?? {}
if (kind === 'rows') return <span className="tnum">{T.rows(d.rows ?? null, !!d.at_least)}</span>
if (kind === 'freshness') return <span className="tnum">{T.freshness(d.max ?? null)}</span>
if (kind === 'daily_volume') {
return <span className="tnum">{T.daily(d.days_with_data ?? 0, d.total ?? 0)}{d.since && d.until ? `(${T.dailyRange(d.since, d.until)})` : ''}</span>
}
const values: [string, number, number][] = d.values ?? []
return (
<span>
<span className="tnum">{T.codesTop(values.length, d.rows ?? 0)}</span>
{values.length > 0 && <span className="ml-1.5 mono">{values.slice(0, 6).map(([code, , share]) => T.code(code, share)).join(' · ')}</span>}
{d.truncated && <span className="ml-1.5 text-[var(--warn)]">{T.codesTruncated}</span>}
</span>
)
}
/** 按日行数的小柱图:只给眼睛看,数字写在旁边那句和读屏名称里 */
function DailyBars({ point }: { point: ObservationPoint }) {
const days: [string, number][] = point.data?.days ?? []
if (!days.length) return null
const max = Math.max(...days.map(([, n]) => n), 1)
const d = point.data ?? {}
return (
<div className="mt-1 flex h-6 items-end gap-px" role="img" data-observe-bars={days.length}
aria-label={T.dailyChart(d.since ?? days[0][0], d.until ?? days[days.length - 1][0], d.days_with_data ?? 0, d.total ?? 0)}>
{days.map(([day, n]) => (
<span key={day} className="min-w-[2px] flex-1 rounded-t-sm" title={`${day}:${n}`}
style={{ height: `${Math.max(n ? 8 : 0, (n / max) * 100)}%`, background: n ? 'var(--accent)' : 'var(--border-strong)' }} />
))}
</div>
)
}
+8 -1
View File
@@ -34,6 +34,7 @@ import { isProfileBusy } from './profileJob'
import { PROFILE_TASK_ICON, ProfileTaskBody } from './ProfileDialog'
import { TableAssistant } from './TableAssistant'
import { TableUsageList } from './TableUsage'
import { TableChanges } from './TableChanges'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
@@ -49,6 +50,8 @@ import type { CodesItem, ColumnField, EditForm, RelationDraft, TableField, Table
//
// 「数据剖析」一节:这张表上次剖析的时间、发起人、所属任务、行数估算和来源、日期范围、发现、跳过的项、发过的查询;
// 「剖析这张表」建只含这张表的剖析任务(没开剖析时引到剖析设置;已有剖析任务时写明它到哪了、这张表在不在里面)。
// 「结构与取值变化」一节(TableChanges):这张表测到的变化(点开到「变化」视图里处理)、撤销过的「已列全」、最近几次取值观察
// 和各自的口径。
// ===========================================================================
const KINDS = Object.keys(CATALOG_KIND_LABEL) as CatalogTableKind[]
@@ -69,7 +72,7 @@ type Conflict = 'edit' | 'review'
export function TableDetail({
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
profileEnabled = false, focusPath = null, usageOpen = false, profile,
profileEnabled = false, focusPath = null, usageOpen = false, profile, onOpenChange,
}: {
sourceId: string
table: string
@@ -108,6 +111,8 @@ export function TableDetail({
usageOpen?: boolean
/** 数据剖析:页面跟着的剖析任务、建只含这张表的任务、打开剖析设置和剖析对话框 */
profile?: TableProfileProps
/** 「结构与取值变化」一节里点开一条变化:到数据目录页的「变化」视图里处理它。不给就不画这一节 */
onOpenChange?: (id: string) => void
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
@@ -637,6 +642,8 @@ export function TableDetail({
enabled={profileEnabled} profile={profile} onFillCodes={detail.in_schema ? fillCodesOf : undefined} />
)}
{onOpenChange && <TableChanges sourceId={sourceId} table={detail.table_name} refreshKey={detail.version} onOpenChange={onOpenChange} />}
<section aria-labelledby="catalog-sec-impact" data-catalog-section="impact">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<h3 id="catalog-sec-impact" className="text-xs font-semibold outline-none" tabIndex={-1}>{CATALOG_IMPACT_TEXT.title}</h3>
+72
View File
@@ -0,0 +1,72 @@
import type { SourceChange, SourceChangeCategory, SourceChangeSeverity, SourceChangeStatusFilter } from '../../typesSource'
// ===========================================================================
// 数据目录页「变化」视图的纯函数:地址里的筛选和打开的那一条、按严重程度分组。界面在 ChangesQueue.tsx、ChangeDetail.tsx。
//
// 顺序由服务端排好(开着的按「使用次数权重 × 严重程度」,处理过的最近测到的在前);这里只按服务端给的 severity 分到三组里,
// 组内照原样,不另排。能做哪些动作、依据什么都在服务端给的变化上(actions、basis_text),这里不判。
// ===========================================================================
/** 要处理、提醒、说明:分组的先后 */
export const SEVERITY_ORDER: readonly SourceChangeSeverity[] = ['action', 'warning', 'info']
export const STATUS_FILTERS: readonly SourceChangeStatusFilter[] = ['pending', 'handled']
export const CATEGORY_FILTERS: readonly ('all' | SourceChangeCategory)[] = ['all', 'struct', 'value']
/** 一页几条(服务端一页最多 200) */
export const CHANGES_PAGE = 100
/**
* 「变化」视图记在地址的查询参数里:view=changes;cstatus 状态筛选(开着的不写);ccat 类别(全部不写);change 打开的那一条。
* 和清单、审阅队列的参数互不相干,各自只动自己的键
*/
export interface ChangesParams {
status: SourceChangeStatusFilter
category: SourceChangeCategory | null
change: string
}
export const isChangesView = (p: URLSearchParams): boolean => p.get('view') === 'changes'
export function changesParamsOf(p: URLSearchParams): ChangesParams {
const cat = p.get('ccat')
return {
status: p.get('cstatus') === 'handled' ? 'handled' : 'pending',
category: cat === 'struct' || cat === 'value' ? cat : null,
change: p.get('change') ?? '',
}
}
/** 写「变化」视图的参数。open:切到「变化」视图(view=changes);切走时由调用方用 writeReviewParams 换 view,再 clearChanges */
export function writeChangesParams(p: URLSearchParams, s: Partial<ChangesParams> & { open?: boolean }): URLSearchParams {
const next = new URLSearchParams(p)
const put = (k: string, v: string | null | undefined) => (v ? next.set(k, v) : next.delete(k))
if (s.open) {
next.set('view', 'changes')
next.delete('item')
}
if ('status' in s) put('cstatus', s.status === 'handled' ? 'handled' : null)
if ('category' in s) put('ccat', s.category)
if ('change' in s) put('change', s.change)
return next
}
/** 离开「变化」视图:它的几个参数都去掉 */
export function clearChanges(p: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(p)
for (const k of ['cstatus', 'ccat', 'change']) next.delete(k)
return next
}
/** 按服务端给的严重程度分组(要处理、提醒、说明),组内照服务端的顺序;空组不出 */
export function groupBySeverity(items: readonly SourceChange[]): { severity: SourceChangeSeverity; items: SourceChange[] }[] {
return SEVERITY_ORDER
.map((severity) => ({ severity, items: items.filter((c) => c.severity === severity) }))
.filter((g) => g.items.length > 0)
}
/** 处理动作落到了目录上(清理、加入码值):哪几张表的目录变了,页面据此重取清单、重新载入正在看的表 */
export function writtenTables(change: SourceChange, versions?: Record<string, number> | null): string[] {
const out = new Set<string>()
if (change.table && change.resolution?.catalog_version != null) out.add(change.table)
for (const t of Object.keys(versions ?? change.resolution?.catalog_versions ?? {})) out.add(t)
return [...out]
}
+232
View File
@@ -0,0 +1,232 @@
/**
* 数据源的变化、取值观察、定时维护、后台任务条的接口约定(docs/design/next-round/03-数据接入与目录维护.md B2–B4、05 (g))。
* 和后端 app/api/source_changes.py、app/data/source_changes.py(agentlab-change/1)、app/data/catalog_profile_observe.py 一一对应。
*
* 能不能做、依据什么、现在怎样都由服务端给(actions、basis_text、affects.catalog[*].now),界面只照写,不自己判。
*/
import type { Schedule, ScheduleFire } from './types'
// ---------------------------------------------------------------- 变化记录 agentlab-change/1
export type SourceChangeSeverity = 'info' | 'warning' | 'action'
export type SourceChangeStatus = 'open' | 'acknowledged' | 'resolved' | 'dismissed'
/** 结构变化 / 取值变化(叫法分开,和已有的「目录有变化」也分开,总方案 X10) */
export type SourceChangeCategory = 'struct' | 'value'
/** 清理目录项、加入码值、重新起草、重新剖析、知道了、误报(必须写理由) */
export type SourceChangeAction = 'clean_catalog' | 'add_codes' | 'redraft' | 'reprofile' | 'acknowledge' | 'dismiss'
/** 列表的状态筛选:开着的(含知道了还没处理的)、处理过的(解决了的、标为误报的) */
export type SourceChangeStatusFilter = 'pending' | 'handled'
/** 影响面里说到这些列的一个目录项(测到时的状态),now 是它现在的状态:清理过、不在目录里了为 null */
export interface SourceChangeCatalogRef {
table: string
path: string
label: string
status: string | null
source: string | null
/** 人工确认过的项还说着被删的列 */
needs_action?: boolean
now: { status: string | null; source: string | null } | null
}
export interface SourceChangeTemplateNode {
node_id: string
label: string | null
type: string
impact: 'direct' | 'possible'
/** 这条写死的 SQL 现在会报列不存在 */
breaks: boolean
unknown_columns?: string[]
}
/** 已发布模板里用到这张表的一个 */
export interface SourceChangeTemplate {
workflow_id: string
name: string
version: number
level: string
impact: 'direct' | 'possible'
nodes: SourceChangeTemplateNode[]
}
export interface SourceChangeAffects {
catalog: SourceChangeCatalogRef[]
templates: SourceChangeTemplate[]
/** 有效记录条件用到了这一列 */
rules_touched: boolean
}
/** 现在能做的一个处理动作;ok 为假时 reason 写为什么做不了 */
export interface SourceChangeActionOption {
action: SourceChangeAction
ok: boolean
reason: string | null
}
export interface SourceChangeResolution {
by: string | null
at: string
action: SourceChangeAction
note: string | null
catalog_version?: number
/** 别的表(指向它的关联关系)也动了的,各表的目录版本 */
catalog_versions?: Record<string, number>
job_id?: string
}
/** 谁、经什么测到的 */
export interface SourceChangeDetected {
job_kind: 'introspect' | 'profile' | 'on_demand' | string
job_id: string | null
task_seq: number | null
trigger: 'manual' | 'schedule' | 'on_demand' | string
actor: string | null
fire_at?: string
}
export interface SourceChange {
schema: string
seq: number
id: string
source_id: string
source_name: string
table: string | null
column: string | null
kind: string
category: SourceChangeCategory
severity: SourceChangeSeverity
/** 服务端给人看的一句(「orders.amount:删除了列」) */
summary: string
detected_at: string
last_seen_at: string
seen_count: number
detected_by: SourceChangeDetected
basis: { method?: string; key?: string; query_refs?: number[] } | null
/** 取值变化依据的口径(整表 / 窗口 / 块采样 / 前 N 行……);结构变化为 null(依据是前后两版结构) */
basis_text: string | null
before: any
after: any
affects: SourceChangeAffects
struct_version_before: number | null
struct_version_after: number | null
status: SourceChangeStatus
resolution: SourceChangeResolution | null
/** 现在能做的处理动作(处理过的为空) */
actions: SourceChangeActionOption[]
/** 处理时建的任务(重新起草、重新剖析) */
job: { kind: 'draft' | 'profile'; id: string } | null
}
export interface SourceChangeCounts {
/** 开着的和知道了还没处理的 */
pending: number
open: number
by_severity: Record<SourceChangeSeverity, number>
}
export interface SourceChangeList {
items: SourceChange[]
total: number
counts: SourceChangeCounts
}
export interface SourceChangeQuery {
status?: SourceChangeStatusFilter
category?: SourceChangeCategory | null
table?: string | null
limit?: number
offset?: number
}
export interface SourceChangeResolveBody {
action: SourceChangeAction
/** 误报必须写 */
note?: string
/** 加入码值:{码值: 含义},含义可以空 */
codes?: Record<string, string>
}
export interface SourceChangeResolveOut {
change: SourceChange
catalog_version?: number
catalog_versions?: Record<string, number>
job?: { kind: 'draft' | 'profile'; id: string }
}
// ---------------------------------------------------------------- 取值观察
export type ObservationKind = 'rows' | 'codes_observe' | 'freshness' | 'daily_volume'
/** 一次观察。data 的形状按种类:rows {rows, at_least, method}、codes_observe {values: [码值, 行数, 占比][], rows, truncated}、
* freshness {max}、daily_volume {since, until, days: [日期, 行数][], total, days_with_data} */
export interface ObservationPoint {
at: string | null
basis_key: string
/** 口径的说法(整表、业务日期 x 最近 35 天、块采样约 1%、按存储顺序取前 N 行……) */
basis_text: string
data: any
}
export interface ObservationSeries {
kind: ObservationKind
/** 行数是空串 */
column: string
/** 新的在前 */
points: ObservationPoint[]
/** 这几次的口径不全相同:口径不同不比 */
basis_changed: boolean
}
export interface TableObservations {
table: string
series: ObservationSeries[]
}
// ---------------------------------------------------------------- 定时维护
export type MaintenanceStep = 'introspect' | 'profile_needed' | 'observe'
/** 一次维护触发的 inputs:建了哪些任务、哪几步没执行和原因 */
export interface MaintenanceFireInputs {
steps?: MaintenanceStep[]
jobs?: { step: string; kind: 'introspect' | 'profile'; id: string; tables?: number; scope?: string }[]
skipped?: { step: MaintenanceStep; reason: string }[]
}
export interface MaintenanceSchedule extends Omit<Schedule, 'last_fire'> {
last_fire: (ScheduleFire & { inputs?: MaintenanceFireInputs }) | null
}
export interface SourceMaintenance {
steps: MaintenanceStep[]
profile_enabled: boolean
/** 上传表格、记录表不维护 */
maintained: boolean
/** 调度循环开着没有:关着时到点不会触发 */
scheduler_on: boolean
schedules: MaintenanceSchedule[]
}
/** 数据源列表一次取齐的:要处理的变化条数(角标)、指向它的维护定时 */
export interface SourceUpkeep {
source_id: string
changes: SourceChangeCounts
maintenance: { id: string; enabled: boolean; cron: string; timezone: string; description_text: string; next_run_at: string | null }[]
}
// ---------------------------------------------------------------- 后台任务条
export type SourceJobKind = 'introspect' | 'draft' | 'profile'
/** 这个源进行中和排队的一个后台任务,按同源队的先后排好 */
export interface SourceBackgroundJob {
kind: SourceJobKind
id: string
status: string
total: number
done: number
/** 排在谁后面(draft / profile / introspect / import / sync_draft) */
waiting_for: string | null
/** 在同源队里的位置(0 是正在做的);还没排进队为 null */
position: number | null
}