mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
数据剖析前端改成逐表的后台任务:对话框看得到每张表的进度,可以停止、重试没做完的表
以前剖析是一次同步长请求,进行中只有一条已用时长的横条、停不下来,关掉页面报告就没了;预算按整次剖析算,几十张表 往往只做完前两三张。服务端改成逐表的后台任务(profile-jobs)后,界面照起草任务的做法重做: - types、api:按接口约定加剖析任务、逐表行、逐表详情(检查清单、发现、发过的查询)、清单行上的最近一次剖析和 profile_due / profile_checks、目录顶上的 active_profile_job,起草任务加 waiting_for;删掉同步接口 profile() 和只给它用的 类型;设置换成每张表最多查询数、每张表时长上限两项新键 - profileJob.ts(新):useProfileJob,进行中每 1.5 秒取一次,状态只往前走,关掉对话框照常进行,结束时右下角提示 - ProfileDialog 重做:开始前四种范围和个数、按表写的预算和这次最多约几条查询、只读一条一条发可随时停止的说明,同源有 起草任务时「排队剖析」;进行中逐表一行(状态、各类检查进度、查询数、用时、正在做哪一项),点开看检查清单、发现和发过的 查询;结束后汇总、止损和停止的原因照实写、按有发现 / 有跳过 / 失败筛、重试没做完的表;409 六种情况各有说法和下一步 - 目录页头部起草、剖析各一个进度入口;表清单加「剖析」一格和「需要剖析」开关(地址里记 profile=due);表详情加 「数据剖析」一节和「剖析这张表」 - 起草排在剖析后面时写「排在剖析任务后面,剖析结束后自动开始」 - 文案:去掉「剖析无法中途停止」「总时长上限」等已不成立的说法,补上新的跳过原因和任务状态的叫法
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogDraftJob,
|
||||
CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut,
|
||||
CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileJob, CatalogProfileTaskDetail,
|
||||
CatalogReviewAction, CatalogReviewBatchItem, CatalogReviewBatchResult, CatalogReviewKind, CatalogReviewProgress, CatalogReviewQueue,
|
||||
CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview,
|
||||
EditRequest, ImportRecord,
|
||||
@@ -466,12 +466,44 @@ export const api = {
|
||||
putGuidance: (sourceId: string, guidance: string) =>
|
||||
put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }),
|
||||
/**
|
||||
* 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张
|
||||
* 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文)
|
||||
* 剖析任务(后台进行:逐表对业务库发只读查询,一条一条发,每张表一结束就写进目录)。剖析会对业务库发查询,默认关闭,
|
||||
* 按数据源开启。和同一个数据源的起草任务按建立顺序排队(起草在排队、进行中时剖析建成 queued、waiting_for 为 draft)
|
||||
*/
|
||||
profile: (sourceId: string, body: { tables?: string[] }, opts?: RequestOptions) =>
|
||||
request<CatalogProfileOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`,
|
||||
{ method: 'POST', body: JSON.stringify(body), ...opts }),
|
||||
profileJobs: {
|
||||
/**
|
||||
* 建任务:tables 非空时只剖析这些表(最多 500 张,按给的顺序,表结构里没有、视图、没有可剖析的项照样建进任务、记跳过),
|
||||
* 否则按 scope(缺省 needed:需要剖析的表;all:全部可剖析的表)由服务端挑。202。
|
||||
* 409 带机读码:profile_disabled、datasource_inactive、schema_missing、snapshot_tampered、profile_busy(这个源已有排队、
|
||||
* 进行中的剖析任务,detail 写明进行到第几张)、profile_nothing(按范围挑出 0 张表,detail 写明原因和建议)
|
||||
*/
|
||||
create: (sourceId: string, body: { tables?: string[]; scope?: 'needed' | 'all' }) =>
|
||||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs`, body),
|
||||
/** 排队或进行中的那个剖析任务;没有为 null */
|
||||
active: (sourceId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogProfileJob | null }>(`${catalogBase(sourceId)}/profile-jobs/active`, opts),
|
||||
/** 最近建的一个剖析任务(任何状态);没有为 null */
|
||||
latest: (sourceId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogProfileJob | null }>(`${catalogBase(sourceId)}/profile-jobs/latest`, opts),
|
||||
/** 一个剖析任务的当前状态;不属于这个数据源回 404 */
|
||||
get: (sourceId: string, jobId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}`, opts),
|
||||
/** 任务里一张表的完整结果:检查清单、发现、跳过的项、日期范围、发过的查询。找不到 404 */
|
||||
task: (sourceId: string, jobId: string, seq: number, opts?: RequestOptions) =>
|
||||
get<{ task: CatalogProfileTaskDetail }>(
|
||||
`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(String(seq))}`, opts),
|
||||
/**
|
||||
* 停止:排队的直接停;进行中的那条查询由数据库按单条时限截止,这张表已做完的项照常写进目录、没做的保留旧结论,
|
||||
* 后面的表记已停止。回停止之后的状态;已经结束的回 409(catalog_profile_finished)
|
||||
*/
|
||||
cancel: (sourceId: string, jobId: string) =>
|
||||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/cancel`),
|
||||
/**
|
||||
* 重试没做完的表:新任务只含原任务里失败、部分完成、已停止的表(按原顺序)。202;没有可重试的表 422;
|
||||
* 这个源已有排队、进行中的剖析任务 409(profile_busy)
|
||||
*/
|
||||
retry: (sourceId: string, jobId: string) =>
|
||||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/retry`),
|
||||
},
|
||||
// ---- 审阅队列(只列推断项,按表的使用次数、这一项对数字的影响和来源排好)
|
||||
/** 一页待确认项。kind:全部 / 影响数字的项 / 名称和含义;table 只看这一张表 */
|
||||
reviewQueue: (sourceId: string, params: { limit?: number; offset?: number; kind?: CatalogReviewKind; table?: string | null },
|
||||
@@ -512,7 +544,8 @@ export const api = {
|
||||
importPreview: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||||
post<CatalogImportReport>(`${catalogBase(sourceId)}/import/preview`, body),
|
||||
/**
|
||||
* 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy);
|
||||
* 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy),剖析任务时
|
||||
* 409(profile_busy);
|
||||
* 文件格式不对 422(detail 是一句中文)
|
||||
*/
|
||||
importCatalog: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||||
|
||||
+229
-74
@@ -2485,6 +2485,10 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
: total === 1 ? '表结构中已没有这张表,不能起草' : `表结构中已没有这 ${formatNumber(total)} 张表,不能起草`),
|
||||
// ---- 进行中
|
||||
queued: '排队中,等待开始…',
|
||||
/** 同一个数据源的起草和剖析按建立顺序排队:排在剖析任务后面 */
|
||||
waitingProfile: '排在剖析任务后面,剖析结束后自动开始',
|
||||
/** 设置阶段:这个数据源有剖析任务在排队、进行中,起草会排在它后面 */
|
||||
queueHint: '这个数据源有剖析任务在进行:起草会排在它后面,剖析结束后自动开始',
|
||||
running: '正在起草…',
|
||||
// 服务端点了停止就取消进行中的模型调用(不等它答完,一次调用可能要几分钟),只等手上正在写入目录的那张表写完
|
||||
// 宽表每写完一段就写进目录:停下来时已写进的段保留,只停掉进行中的调用
|
||||
@@ -2578,6 +2582,13 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
// ---- 目录页上方的进度、结束提示
|
||||
strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`,
|
||||
stripQueuedProfile: (total: number) => `起草任务排在剖析任务后面,剖析结束后自动开始(共 ${formatNumber(total)} 张表)`,
|
||||
/** 页头的进度入口:进行中写「起草 3/20」,排队写「起草 排队中」 */
|
||||
headerRunning: (done: number, total: number) => `起草 ${formatNumber(done)}/${formatNumber(total)}`,
|
||||
headerQueued: '起草 排队中',
|
||||
headerRunningHint: (done: number, total: number) => `起草进行中:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,点击查看进度`,
|
||||
headerQueuedHint: '起草任务排队中,点击查看',
|
||||
headerQueuedProfile: '起草任务排在剖析任务后面,剖析结束后自动开始,点击查看',
|
||||
stripOpen: '查看进度',
|
||||
toast: {
|
||||
succeeded: (summary: string) => `数据目录起草完成:${summary}`,
|
||||
@@ -2752,47 +2763,51 @@ export const SCHEMA_PARTIAL_TEXT = {
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」)
|
||||
// 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」:后台任务,逐表进行)
|
||||
//
|
||||
// 术语:
|
||||
// - 「数据剖析」:对业务库发少量只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。
|
||||
// - 「数据剖析」:对业务库发只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。
|
||||
// 动作写「剖析」,不写扫描、探测。默认关闭,按数据源开启。
|
||||
// - 预算五项:查询次数上限、单条查询时限、抽样键值数、整表统计行数上限、总时长上限。标签和服务端校验报错里的叫法
|
||||
// 一致(catalog_profile._NUMBER_FIELDS),保存被拒时按它认出是哪一项。
|
||||
// - 剖析是后台任务:逐张表进行,每张表剖析完就写进目录;查询一条一条地发,可以随时停止。关掉对话框照常进行。
|
||||
// - 预算按表算:「每张表最多查询数」「每张表时长上限」用完只停这张表,下一张表重新计数;另有单条查询时限、抽样键值数、
|
||||
// 整表统计行数上限。标签和服务端校验报错里的叫法一致(catalog_profile._NUMBER_FIELDS),保存被拒时按机读码认出是哪一项。
|
||||
// - 没有整次总量上限:任务自己在连续失败、多数超时时停下(止损),写明原因。
|
||||
// - 「没查成」≠ 结论错了:因为次数、时长、超时、出错没查成的项保留剖析以前的结论,可以「重试没做完的表」。
|
||||
// - 和起草同一套状态词:排队、进行中、完成、部分完成、失败、已停止、服务重启中断。同一个数据源的起草和剖析排队进行。
|
||||
// ===========================================================================
|
||||
|
||||
export const PROFILE_FIELD_LABEL: Record<'enabled' | CatalogProfileNumberKey, string> = {
|
||||
enabled: '开启数据剖析',
|
||||
max_queries: '查询次数上限',
|
||||
table_max_queries: '每张表最多查询数',
|
||||
table_max_s: '每张表时长上限',
|
||||
query_timeout_s: '单条查询时限',
|
||||
sample_size: '抽样键值数',
|
||||
max_scan_rows: '整表统计行数上限',
|
||||
max_total_s: '总时长上限',
|
||||
}
|
||||
|
||||
export const PROFILE_FIELD_UNIT: Record<CatalogProfileNumberKey, string> = {
|
||||
max_queries: '条',
|
||||
table_max_queries: '条',
|
||||
table_max_s: '秒',
|
||||
query_timeout_s: '秒',
|
||||
sample_size: '个',
|
||||
max_scan_rows: '行',
|
||||
max_total_s: '秒',
|
||||
}
|
||||
|
||||
export const PROFILE_FIELD_HINT: Record<CatalogProfileNumberKey, string> = {
|
||||
max_queries: '一次剖析最多发出的查询条数,用完即停止',
|
||||
query_timeout_s: '超过即中断这条查询;不超过数据源自身的查询时限',
|
||||
table_max_queries: '剖析一张表最多发出的查询条数,用完只停这张表,下一张表重新计数',
|
||||
table_max_s: '剖析一张表最多用的时长,到时只停这张表,下一张表重新计时',
|
||||
query_timeout_s: '超过即中断这条查询;不超过数据源自身的查询时限,也不超过这张表剩下的时长',
|
||||
sample_size: '核对一条关联关系时,从本表抽取的不同键值个数',
|
||||
max_scan_rows: '不超过此行数的表才做去重计数、最小值和最大值等整表统计',
|
||||
max_total_s: '一次剖析的总时长,到时即停止',
|
||||
}
|
||||
|
||||
export const PROFILE_TEXT = {
|
||||
// ---- 设置
|
||||
section: '数据剖析',
|
||||
enableHint: '默认关闭。开启后可在数据目录中核实推断的关联关系、取状态类列的码值候选',
|
||||
risk: (queries: number, timeout: number, total: number) =>
|
||||
`开启后,每次剖析会对这个数据源发出只读查询:最多 ${formatNumber(queries)} 条,单条不超过 ${formatNumber(timeout)} 秒,`
|
||||
+ `总时长不超过 ${formatNumber(total)} 秒。不读取明细行,遮罩的列不取样。`,
|
||||
risk: (queries: number, perTable: number, timeout: number) =>
|
||||
`开启后,剖析会对这个数据源发出只读查询,一条一条地发,可随时停止:每张表最多 ${formatNumber(queries)} 条、`
|
||||
+ `${formatNumber(perTable)} 秒,单条不超过 ${formatNumber(timeout)} 秒。不读取明细行,遮罩的列不取样。`,
|
||||
range: (min: number, max: number, unit: string, def: number) =>
|
||||
`范围 ${formatNumber(min)}–${formatNumber(max)} ${unit},默认 ${formatNumber(def)}`,
|
||||
scanZero: '填 0 表示一律不做整表统计',
|
||||
@@ -2802,70 +2817,160 @@ export const PROFILE_TEXT = {
|
||||
invalid: (n: number) => `数据剖析有 ${formatNumber(n)} 项设置不正确`,
|
||||
// ---- 数据源卡片
|
||||
chip: '数据剖析已开启',
|
||||
chipHint: (queries: number, timeout: number) =>
|
||||
`数据剖析已开启:每次最多 ${formatNumber(queries)} 条只读查询,单条不超过 ${formatNumber(timeout)} 秒`,
|
||||
chipHint: (queries: number, perTable: number, timeout: number) =>
|
||||
`数据剖析已开启:每张表最多 ${formatNumber(queries)} 条只读查询、${formatNumber(perTable)} 秒,单条不超过 ${formatNumber(timeout)} 秒`,
|
||||
open: '设置数据剖析',
|
||||
dialogTitle: (name: string) => `「${name}」的数据剖析设置`,
|
||||
dialogBody: '设置保存在数据源上,对数据目录中的每次剖析生效。',
|
||||
dialogBody: '设置保存在数据源上,对之后新建的剖析生效;进行中的剖析按开始时的设置。',
|
||||
savedOn: (name: string) => `「${name}」已开启数据剖析`,
|
||||
savedOff: (name: string) => `「${name}」已关闭数据剖析`,
|
||||
saved: (name: string) => `已保存「${name}」的数据剖析设置`,
|
||||
// ---- 数据目录页:范围与确认
|
||||
|
||||
// ---- 数据目录页:入口
|
||||
action: '数据剖析',
|
||||
actionHint: '对业务库发少量只读查询:核实推断的关联关系、取码值候选、提议业务日期',
|
||||
actionHint: '对业务库发只读查询:核实推断的关联关系、取码值候选、提议业务日期',
|
||||
profileSelected: (n: number) => `剖析所选(${formatNumber(n)})`,
|
||||
/** 页头的进度入口:进行中写「剖析 12/40」,排队写「剖析 排队中」 */
|
||||
headerRunning: (done: number, total: number) => `剖析 ${formatNumber(done)}/${formatNumber(total)}`,
|
||||
headerQueued: '剖析 排队中',
|
||||
headerRunningHint: (done: number, total: number) => `数据剖析进行中:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,点击查看进度`,
|
||||
headerQueuedHint: '剖析任务排队中,点击查看',
|
||||
headerQueuedDraft: '剖析任务排在起草任务后面,起草结束后自动开始,点击查看',
|
||||
|
||||
// ---- 开始前:范围、预算
|
||||
title: '数据剖析',
|
||||
intro: '对业务库发少量只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。'
|
||||
+ '结论写入数据目录;已确认、已驳回的项不会被改动。',
|
||||
intro: '逐张表对业务库发只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。'
|
||||
+ '每张表剖析完就写进数据目录;已确认、已驳回的项不会被改动。',
|
||||
scope: '剖析范围',
|
||||
scopeSelected: (n: number) => `已选的 ${formatNumber(n)} 张表`,
|
||||
scopeFiltered: (n: number) => `当前筛选结果中的 ${formatNumber(n)} 张表`,
|
||||
scopeDefault: '使用次数最多、有待核实关联关系的表',
|
||||
scopeDefaultHint: (n: number) => `由服务端挑选,至多 ${formatNumber(n)} 张`,
|
||||
scopeTooMany: (max: number) => `超过 ${formatNumber(max)} 张,请缩小范围`,
|
||||
budget: '本次预算',
|
||||
budgetQueries: (queries: number, timeout: number) =>
|
||||
`最多 ${formatNumber(queries)} 条只读查询,单条不超过 ${formatNumber(timeout)} 秒`,
|
||||
budgetTotal: (total: number) => `总时长不超过 ${formatNumber(total)} 秒,到时即停止`,
|
||||
scopeNeeded: (n: number) => `需要剖析的 ${formatNumber(n)} 张表`,
|
||||
scopeNeededHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系的表',
|
||||
scopeNeededNone: '没有需要剖析的表:都剖析过、上次都做完了,也没有新的待核实关联关系',
|
||||
scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`,
|
||||
scopeAllHint: '有可剖析项的表,不含视图',
|
||||
/** 已选、筛选结果里有剖析不了的表:不交给服务端,写明有几张 */
|
||||
scopeLeftOut: (n: number) => `另有 ${formatNumber(n)} 张不剖析(视图、表结构中已没有,或没有可剖析的项)`,
|
||||
scopeEmpty: '这个范围里没有可剖析的表',
|
||||
scopeTooMany: (max: number) => `一次最多剖析 ${formatNumber(max)} 张表,请缩小范围`,
|
||||
budget: '预算',
|
||||
budgetFrom: (name: string) => `按「${name}」的数据剖析设置`,
|
||||
budgetPerTable: (queries: number, secs: number) =>
|
||||
`每张表最多 ${formatNumber(queries)} 条查询、${formatNumber(secs)} 秒,用完只停这张表,下一张表重新计数`,
|
||||
budgetQuery: (secs: number) => `单条查询不超过 ${formatNumber(secs)} 秒`,
|
||||
budgetSample: (n: number) => `每条关联关系抽样 ${formatNumber(n)} 个键值`,
|
||||
budgetScan: (n: number) => (n > 0 ? `行数超过 ${formatNumber(n)} 的表不做整表统计` : '不做整表统计'),
|
||||
budgetFrom: (name: string) => `按「${name}」的数据剖析设置`,
|
||||
/** 上限的估算:表数 × 每张表的上限 */
|
||||
budgetTotal: (tables: number, queries: number, span: string) =>
|
||||
`这次 ${formatNumber(tables)} 张表,最多约 ${formatNumber(queries)} 条查询、${span}`,
|
||||
editSettings: '修改设置',
|
||||
noCancel: '剖析在服务端一次完成,开始后无法中途停止。',
|
||||
readOnly: '剖析会对业务库发只读查询,一条一条地发,可随时停止;停止时已做完的项照常写进目录,没做的保留原来的结论。',
|
||||
start: '开始剖析',
|
||||
again: '重新剖析',
|
||||
/** 同一个数据源有起草任务在排队、进行中:剖析排在它后面 */
|
||||
queue: '排队剖析',
|
||||
queueHint: '这个数据源有起草任务在进行:剖析会排在它后面,起草任务结束后自动开始',
|
||||
disabledTitle: '这个数据源未开启数据剖析',
|
||||
disabledBody: '剖析会对业务库发出只读查询,需要先在数据源设置中开启。',
|
||||
enable: '开启数据剖析',
|
||||
// ---- 进行中
|
||||
running: '正在剖析…',
|
||||
elapsed: (used: string, total: string) => `已用时 ${used}(总时长上限 ${total})`,
|
||||
runningHint: '剖析在服务端一次完成,无法中途停止。关闭此窗口不影响剖析,完成后会提示结果。',
|
||||
background: '在后台继续',
|
||||
headerRunning: (used: string) => `正在剖析 ${used}`,
|
||||
headerRunningHint: '数据剖析进行中,点击查看',
|
||||
leavePage: '离开本页不会中止剖析,结论仍会写入数据目录,但不再显示本次的报告。',
|
||||
// ---- 报告
|
||||
done: '剖析完成',
|
||||
stoppedTitle: (reason: string) => `剖析已停止:${reason}`,
|
||||
summary: (used: number, max: number, tables: number) =>
|
||||
`用了 ${formatNumber(used)} / ${formatNumber(max)} 条查询,剖析 ${formatNumber(tables)} 张表`,
|
||||
/** 上一次剖析(没做完的才提),可以点开 */
|
||||
last: (state: string, at: string) => `上一次剖析(${at}):${state}`,
|
||||
lastOpen: '查看',
|
||||
startFailed: (reason: string) => `未能开始剖析:${reason}`,
|
||||
|
||||
// ---- 进行中、结束:任务
|
||||
jobTitle: {
|
||||
queued: '排队中,等待开始…',
|
||||
running: '正在剖析…',
|
||||
succeeded: '剖析完成',
|
||||
partial: '剖析完成,部分表没做完',
|
||||
failed: '剖析失败',
|
||||
cancelled: '已停止',
|
||||
interrupted: '服务重启,剖析中断',
|
||||
} as Record<CatalogDraftJobStatus, string>,
|
||||
waitingDraft: '排在起草任务后面,起草结束后自动开始',
|
||||
// 停止时正在执行的那条查询由数据库按单条时限截止,不是立刻停
|
||||
stopping: '正在停止:等正在执行的那条查询结束(不超过单条查询时限),已做完的项照常写进目录…',
|
||||
progress: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
queriesSent: (n: number) => `已发 ${formatNumber(n)} 条查询`,
|
||||
queriesBad: (timeouts: number, errors: number) => [
|
||||
timeouts ? `超时 ${formatNumber(timeouts)} 条` : '', errors ? `失败 ${formatNumber(errors)} 条` : '',
|
||||
].filter(Boolean).join('、'),
|
||||
elapsed: (span: string) => `用时 ${span}`,
|
||||
hide: '在后台继续',
|
||||
hideHint: '关闭对话框不会停止剖析,页头显示进度;每张表剖析完就写进目录',
|
||||
stop: '停止',
|
||||
busyShown: '这个数据源已有剖析任务在进行,下面显示的是它的进度',
|
||||
createdBy: (who: string) => `由「${who}」发起`,
|
||||
/** 任务级止损 */
|
||||
stopTitle: { failed: '连续多条查询失败,剖析已停止', slow: '数据库响应慢,剖析已停止' } as Record<string, string>,
|
||||
stopShort: { failed: '连续多条查询失败', slow: '数据库响应慢' } as Record<string, string>,
|
||||
stopNext: {
|
||||
failed: '后面的表没有剖析。请先在数据源卡片上测试连接、确认账号有只读权限,再重试没做完的表。',
|
||||
slow: '后面的表没有剖析。可以在业务库不忙的时候重试没做完的表,或在剖析设置中调低单条查询时限、每张表最多查询数。',
|
||||
} as Record<string, string>,
|
||||
cancelledNote: '做完的项已写进数据目录,没做的项保留原来的结论;后面的表没有剖析,可以重试没做完的表。',
|
||||
interruptedNote: '服务重启时这次剖析还没有完成:做完的项已写进数据目录,没做的项保留原来的结论。不会自动续上,可以重试没做完的表。',
|
||||
/** 结束后的汇总:「40 张表:完成 33,部分完成 4,跳过 2,失败 1」 */
|
||||
counts: (total: number, c: { ok: number; partial: number; skipped: number; failed: number; cancelled: number }) =>
|
||||
`${formatNumber(total)} 张表:${[
|
||||
`完成 ${formatNumber(c.ok)}`,
|
||||
c.partial ? `部分完成 ${formatNumber(c.partial)}` : '',
|
||||
c.skipped ? `跳过 ${formatNumber(c.skipped)}` : '',
|
||||
c.failed ? `失败 ${formatNumber(c.failed)}` : '',
|
||||
c.cancelled ? `已停止 ${formatNumber(c.cancelled)}` : '',
|
||||
].filter(Boolean).join(',')}`,
|
||||
changes: (added: number, updated: number, removed: number) =>
|
||||
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
|
||||
`目录新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
|
||||
noChange: '目录没有变化',
|
||||
toastDone: (summary: string) => `数据剖析完成:${summary}`,
|
||||
toastStopped: (reason: string) => `数据剖析已停止:${reason}`,
|
||||
toastFailed: '数据剖析未能完成',
|
||||
viewReport: '查看报告',
|
||||
/**
|
||||
* 发现的合计(任务、逐表共用):核实了几条关系、几条仍为推断,取到几列码值(几个含义待填写),提议了业务日期。
|
||||
* 一项都没有为空串
|
||||
*/
|
||||
findings: (s: { relations_checked: number; relations_verified: number; codes: number; codes_pending: number; business_dates: number },
|
||||
perTable = false) => [
|
||||
s.relations_verified ? `核实 ${formatNumber(s.relations_verified)} 条关系` : '',
|
||||
s.relations_checked > s.relations_verified ? `${formatNumber(s.relations_checked - s.relations_verified)} 条关系仍为推断` : '',
|
||||
s.codes ? `取到 ${formatNumber(s.codes)} 列码值候选${s.codes_pending ? `(${formatNumber(s.codes_pending)} 个含义待填写)` : ''}` : '',
|
||||
s.business_dates ? (perTable ? '提议业务日期' : `提议 ${formatNumber(s.business_dates)} 张表的业务日期`) : '',
|
||||
].filter(Boolean).join(','),
|
||||
noFindings: '没有新的发现',
|
||||
filterLabel: '筛选逐表结果',
|
||||
filter: { all: '全部', findings: '有发现', skipped: '有跳过', failed: '失败' } as Record<string, string>,
|
||||
filterNone: '没有符合的表',
|
||||
retry: (n: number) => `重试没做完的表(${formatNumber(n)} 张)`,
|
||||
retryHint: '新建一次剖析,只含失败、部分完成和已停止的表;已写进目录的结论不受影响',
|
||||
again: '重新选择范围',
|
||||
close: '完成',
|
||||
tasks: '逐表进度',
|
||||
|
||||
// ---- 逐表一行
|
||||
taskStatus: {
|
||||
pending: '排队', running: '剖析中', ok: '完成', partial: '部分完成', failed: '失败', skipped: '跳过', cancelled: '已停止',
|
||||
} as Record<CatalogDraftTaskStatus, string>,
|
||||
checkKind: { relation: '关系', codes: '码值', date: '日期' } as Record<string, string>,
|
||||
kindProgress: (label: string, done: number, total: number) => `${label} ${formatNumber(done)}/${formatNumber(total)}`,
|
||||
tableQueries: (n: number) => `${formatNumber(n)} 条查询`,
|
||||
/** 这张表的查询次数用完了(等于每张表最多查询数) */
|
||||
tableQueriesUsedUp: (n: number) => `${formatNumber(n)} 条查询(已用完)`,
|
||||
unfinished: (n: number) => `${formatNumber(n)} 项没查成`,
|
||||
skippedN: (n: number) => `跳过 ${formatNumber(n)} 项`,
|
||||
expandLabel: (table: string) => `查看 ${table} 的检查清单和发过的查询`,
|
||||
|
||||
// ---- 逐表展开、表详情:检查清单、发现、发过的查询
|
||||
loading: '正在载入…',
|
||||
loadFailed: (reason: string) => `未能载入这张表的剖析结果:${reason}`,
|
||||
checksTitle: (done: number, total: number) => `检查清单(${formatNumber(done)} / ${formatNumber(total)} 项)`,
|
||||
checkState: { pending: '等待', running: '进行中', done: '完成', skipped: '跳过' } as Record<string, string>,
|
||||
checkQueries: (n: number) => `${formatNumber(n)} 条查询`,
|
||||
rowsTitle: '行数',
|
||||
rowsStats: (n: number) => `约 ${formatNumber(n)} 行`,
|
||||
rowsCount: (n: number) => `${formatNumber(n)} 行`,
|
||||
rowsAtLeast: (n: number) => `超过 ${formatNumber(n - 1)} 行`,
|
||||
rowsUnknown: '没能得到行数',
|
||||
rowsHint: { stats: '数据库统计信息中的估算值', count: '计数到整表统计行数上限为止' } as Record<string, string>,
|
||||
rowsSource: { stats: '来自数据库统计信息', count: '数到整表统计行数上限为止' } as Record<string, string>,
|
||||
openTable: '打开',
|
||||
openTableLabel: (name: string) => `打开「${name}」`,
|
||||
noFindings: '没有新的发现',
|
||||
findingRelations: '关联关系',
|
||||
findingCodes: '码值候选',
|
||||
findingDate: '业务日期',
|
||||
@@ -2887,52 +2992,101 @@ export const PROFILE_TEXT = {
|
||||
dateRanges: '日期列的取值范围',
|
||||
skippedTitle: (n: number) => `跳过 ${formatNumber(n)} 项`,
|
||||
moreSkipped: (n: number) => `…另有 ${formatNumber(n)} 项`,
|
||||
tableError: '这张表未能剖析',
|
||||
// ---- 无法开始(409)
|
||||
queriesTitle: (n: number) => `看发过的查询(${formatNumber(n)} 条)`,
|
||||
queriesHint: 'SQL 里的取值都换成了「?」:抽样到的键值不保存在 AgentLab 中;遮罩的列不出现在任何查询里',
|
||||
queryKind: { row_estimate: '行数估算', relation: '关系', codes: '码值', date: '日期' } as Record<string, string>,
|
||||
queryOutcome: { ok: '成功', timeout: '超时', error: '失败', rejected: '未通过只读守卫' } as Record<string, string>,
|
||||
queryRows: (n: number) => `${formatNumber(n)} 行`,
|
||||
queryMs: (ms: number) => `${formatNumber(ms)} 毫秒`,
|
||||
|
||||
// ---- 结束时的提示(页面右下角,对话框关着时)
|
||||
toast: {
|
||||
succeeded: (text: string) => (text ? `数据剖析完成:${text}` : '数据剖析完成'),
|
||||
partial: (n: number) => `数据剖析完成,${formatNumber(n)} 张表没做完`,
|
||||
failed: (reason: string) => (reason ? `数据剖析失败:${reason}` : '数据剖析失败'),
|
||||
stopped: (title: string) => `数据剖析已停止:${title}`,
|
||||
cancelled: '数据剖析已停止',
|
||||
interrupted: '服务重启,数据剖析中断',
|
||||
},
|
||||
viewReport: '查看报告',
|
||||
|
||||
// ---- 开始不了(409)
|
||||
blocked: {
|
||||
disabled: '未开启数据剖析',
|
||||
inactive: '数据源已停用',
|
||||
noSchema: '还没有表结构',
|
||||
busy: '这个数据源正在剖析',
|
||||
busy: '这个数据源已有剖析任务',
|
||||
tampered: '数据文件核对未通过',
|
||||
nothing: '没有可剖析的表',
|
||||
other: '无法开始剖析',
|
||||
} as Record<string, string>,
|
||||
blockedNext: {
|
||||
disabled: '开启后即可在这里剖析。',
|
||||
inactive: '启用后回到这里重试。',
|
||||
noSchema: '探查完成后回到这里重试。',
|
||||
busy: '同一个数据源同一时间只能进行一次剖析。',
|
||||
busy: '同一个数据源同一时间只有一个剖析任务:可以查看它的进度,等它结束后再开始新的剖析。',
|
||||
tampered: '请重新上传表格,或联系管理员核对数据文件。',
|
||||
nothing: '可以改选「全部表」,或在表清单中选定要剖析的表。',
|
||||
other: '请稍后重试。',
|
||||
} as Record<string, string>,
|
||||
busyAt: (who: string | null, done: number, total: number) =>
|
||||
`${who ? `由「${who}」发起,` : ''}已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
viewActive: '查看它的进度',
|
||||
chooseAll: '改选全部表',
|
||||
goSource: '前往数据源',
|
||||
retry: '重试',
|
||||
failedTitle: '剖析未能完成',
|
||||
retryStart: '重试',
|
||||
failedTitle: '剖析未能开始',
|
||||
backToSetup: '返回',
|
||||
|
||||
// ---- 表清单的「剖析」一列和筛选
|
||||
listNever: '未剖析',
|
||||
listNoChecks: '无可剖析项',
|
||||
listNoChecksHint: '按当前的目录和表结构,这张表没有可剖析的项(关联关系候选、像码值的列、日期列)',
|
||||
listAgo: (ago: string) => `${ago}剖析`,
|
||||
listNewRelations: '有新关系待剖析',
|
||||
listStatus: {
|
||||
pending: '排队剖析', running: '剖析中', partial: '剖析部分完成', failed: '剖析失败', skipped: '剖析已跳过', cancelled: '剖析已停止',
|
||||
} as Record<string, string>,
|
||||
listHint: (status: string, at: string) => `上次剖析(${at}):${status}`,
|
||||
dueReason: {
|
||||
never: '从没剖析过',
|
||||
unfinished: '上次剖析没做完',
|
||||
new_relations: '剖析之后目录里出现了新的待核实关联关系',
|
||||
} as Record<string, string>,
|
||||
dueFilter: '需要剖析',
|
||||
dueFilterHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系的表',
|
||||
|
||||
// ---- 表详情的「数据剖析」一节
|
||||
detailHint: '对业务库发只读查询得到的观测:行数、日期范围、核实了什么、跳过了什么',
|
||||
detailNever: '这张表还没有剖析过。',
|
||||
detailNoChecks: '按当前的目录和表结构,这张表没有可剖析的项(关联关系候选、像码值的列、日期列)。',
|
||||
detailView: '视图不剖析。',
|
||||
detailMissing: '表结构中已没有这张表,不能剖析。',
|
||||
detailLast: (at: string) => `上次剖析:${at}`,
|
||||
detailBy: (who: string) => `${who} 发起`,
|
||||
detailJob: '查看这次剖析',
|
||||
detailPending: '在剖析任务里排队,等待开始',
|
||||
detailRunning: '正在剖析这张表',
|
||||
profileThis: '剖析这张表',
|
||||
profileThisHint: '新建一次剖析,只含这张表',
|
||||
profileThisDisabled: '这个数据源未开启数据剖析,开启后才能剖析',
|
||||
profileThisStarted: (table: string) => `已开始剖析 ${table}`,
|
||||
profileThisQueued: (table: string) => `已排队剖析 ${table}:起草任务结束后自动开始`,
|
||||
busyThisIn: (pos: number, total: number) => `这张表在其中,排第 ${formatNumber(pos)} / ${formatNumber(total)} 张`,
|
||||
busyThisNotIn: '这张表不在其中,等它结束后再剖析这张表',
|
||||
viewProgress: '查看进度',
|
||||
}
|
||||
|
||||
/** 整次剖析中途停下的原因 */
|
||||
export const PROFILE_STOP_LABEL: Record<string, string> = {
|
||||
budget: '查询次数已用完',
|
||||
deadline: '总时长已用完',
|
||||
failed: '连续多条查询失败',
|
||||
}
|
||||
|
||||
/** 停下之后怎么办 */
|
||||
export const PROFILE_STOP_NEXT: Record<string, string> = {
|
||||
budget: '其余检查未执行。可在数据剖析设置中调高查询次数上限,或缩小剖析范围后重新剖析。',
|
||||
deadline: '其余检查未执行。可在数据剖析设置中调高总时长上限,或缩小剖析范围后重新剖析。',
|
||||
failed: '其余检查未执行。请先在数据源卡片上测试连接,确认可用后重新剖析。',
|
||||
}
|
||||
|
||||
/** 跳过一项的原因(skipped.reason)。整句说明在 skipped.detail 里 */
|
||||
/** 跳过一项的原因(skipped.reason、检查项的 reason)。整句说明在 detail 里 */
|
||||
export const PROFILE_SKIP_REASON_LABEL: Record<string, string> = {
|
||||
budget: '查询次数用完',
|
||||
deadline: '总时长用完',
|
||||
budget: '本表查询次数用完',
|
||||
deadline: '本表时长用完',
|
||||
failed: '连续失败',
|
||||
timeout: '查询超时',
|
||||
error: '查询失败',
|
||||
rejected: '未通过安全守卫',
|
||||
rejected: '未通过只读守卫',
|
||||
slow: '数据库响应慢',
|
||||
cancelled: '已停止',
|
||||
masked: '列已遮罩',
|
||||
too_large: '表太大',
|
||||
view: '视图',
|
||||
@@ -2940,6 +3094,7 @@ export const PROFILE_SKIP_REASON_LABEL: Record<string, string> = {
|
||||
no_data: '没有数据',
|
||||
missing: '表结构中没有',
|
||||
high_cardinality: '取值太多',
|
||||
nothing: '没有可剖析的项',
|
||||
}
|
||||
|
||||
/** 跳过的是哪一类检查(skipped.kind) */
|
||||
|
||||
@@ -536,7 +536,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
|
||||
</button>
|
||||
)}
|
||||
{profile.enabled && (
|
||||
<span className="chip" data-profile-on="" title={PROFILE_TEXT.chipHint(profile.max_queries, profile.query_timeout_s)}>
|
||||
<span className="chip" data-profile-on="" title={PROFILE_TEXT.chipHint(profile.table_max_queries, profile.table_max_s, profile.query_timeout_s)}>
|
||||
<ScanSearch size={10} aria-hidden /> {PROFILE_TEXT.chip}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -6,30 +6,34 @@ import {
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogDetail, CatalogList, CatalogReviewItem, CatalogReviewKind, CatalogTableRow } from '../../types'
|
||||
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, rovingTarget, toast, useTicker } from '../../components/ui'
|
||||
import type {
|
||||
CatalogDetail, CatalogList, CatalogProfileTaskStatus, CatalogReviewItem, CatalogReviewKind, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, rovingTarget, toast } from '../../components/ui'
|
||||
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_STOP_LABEL,
|
||||
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, PROFILE_TEXT as PT,
|
||||
SCHEMA_PARTIAL_TEXT as SPT,
|
||||
} from '../../lib/terms'
|
||||
import { StatusLegend, SystemNotesNotice } from './parts'
|
||||
import { DraftDialog } from './DraftDialog'
|
||||
import { isJobActive, jobBlank, retryableCount, useDraftJob } from './draftJob'
|
||||
import { isJobActive, jobBlank, retryableCount, useDraftJob, waitsForProfile } from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
import { ProfileDialog } from './ProfileDialog'
|
||||
import type { ProfileJob } from './ProfileDialog'
|
||||
import { ProfileSettingsDialog } from './ProfileSettings'
|
||||
import { fillKey, profileBlockOf, profileSettingsOf, secondsText } from './profile'
|
||||
import { fillKey, profileSettingsOf } from './profile'
|
||||
import { retryableTasks, useProfileJob } from './profileJob'
|
||||
import type { ProfileStartBody } from './profileJob'
|
||||
import { TableDetail } from './TableDetail'
|
||||
import type { TableProfileProps } from './TableDetail'
|
||||
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 { filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
|
||||
import { dueCount, filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
|
||||
import type { ListFilter, ListParams, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -47,6 +51,10 @@ import type { ListFilter, ListParams, ListSort } from './model'
|
||||
// 左栏有两个视图:表清单(逐表看)和审阅队列(view=review:跨表按优先级列出推断项,成批确认、驳回,筛选记在 kind /
|
||||
// rtable 里)。从队列点开一项,右栏是所在表的详情,地址带着那一项(item),详情滚到那一项、列出用过这张表的运行。
|
||||
// 页头的「导出目录」「导入目录」把整理好的目录带到另一个实例(导入先预览,确认后才写)。
|
||||
//
|
||||
// 起草、剖析都是服务端的后台任务(draftJob.ts、profileJob.ts),状态在这一页上:关掉对话框照常进行,页头的「起草」「数据
|
||||
// 剖析」换成进度入口(「起草 3/20」「剖析 12/40」「剖析 排队中」),点开对应的对话框;结束时右下角提示,可以回到结果。
|
||||
// 同一个数据源的起草和剖析按建立顺序排队:一个在排队、进行中时,另一个建出来是排队,前一个结束后自动开始。
|
||||
// ===========================================================================
|
||||
|
||||
export function CatalogPage() {
|
||||
@@ -107,10 +115,13 @@ export function CatalogPage() {
|
||||
const { query, filter, sort } = list
|
||||
/** 只看运行中查询过的表:从顶部摘要点进来时打开,筛选结果和摘要的数字一致 */
|
||||
const usedOnly = list.used
|
||||
/** 只看需要剖析的表(和剖析范围「需要剖析的表」同一个口径) */
|
||||
const dueOnly = !!list.due
|
||||
const setQuery = useCallback((q: string) => { typedAt.current = Date.now(); setList((c) => ({ ...c, query: q })) }, [])
|
||||
const setFilter = useCallback((f: ListFilter) => setList((c) => ({ ...c, filter: f })), [])
|
||||
const setSort = useCallback((v: ListSort) => setList((c) => ({ ...c, sort: v })), [])
|
||||
const setUsedOnly = useCallback((v: boolean) => setList((c) => ({ ...c, used: v })), [])
|
||||
const setDueOnly = useCallback((v: boolean) => setList((c) => ({ ...c, due: v })), [])
|
||||
/**
|
||||
* 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址)。审阅视图的参数(view、kind、rtable)
|
||||
* 照旧带着,从审阅队列打开的那一项(item)不带:换了表就不是那一项了
|
||||
@@ -133,21 +144,25 @@ export function CatalogPage() {
|
||||
const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean; preset?: DraftPreset } | null>(null)
|
||||
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
|
||||
const [drafted, setDrafted] = useState<{ seq: number; tables: Set<string> }>({ seq: 0, tables: new Set() })
|
||||
const [profiling, setProfiling] = useState(false)
|
||||
/**
|
||||
* 剖析对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
|
||||
* scope:从表清单的「剖析所选」打开,范围默认是已选的表
|
||||
*/
|
||||
const [profileOpen, setProfileOpen] = useState<{ view: 'setup' | 'job'; scope?: 'selected' } | null>(null)
|
||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false)
|
||||
/** 一次剖析的状态。关掉弹窗剖析照常进行:状态留在页面上,页头有入口,完成后提示并可再打开报告 */
|
||||
const [job, setJob] = useState<(ProfileJob & { sourceId: string }) | null>(null)
|
||||
/** 剖析设置刚存过:数据源列表重取回来之前按存下的这份算(预算、开没开) */
|
||||
const [savedOptions, setSavedOptions] = useState<{ sourceId: string; options: Record<string, unknown> } | null>(null)
|
||||
/**
|
||||
* 从剖析报告点「填写含义」:报告先收起,打开那张表并弹出那一列的码值;码值弹窗关上(保存或取消)后回到报告,
|
||||
* 焦点落在下一列的「填写含义」上,一列接一列地填
|
||||
* 从剖析结果点「填写含义」:对话框先收起,打开那张表并弹出那一列的码值;码值弹窗关上(保存或取消)后回到结果,
|
||||
* 那张表照旧展开,焦点落在下一列的「填写含义」上,一列接一列地填
|
||||
*/
|
||||
const [fillCodes, setFillCodes] = useState<{ table: string; column: string; seq: number } | null>(null)
|
||||
/** 回到报告时从哪一列接着往下 */
|
||||
/** 回到结果时从哪一列接着往下 */
|
||||
const [returnTo, setReturnTo] = useState<{ table: string; column: string } | null>(null)
|
||||
const profilingRef = useRef(profiling)
|
||||
profilingRef.current = profiling
|
||||
/** 从结果去填了含义的码值列:保存后还剩几个待填写(按任务记,换了任务就不算) */
|
||||
const [filled, setFilled] = useState<{ jobId: string; map: Record<string, number> } | null>(null)
|
||||
const profileOpenRef = useRef(profileOpen)
|
||||
profileOpenRef.current = profileOpen
|
||||
const alive = useRef(true)
|
||||
// 开发模式的严格模式会先卸载再挂载一次:挂载时要重新置为真
|
||||
useEffect(() => {
|
||||
@@ -158,8 +173,9 @@ export function CatalogPage() {
|
||||
sourceRef.current = sourceId
|
||||
|
||||
const rows = useMemo(() => data?.tables ?? [], [data])
|
||||
const visible = useMemo(() => visibleRows(rows, query, filter, sort, usedOnly), [rows, query, filter, sort, usedOnly])
|
||||
const counts = useMemo(() => filterCounts(rows, query, usedOnly), [rows, query, usedOnly])
|
||||
const visible = useMemo(() => visibleRows(rows, query, filter, sort, usedOnly, dueOnly), [rows, query, filter, sort, usedOnly, dueOnly])
|
||||
const counts = useMemo(() => filterCounts(rows, query, usedOnly, dueOnly), [rows, query, usedOnly, dueOnly])
|
||||
const dueTotal = useMemo(() => dueCount(rows, query, usedOnly), [rows, query, usedOnly])
|
||||
const base = `/data/catalog/${encodeURIComponent(sourceId)}`
|
||||
const openTable = useCallback((t: string) => navigate(`${base}/${encodeURIComponent(t)}${searchRef.current}`), [navigate, base])
|
||||
const backToList = useCallback(() => navigate(`${base}${searchRef.current}`), [navigate, base])
|
||||
@@ -232,7 +248,7 @@ export function CatalogPage() {
|
||||
}, [])
|
||||
// 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
|
||||
const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null
|
||||
const stripJob: { done: number; total: number; status: string } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
|
||||
const stripJob: { done: number; total: number; status: string; waiting_for?: string | null } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
|
||||
? (draftActive ? draftJob : null)
|
||||
: listActive
|
||||
// 调用接连没写出来的提醒:关着对话框时也要看得到(上方的进度带着它)
|
||||
@@ -240,87 +256,105 @@ export function CatalogPage() {
|
||||
|
||||
const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
|
||||
const profileSettings = useMemo(() => profileSettingsOf(options), [options])
|
||||
const myJob = job?.sourceId === sourceId ? job : null
|
||||
const jobRunning = myJob?.phase === 'running'
|
||||
useTicker(1000, jobRunning)
|
||||
|
||||
/**
|
||||
* 剖析:同步请求,做完才返回。期间关掉弹窗不影响;回来时页面已经换了数据源或卸载了,只提示、不再改这一页。
|
||||
* 写进去的表重新载入清单和正在看的详情(编辑中的不动,同起草)
|
||||
* 剖析任务(后台进行,状态在页面上):每张表一结束就重取清单、正在看的表在里面就重新载入(编辑中的不动,保存时服务端
|
||||
* 会用版本号拦下);结束时重取一次清单。对话框关着时结束的,右下角提示并给「查看报告」
|
||||
*/
|
||||
const startProfile = useCallback(async (tables: string[] | null) => {
|
||||
const profile = useProfileJob(sourceId, data?.active_profile_job, {
|
||||
onTablesEnded: (tables) => {
|
||||
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
|
||||
void load()
|
||||
},
|
||||
onFinished: (job) => {
|
||||
void load()
|
||||
if (profileOpenRef.current) return
|
||||
const src = job.source_id
|
||||
const opts = {
|
||||
key: `catalog-profile:${src}`, duration: 10000,
|
||||
action: { label: PT.viewReport, onClick: () => { if (sourceRef.current === src) setProfileOpen({ view: 'job' }) } },
|
||||
}
|
||||
if (job.stop_reason) toast.warn(PT.toast.stopped(PT.stopShort[job.stop_reason] ?? ''), opts)
|
||||
else if (job.status === 'succeeded') toast.ok(PT.toast.succeeded(PT.findings(job.summary)), opts)
|
||||
else if (job.status === 'partial') toast.warn(PT.toast.partial(retryableTasks(job).length), opts)
|
||||
else if (job.status === 'failed') toast.error(PT.toast.failed(job.error ?? ''), opts)
|
||||
else if (job.status === 'interrupted') toast.warn(PT.toast.interrupted, opts)
|
||||
else toast.warn(PT.toast.cancelled, opts)
|
||||
},
|
||||
})
|
||||
const profileJob = profile.job
|
||||
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 =
|
||||
profileJob && (profileActive || profileJob.id === listProfile?.id) ? (profileActive ? profileJob : null) : listProfile
|
||||
/** 跟着的剖析任务里各表的状态(表清单的「剖析」一格据此写排队、剖析中) */
|
||||
const profileLive = useMemo(() => new Map<string, CatalogProfileTaskStatus>(
|
||||
profileActive && profileJob ? profileJob.tasks.map((t) => [t.table_name, t.status]) : []), [profileActive, profileJob])
|
||||
/** 打开剖析对话框:有进行中的任务就直接显示它;上一次的结果看过了,再点就是重新开始 */
|
||||
const openProfile = useCallback(() => setProfileOpen({ view: profileActive ? 'job' : 'setup' }), [profileActive])
|
||||
const openProfileSelected = useCallback(() => setProfileOpen({ view: profileActive ? 'job' : 'setup', scope: 'selected' }), [profileActive])
|
||||
const startProfile = useCallback(async (body: ProfileStartBody) => {
|
||||
const next = await profile.start(body)
|
||||
// 选中的表已经交给剖析任务了:清空,免得接着点「剖析所选」又剖析一遍
|
||||
if (body.tables) setSelected(new Set())
|
||||
return next
|
||||
}, [profile.start])
|
||||
/** 表详情里「剖析这张表」:只含这张表 */
|
||||
const profileTable = useCallback((t: string) => profile.start({ tables: [t] }), [profile.start])
|
||||
/** 打开剖析对话框显示某个任务:页面跟着的那一个直接打开;别的(表详情里「查看这次剖析」)先取来 */
|
||||
const openProfileJob = useCallback(async (jobId: string | null) => {
|
||||
if (!jobId || profile.job?.id === jobId) {
|
||||
setProfileOpen({ view: 'job' })
|
||||
return
|
||||
}
|
||||
const src = sourceId
|
||||
const startedAt = Date.now()
|
||||
setJob({ sourceId: src, phase: 'running', startedAt, tables, settings: profileSettings })
|
||||
let next: ProfileJob
|
||||
try {
|
||||
const report = await api.dataCatalog.profile(src, tables ? { tables } : {})
|
||||
next = { phase: 'done', report, ms: Date.now() - startedAt }
|
||||
const { job } = await api.dataCatalog.profileJobs.get(src, jobId)
|
||||
if (sourceRef.current !== src) return
|
||||
profile.track(job)
|
||||
setProfileOpen({ view: 'job' })
|
||||
} catch (e) {
|
||||
next = e instanceof ApiError && e.status === 409
|
||||
? { phase: 'blocked', kind: profileBlockOf(e), message: e.message, tables }
|
||||
: { phase: 'failed', error: e, tables }
|
||||
toast.error(e)
|
||||
}
|
||||
const here = alive.current && sourceRef.current === src
|
||||
if (here) setJob({ ...next, sourceId: src })
|
||||
if (next.phase === 'done') {
|
||||
const r = next.report
|
||||
const touched = r.tables.filter((t) => !t.error && t.added + t.updated + t.removed > 0).map((t) => t.table_name)
|
||||
if (here) {
|
||||
if (touched.length) setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(touched) }))
|
||||
void load()
|
||||
}
|
||||
// 弹窗开着就看报告;关了(在后台继续)才提示,并给「查看报告」
|
||||
if (!here || !profilingRef.current) {
|
||||
const summary = PT.summary(r.queries_used, r.settings.max_queries, r.tables.length)
|
||||
const text = r.stopped ? PT.toastStopped(PROFILE_STOP_LABEL[r.stopped] ?? r.stopped) : PT.toastDone(summary)
|
||||
const show = r.stopped ? toast.warn : toast.ok
|
||||
show(text, here ? { key: `profile:${src}`, duration: 10000, action: { label: PT.viewReport, onClick: () => setProfiling(true) } }
|
||||
: { key: `profile:${src}` })
|
||||
}
|
||||
} else if (!here || !profilingRef.current) {
|
||||
toast.error(next.phase === 'blocked' ? next.message : next.error, here
|
||||
? { key: `profile:${src}`, action: { label: PT.viewReport, onClick: () => setProfiling(true) } } : { key: `profile:${src}` })
|
||||
}
|
||||
}, [sourceId, profileSettings, load])
|
||||
|
||||
const openProfile = useCallback(() => {
|
||||
// 上一次的结果(报告、被拒)看过了,再点就是重新开始;正在剖析时打开的是进度
|
||||
setJob((cur) => (cur && cur.phase === 'running' && cur.sourceId === sourceId ? cur : null))
|
||||
setProfiling(true)
|
||||
}, [sourceId])
|
||||
}, [sourceId, profile.job?.id, profile.track])
|
||||
const tableProfile: TableProfileProps = useMemo(() => ({
|
||||
job: profileJob, onStart: profileTable, onShowActive: profile.showActive, onSettings: () => setProfileSettingsOpen(true),
|
||||
onOpenJob: (id: string | null) => void openProfileJob(id),
|
||||
}), [profileJob, profileTable, profile.showActive, openProfileJob])
|
||||
|
||||
const onProfileSettingsSaved = useCallback((row: any) => {
|
||||
setProfileSettingsOpen(false)
|
||||
setSavedOptions({ sourceId, options: row?.options ?? {} })
|
||||
void useCatalog.getState().reload('datasources')
|
||||
// 因为没开启被拒的:开了就回到选范围,可以直接开始
|
||||
setJob((cur) => (cur && cur.phase === 'blocked' && cur.kind === 'disabled' ? null : cur))
|
||||
}, [sourceId])
|
||||
|
||||
const openFromReport = useCallback((t: string) => {
|
||||
setProfiling(false)
|
||||
setProfileOpen(null)
|
||||
openTable(t)
|
||||
}, [openTable])
|
||||
|
||||
const fillFromReport = useCallback((t: string, column: string) => {
|
||||
setProfiling(false)
|
||||
setProfileOpen(null)
|
||||
setFillCodes((cur) => ({ table: t, column, seq: (cur?.seq ?? 0) + 1 }))
|
||||
openTable(t)
|
||||
}, [openTable])
|
||||
|
||||
const jobRef = useRef(job)
|
||||
jobRef.current = job
|
||||
/** 从报告打开的码值弹窗关上了:记下这一列还剩几个含义待填写(报告里跟着改),回到报告 */
|
||||
const profileJobRef = useRef(profileJob)
|
||||
profileJobRef.current = profileJob
|
||||
/**
|
||||
* 码值弹窗关上了:是从剖析结果打开的,就记下这一列还剩几个含义待填写(结果里跟着改),回到结果。表详情的「数据剖析」
|
||||
* 一节里点的「填写含义」不回对话框
|
||||
*/
|
||||
const onFillDone = useCallback((seq: number, pending: number | null) => {
|
||||
const fill = fillCodes?.seq === seq ? fillCodes : null
|
||||
const cur = jobRef.current
|
||||
if (!fill || !alive.current || cur?.phase !== 'done' || cur.sourceId !== sourceRef.current) return
|
||||
const cur = profileJobRef.current
|
||||
if (!fill || !alive.current || !cur || cur.source_id !== sourceRef.current) return
|
||||
if (pending != null) {
|
||||
setJob((j) => (j && j.phase === 'done' ? { ...j, filled: { ...j.filled, [fillKey(fill.table, fill.column)]: pending } } : j))
|
||||
setFilled((f) => ({ jobId: cur.id, map: { ...(f?.jobId === cur.id ? f.map : {}), [fillKey(fill.table, fill.column)]: pending } }))
|
||||
}
|
||||
setReturnTo({ table: fill.table, column: fill.column })
|
||||
setProfiling(true)
|
||||
setProfileOpen({ view: 'job' })
|
||||
}, [fillCodes])
|
||||
|
||||
// 上一张 / 下一张按清单当前的筛选和排序走;正在看的表不在筛选结果里(刚确认完、从关联关系跳过来)时按全部表走
|
||||
@@ -394,7 +428,7 @@ export function CatalogPage() {
|
||||
}
|
||||
}, [table, navigate, base, params])
|
||||
|
||||
const filtered = !!query.trim() || filter !== 'all' || usedOnly
|
||||
const filtered = !!query.trim() || filter !== 'all' || usedOnly || dueOnly
|
||||
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
|
||||
const hasTables = rows.length > 0
|
||||
const draftButton = (primary = false) => (
|
||||
@@ -402,23 +436,32 @@ export function CatalogPage() {
|
||||
<Sparkles size={11} aria-hidden /> {CT.draft}
|
||||
</button>
|
||||
)
|
||||
// 页头的「数据剖析」:剖析进行中换成计时,点开看进度(关掉弹窗剖析照常进行)
|
||||
const headerProfile = jobRunning && myJob?.phase === 'running'
|
||||
? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setProfiling(true)} title={PT.headerRunningHint}
|
||||
aria-label={PT.headerRunning(secondsText(Date.now() - myJob.startedAt))} data-catalog-profile="running">
|
||||
<Spinner size={11} /> <span className="tnum hidden sm:inline">{PT.headerRunning(secondsText(Date.now() - myJob.startedAt))}</span>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn btn-sm" onClick={openProfile} aria-label={PT.action} title={PT.actionHint} data-catalog-profile="">
|
||||
<ScanSearch size={11} aria-hidden /> <span className="hidden sm:inline">{PT.action}</span>
|
||||
</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 : PT.headerQueuedHint) : PT.headerRunningHint(profileStrip.done, profileStrip.total))
|
||||
: PT.actionHint
|
||||
// 最近查询多数超时的提醒:对话框关着时也要看得到(页头的入口换成提醒色,悬停写全文)
|
||||
const profileNotice = profileActive ? profileJob?.notice?.trim() || null : null
|
||||
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>
|
||||
)
|
||||
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 : DT.headerQueuedHint) : DT.headerRunningHint(stripJob.done, stripJob.total))
|
||||
: CT.draft
|
||||
const headerDraft = (
|
||||
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
|
||||
<Sparkles size={11} aria-hidden /> <span className="hidden sm:inline">{CT.draft}</span>
|
||||
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={draftLabel} title={draftTitle} 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>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -481,7 +524,12 @@ export function CatalogPage() {
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
onProfile={openProfileSelected}
|
||||
profileOn={profileSettings.enabled}
|
||||
profileLive={profileLive}
|
||||
dueOnly={dueOnly}
|
||||
onDueOnly={setDueOnly}
|
||||
dueTotal={dueTotal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -493,7 +541,8 @@ export function CatalogPage() {
|
||||
<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} />
|
||||
profileEnabled={profileSettings.enabled} focusPath={inReview ? review.item || null : null} usageOpen={inReview}
|
||||
profile={tableProfile} />
|
||||
)
|
||||
: inReview
|
||||
? <ReviewOverview />
|
||||
@@ -554,6 +603,7 @@ export function CatalogPage() {
|
||||
initialBusy={!!draftOpen.busy}
|
||||
preset={draftOpen.preset ?? null}
|
||||
stopping={draft.stopping}
|
||||
profileActive={!!profileStrip}
|
||||
onStart={startDraft}
|
||||
onCancel={draft.cancel}
|
||||
onRetry={draft.retry}
|
||||
@@ -562,23 +612,32 @@ export function CatalogPage() {
|
||||
onClose={() => setDraftOpen(null)}
|
||||
/>
|
||||
)}
|
||||
{profiling && data && (
|
||||
{profileOpen && data && (
|
||||
<ProfileDialog
|
||||
sourceId={sourceId}
|
||||
sourceName={source?.name ?? sourceId}
|
||||
settings={profileSettings}
|
||||
rows={rows}
|
||||
visible={visible}
|
||||
selected={[...selected]}
|
||||
filtered={filtered}
|
||||
job={myJob}
|
||||
onStart={(tables) => void startProfile(tables)}
|
||||
onClose={() => { setProfiling(false); setReturnTo(null) }}
|
||||
job={profileJob}
|
||||
initial={profileOpen.view}
|
||||
initialScope={profileOpen.scope ?? null}
|
||||
draftActive={!!stripJob}
|
||||
stopping={profile.stopping}
|
||||
filled={filled && filled.jobId === profileJob?.id ? filled.map : {}}
|
||||
returnTo={returnTo}
|
||||
onStart={startProfile}
|
||||
onCancel={profile.cancel}
|
||||
onRetry={profile.retry}
|
||||
onShowActive={profile.showActive}
|
||||
onTrack={profile.track}
|
||||
onClose={() => { setProfileOpen(null); setReturnTo(null) }}
|
||||
onSettings={() => setProfileSettingsOpen(true)}
|
||||
onGoSource={() => navigate(back)}
|
||||
onOpenTable={openFromReport}
|
||||
onFillCodes={fillFromReport}
|
||||
onReset={() => setJob(null)}
|
||||
/>
|
||||
)}
|
||||
{importing && (
|
||||
@@ -597,11 +656,12 @@ export function CatalogPage() {
|
||||
* notice:调用接连没写出来时服务端给的提醒,跟着写一行(全文在悬停里,对话框里也有)
|
||||
*/
|
||||
function DraftStrip({ job, notice, onOpen }: {
|
||||
job: { done: number; total: number; status: string }
|
||||
job: { done: number; total: number; status: string; waiting_for?: string | null }
|
||||
notice: string | null
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const text = job.status === 'queued' ? DT.stripQueued(job.total) : DT.strip(job.done, job.total)
|
||||
// 排在剖析任务后面(同一个数据源的起草和剖析按建立顺序排队):写明剖析结束后自动开始
|
||||
const text = job.status === 'queued' ? (waitsForProfile(job) ? DT.stripQueuedProfile(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))' }}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatTime } from '../../lib/format'
|
||||
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
|
||||
import {
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason, writtenOf,
|
||||
nothingWritten, retryableCount, taskReason, waitsForProfile, writtenOf,
|
||||
} from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
|
||||
@@ -27,6 +27,9 @@ import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
// 术语的含义,再重新起草这几张表。「重新起草这几张表」回到设置:范围就是那几张、勾着用模型、焦点在起草要求,
|
||||
// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。预先选好的范围里的表已经不在表结构
|
||||
// 里时不退回别的范围:那一项照旧选着、写明原因,不能开始。
|
||||
//
|
||||
// 同一个数据源的起草和剖析按建立顺序排队:有剖析任务在进行时,设置里写明起草会排在它后面;任务排在剖析后面
|
||||
// (waiting_for 为 profile)时写「排在剖析任务后面,剖析结束后自动开始」。
|
||||
// ===========================================================================
|
||||
|
||||
type Scope = 'preset' | 'selected' | 'filtered' | 'top' | 'all'
|
||||
@@ -46,7 +49,7 @@ const startErrorText = (e: unknown) =>
|
||||
|
||||
export function DraftDialog({
|
||||
sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, preset: initialPreset = null,
|
||||
stopping, onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
|
||||
stopping, profileActive = false, onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
|
||||
}: {
|
||||
sourceId: string
|
||||
/** 数据源说明:用模型起草时连同起草要求一起交给模型 */
|
||||
@@ -74,6 +77,8 @@ export function DraftDialog({
|
||||
preset?: DraftPreset | null
|
||||
/** 点了停止、服务端还没停下来 */
|
||||
stopping: boolean
|
||||
/** 同一个数据源有剖析任务在排队、进行中:起草会排在它后面 */
|
||||
profileActive?: boolean
|
||||
/** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */
|
||||
onStart: (body: DraftStartBody) => Promise<{ job: CatalogDraftJob; busy: boolean }>
|
||||
onCancel: () => Promise<void>
|
||||
@@ -367,6 +372,11 @@ export function DraftDialog({
|
||||
<button type="button" className="btn btn-sm" onClick={() => openLast(last)} data-draft-last-open="">{DT.lastOpen}</button>
|
||||
</div>
|
||||
)}
|
||||
{profileActive && (
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-dim" data-draft-queue="">
|
||||
<Info size={11} className="mt-px shrink-0" style={{ color: 'var(--accent)' }} aria-hidden /> <span>{DT.queueHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{startError && <Notice tone="err" attr={{ 'data-draft-start-error': '' }}>{startError}</Notice>}
|
||||
</>
|
||||
)}
|
||||
@@ -451,6 +461,11 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
|
||||
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
|
||||
<div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${pct}%`, background: tone }} />
|
||||
</div>
|
||||
{waitsForProfile(job) && (
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-dim" data-draft-waiting="profile">
|
||||
<Info size={11} className="mt-px shrink-0" style={{ color: 'var(--accent)' }} aria-hidden /> <span>{DT.waitingProfile}</span>
|
||||
</p>
|
||||
)}
|
||||
{notice && <Notice tone="warn" className="break-words" attr={{ 'data-draft-notice': '' }}>{notice}</Notice>}
|
||||
<p className="tnum text-sm" data-draft-summary="">{summary}</p>
|
||||
{(job.model || usage.calls > 0 || failures) && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ import type { ProfileForm } from './profile'
|
||||
// 数据剖析的设置:开关、五项预算、风险说明。数据源编辑框里是其中一段;导入表格的源没有编辑框,数据目录页在
|
||||
// 剖析被拒(未开启)时也要就地打开它,所以另有一个只改这一项的弹窗(options 的其余键原样带上)。
|
||||
// 剖析会对业务库发查询,开关默认关着;开启后五项预算才展开,风险说明写的是此刻填的数。
|
||||
// 预算按表算:每张表最多查询数、每张表时长上限(用完只停这张表),另有单条查询时限、抽样键值数、整表统计行数上限;
|
||||
// 没有整次剖析的总量上限(任务自己在连续失败、多数超时时停下)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 保存被拒时的落点:某一项,或者整段(认不出是哪一项) */
|
||||
@@ -57,7 +59,7 @@ export function ProfileSettingsSection({ idPrefix, form, onChange, serverError }
|
||||
<p className="flex items-start gap-1.5 rounded-md px-2 py-1.5 text-2xs leading-relaxed text-dim" data-profile-risk=""
|
||||
style={{ background: 'var(--st-waiting-soft)' }}>
|
||||
<ShieldAlert size={12} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
|
||||
<span>{PT.risk(s.max_queries, s.query_timeout_s, s.max_total_s)}</span>
|
||||
<span>{PT.risk(s.table_max_queries, s.table_max_s, s.query_timeout_s)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, Info, ListChecks, Lock, MessagesSquare, Pencil, Plus, RotateCw, Search, Sparkles, X,
|
||||
AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, Info, ListChecks, Lock, MessagesSquare, Pencil, Plus, RotateCw, ScanSearch, Search,
|
||||
Sparkles, X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type {
|
||||
CatalogBusinessDate, CatalogCardinality, CatalogDetail, CatalogItem, CatalogRelation, CatalogReviewAction, CatalogTableKind,
|
||||
CatalogBusinessDate, CatalogCardinality, CatalogDetail, CatalogItem, CatalogProfileJob, CatalogRelation, CatalogReviewAction, CatalogTableKind,
|
||||
CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast } from '../../components/ui'
|
||||
@@ -15,7 +16,7 @@ import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_ASSISTANT_TEXT as AT, CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL,
|
||||
CATALOG_REVIEW_TEXT as RT, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
|
||||
CATALOG_USAGE_TEXT, CODES_TEXT as KT,
|
||||
CATALOG_USAGE_TEXT, CODES_TEXT as KT, PROFILE_TEXT as PT,
|
||||
} from '../../lib/terms'
|
||||
import { CatalogImpactList } from '../../components/CatalogImpact'
|
||||
import {
|
||||
@@ -26,8 +27,10 @@ import type { ReviewTarget } from './parts'
|
||||
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
|
||||
import type { ColumnModel } from './ColumnTable'
|
||||
import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import { coverageText, isProfilable, profileBlockOf } from './profile'
|
||||
import { isJobActive, lastBlankInfo, taskReason } from './draftJob'
|
||||
import { isProfileBusy } from './profileJob'
|
||||
import { PROFILE_TASK_ICON, ProfileTaskBody } from './ProfileDialog'
|
||||
import { TableAssistant } from './TableAssistant'
|
||||
import { TableUsageList } from './TableUsage'
|
||||
import {
|
||||
@@ -42,6 +45,9 @@ import type { CodesItem, ColumnField, EditForm, RelationDraft, TableField, Table
|
||||
//
|
||||
// 写操作都带读到的版本。409 说明别人刚改过:不静默覆盖,横幅写明并给「重新载入」;编辑中的修改留着,重新载入前
|
||||
// 先问一句。编辑中换表、离开页面走全站的离开前确认。
|
||||
//
|
||||
// 「数据剖析」一节:这张表上次剖析的时间、发起人、所属任务、行数估算和来源、日期范围、发现、跳过的项、发过的查询;
|
||||
// 「剖析这张表」建只含这张表的剖析任务(没开剖析时引到剖析设置;已有剖析任务时写明它到哪了、这张表在不在里面)。
|
||||
// ===========================================================================
|
||||
|
||||
const KINDS = Object.keys(CATALOG_KIND_LABEL) as CatalogTableKind[]
|
||||
@@ -62,7 +68,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,
|
||||
profileEnabled = false, focusPath = null, usageOpen = false, profile,
|
||||
}: {
|
||||
sourceId: string
|
||||
table: string
|
||||
@@ -99,6 +105,8 @@ export function TableDetail({
|
||||
focusPath?: string | null
|
||||
/** 「用过这张表的运行」直接取(从审阅队列打开时) */
|
||||
usageOpen?: boolean
|
||||
/** 数据剖析:页面跟着的剖析任务、建只含这张表的任务、打开剖析设置和剖析对话框 */
|
||||
profile?: TableProfileProps
|
||||
}) {
|
||||
const [detail, setDetail] = useState<CatalogDetail | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
@@ -620,6 +628,11 @@ export function TableDetail({
|
||||
focusPath={focusPath} />}
|
||||
</section>
|
||||
|
||||
{profile && (
|
||||
<ProfileSection table={detail.table_name} detail={detail} row={rows.find((r) => r.table_name === table) ?? null}
|
||||
enabled={profileEnabled} profile={profile} onFillCodes={detail.in_schema ? fillCodesOf : undefined} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
@@ -1037,3 +1050,151 @@ function RelationEditor({ drafts, initial, problems, rows, onChange }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据剖析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TableProfileProps {
|
||||
/** 页面跟着的剖析任务(进行中的、刚结束的) */
|
||||
job: CatalogProfileJob | null
|
||||
/** 建只含这张表的剖析任务;409、422 照原样抛 */
|
||||
onStart: (table: string) => Promise<CatalogProfileJob>
|
||||
/** 正忙(409)时取进行中的那一个,页面改跟着它 */
|
||||
onShowActive: () => Promise<CatalogProfileJob | null>
|
||||
/** 打开这个数据源的剖析设置 */
|
||||
onSettings: () => void
|
||||
/** 打开剖析对话框,显示这个任务(页面跟着的那一个,或者按编号取来) */
|
||||
onOpenJob: (jobId: string | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 表详情里的「数据剖析」一节。上次剖析的结论在服务端的 last_profile(GET /catalog/{table},任务还在进行就是进行中的状态),
|
||||
* 页面跟着的任务里这张表在排队、剖析中时以任务为准(比载入详情时新);这张表剖析完,页面会重新载入详情
|
||||
*/
|
||||
function ProfileSection({ table, detail, row, enabled, profile, onFillCodes }: {
|
||||
table: string
|
||||
detail: CatalogDetail
|
||||
row: CatalogTableRow | null
|
||||
enabled: boolean
|
||||
profile: TableProfileProps
|
||||
onFillCodes?: (column: string) => void
|
||||
}) {
|
||||
const [starting, setStarting] = useState(false)
|
||||
/** 开始不了(409):服务端的原话、正在进行的那个任务里有没有这张表 */
|
||||
const [blocked, setBlocked] = useState<{ kind: string; message: string; where: string } | null>(null)
|
||||
useEffect(() => { setBlocked(null) }, [table])
|
||||
const last = detail.last_profile ?? null
|
||||
const job = profile.job
|
||||
const live = job && isJobActive(job) ? job.tasks.find((t) => t.table_name === table) ?? null : null
|
||||
const liveActive = live && (live.status === 'pending' || live.status === 'running') ? live : null
|
||||
const isView = !!detail.structure?.is_view || !!row?.is_view
|
||||
const profilable = detail.in_schema && !isView && (row ? isProfilable(row) : true)
|
||||
const canStart = enabled && profilable && !liveActive && !starting
|
||||
// 上次剖析所在的任务:正有别的剖析任务在进行时不去换(页面只跟着一个任务,换了就看不到进行中的那个了)
|
||||
const canOpenJob = !!last && (!job || !isJobActive(job) || job.id === last.job_id)
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true)
|
||||
setBlocked(null)
|
||||
try {
|
||||
const next = await profile.onStart(table)
|
||||
const text = next.status === 'queued' && next.waiting_for === 'draft' ? PT.profileThisQueued(table) : PT.profileThisStarted(table)
|
||||
toast.ok(text, { action: { label: PT.viewProgress, onClick: () => profile.onOpenJob(next.id) } })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
const kind = profileBlockOf(e)
|
||||
let where = ''
|
||||
if (isProfileBusy(e)) {
|
||||
// 正忙:写清进行中的那个任务到哪了、这张表在不在里面、排第几
|
||||
const active = await profile.onShowActive().catch(() => null)
|
||||
if (active) {
|
||||
const at = active.tasks.findIndex((t) => t.table_name === table)
|
||||
where = [PT.busyAt(active.created_by, active.done, active.total), at >= 0 ? PT.busyThisIn(at + 1, active.total) : PT.busyThisNotIn].join(';')
|
||||
}
|
||||
}
|
||||
setBlocked({ kind, message: e.message, where })
|
||||
} else {
|
||||
toast.error(e)
|
||||
}
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
let status: ReactNode = null
|
||||
if (liveActive) {
|
||||
status = (
|
||||
<Notice tone="info" attr={{ 'data-catalog-profile-live': liveActive.status }}>
|
||||
<p className="font-medium">{liveActive.status === 'running' ? PT.detailRunning : PT.detailPending}</p>
|
||||
{liveActive.status === 'running' && liveActive.current && <p className="mt-0.5 break-words text-dim">{liveActive.current}</p>}
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={() => profile.onOpenJob(job!.id)} data-catalog-profile-progress="">{PT.viewProgress}</button>
|
||||
</Notice>
|
||||
)
|
||||
}
|
||||
const lastAt = last?.finished_at ?? last?.started_at ?? null
|
||||
const ended = last && last.status !== 'pending' && last.status !== 'running'
|
||||
const LastIcon = last ? PROFILE_TASK_ICON[last.status].Icon : null
|
||||
return (
|
||||
<section aria-labelledby="catalog-sec-profile" data-catalog-section="profile">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h3 id="catalog-sec-profile" className="text-xs font-semibold">{PT.title}</h3>
|
||||
<span className="text-2xs text-faint">{PT.detailHint}</span>
|
||||
<span className="flex-1" />
|
||||
{profilable && (
|
||||
<button type="button" className="btn btn-sm" disabled={!canStart} onClick={() => void start()}
|
||||
title={!enabled ? PT.profileThisDisabled : PT.profileThisHint} data-catalog-profile-table="">
|
||||
{starting ? <Spinner size={11} /> : <ScanSearch size={11} aria-hidden />} {PT.profileThis}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2.5 rounded-lg border bg-panel px-3 py-2.5" data-catalog-profile-section={last ? last.status : 'never'}>
|
||||
{!enabled && profilable && (
|
||||
<p className="flex flex-wrap items-center gap-2 text-2xs text-dim" data-catalog-profile-disabled="">
|
||||
<span>{PT.profileThisDisabled}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={profile.onSettings} data-catalog-profile-enable="">{PT.enable}</button>
|
||||
</p>
|
||||
)}
|
||||
{blocked && (
|
||||
<Notice tone={blocked.kind === 'busy' ? 'info' : 'warn'} attr={{ 'data-catalog-profile-blocked': blocked.kind }}>
|
||||
<p className="font-medium">{PT.blocked[blocked.kind] ?? PT.blocked.other}</p>
|
||||
<p className="mt-0.5 break-words text-dim">{blocked.message}</p>
|
||||
{blocked.where && <p className="mt-0.5 text-dim" data-catalog-profile-busy-where="">{blocked.where}</p>}
|
||||
{blocked.kind === 'busy' && (
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={() => profile.onOpenJob(null)} data-catalog-profile-progress="">{PT.viewProgress}</button>
|
||||
)}
|
||||
{blocked.kind === 'disabled' && (
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={profile.onSettings} data-catalog-profile-enable="">{PT.enable}</button>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{status}
|
||||
{!last && !liveActive && (
|
||||
<p className="text-xs text-dim" data-catalog-profile-never="">
|
||||
{!detail.in_schema ? PT.detailMissing : isView ? PT.detailView : !profilable ? PT.detailNoChecks : PT.detailNever}
|
||||
</p>
|
||||
)}
|
||||
{last && ended && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-dim" data-catalog-profile-last="">
|
||||
{LastIcon && <LastIcon size={11} className="shrink-0" style={{ color: PROFILE_TASK_ICON[last.status].color }} aria-hidden />}
|
||||
<span className="font-medium text-fg">{PT.taskStatus[last.status]}</span>
|
||||
{lastAt && <span className="tnum" title={formatDateTime(lastAt)}>{PT.detailLast(formatTime(lastAt))}</span>}
|
||||
{last.created_by && <span>· {PT.detailBy(last.created_by)}</span>}
|
||||
{last.queries > 0 && <span className="tnum">· {PT.tableQueries(last.queries)}</span>}
|
||||
<span className="flex-1" />
|
||||
{canOpenJob && (
|
||||
<button type="button" className="rounded px-1 text-2xs text-[var(--accent)] underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => profile.onOpenJob(last.job_id)} data-catalog-profile-job={last.job_id}>{PT.detailJob}</button>
|
||||
)}
|
||||
</div>
|
||||
{PT.findings(last.summary, true) && (
|
||||
<p className="text-xs" data-catalog-profile-findings="">{PT.findings(last.summary, true)}</p>
|
||||
)}
|
||||
<ProfileTaskBody detail={last} mode="detail" onFill={onFillCodes} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { AlertTriangle, History, Info, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogTableRow } from '../../types'
|
||||
import { useRadioGroup } from '../../components/ui'
|
||||
import type { CatalogProfileTaskStatus, CatalogTableRow } from '../../types'
|
||||
import { useRadioGroup, useTicker } from '../../components/ui'
|
||||
import { formatNumber } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_KIND_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import { profileCell } from './profile'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -19,11 +20,15 @@ import type { ListFilter, ListSort } from './model'
|
||||
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。上次起草失败、部分完成的表再多一行,
|
||||
// 写明原因(全文在悬停里,点开表还有「重新起草这张表」);上次起草有留空列(模型拿不准)的表另有一行淡色的轻提示,
|
||||
// 不是失败标记。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
|
||||
//
|
||||
// 开了数据剖析的源(或者剖析过的表)第二行多一格「剖析」:未剖析 / 3 天前剖析 / 剖析部分完成 / 剖析失败 / 剖析中 / 排队剖析,
|
||||
// 需要剖析的(从没剖析过、上次没做完、之后出现了新的待核实关系)用提醒色,悬停写上次剖析的时间和原因。筛选组下面另有
|
||||
// 「需要剖析」开关,和审阅进度的筛选叠加(审阅进度的四项数字照旧按审阅算),口径同剖析范围里的「需要剖析的表」。
|
||||
// ===========================================================================
|
||||
|
||||
export function TableIndex({
|
||||
rows, total, counts, query, onQuery, filter, onFilter, usedOnly = false, onUsedOnly, sort, onSort, selected, onSelected, active, onOpen,
|
||||
onDraft, onProfile,
|
||||
onDraft, onProfile, profileOn = false, profileLive, dueOnly = false, onDueOnly, dueTotal = 0,
|
||||
}: {
|
||||
/** 筛选、排序后的行 */
|
||||
rows: CatalogTableRow[]
|
||||
@@ -46,6 +51,15 @@ export function TableIndex({
|
||||
onDraft: () => void
|
||||
/** 剖析所选(数据剖析弹窗,范围默认是已选的表) */
|
||||
onProfile?: () => void
|
||||
/** 这个数据源开了数据剖析:每行写「剖析」一格,给「需要剖析」开关 */
|
||||
profileOn?: boolean
|
||||
/** 页面正跟着的剖析任务里各表的状态(排队、剖析中),比清单新 */
|
||||
profileLive?: Map<string, CatalogProfileTaskStatus>
|
||||
/** 只看需要剖析的表 */
|
||||
dueOnly?: boolean
|
||||
onDueOnly?: (v: boolean) => void
|
||||
/** 需要剖析的表有几张(按搜索词算) */
|
||||
dueTotal?: number
|
||||
}) {
|
||||
const radio = useRadioGroup(LIST_FILTERS, filter, onFilter)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
@@ -53,6 +67,8 @@ export function TableIndex({
|
||||
const lastClicked = useRef<number | null>(null)
|
||||
const picked = rows.filter((r) => selected.has(r.table_name)).length
|
||||
const all = rows.length > 0 && picked === rows.length
|
||||
// 「3 天前剖析」「12 分钟前剖析」隔一分钟重算一次
|
||||
const now = useTicker(60_000, profileOn)
|
||||
|
||||
useEffect(() => {
|
||||
if (allRef.current) allRef.current.indeterminate = picked > 0 && !all
|
||||
@@ -140,6 +156,17 @@ export function TableIndex({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{onDueOnly && (profileOn || dueOnly) && (
|
||||
<div className="flex">
|
||||
<button type="button" aria-pressed={dueOnly} onClick={() => onDueOnly(!dueOnly)} title={PT.dueFilterHint} data-catalog-profile-due={dueOnly ? 'on' : 'off'}
|
||||
className={clsx('inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-2xs outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
|
||||
dueOnly ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'border-dashed text-dim hover:bg-hover hover:text-fg')}>
|
||||
<ScanSearch size={10} className={dueOnly ? 'text-[var(--accent)]' : 'text-faint'} aria-hidden />
|
||||
{PT.dueFilter}
|
||||
<span className="tnum text-faint">{formatNumber(dueTotal)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{usedOnly && (
|
||||
<div className="flex" data-catalog-used-only="">
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-md border border-[var(--accent)] bg-accent-soft py-0.5 pl-2 pr-0.5 text-2xs text-fg"
|
||||
@@ -179,7 +206,7 @@ export function TableIndex({
|
||||
<ul ref={listRef} className="relative min-h-0 flex-1 overflow-y-auto" aria-label={CT.tableCount(rows.length, total)}>
|
||||
{rows.map((r, i) => (
|
||||
<Row key={r.table_name} row={r} index={i} active={r.table_name === active} checked={selected.has(r.table_name)}
|
||||
onToggle={toggle} onOpen={onOpen} onKey={onRowKey} />
|
||||
onToggle={toggle} onOpen={onOpen} onKey={onRowKey} profileOn={profileOn} live={profileLive?.get(r.table_name) ?? null} now={now} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -199,7 +226,9 @@ export function TableIndex({
|
||||
)
|
||||
}
|
||||
|
||||
const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen, onKey }: {
|
||||
const PROFILE_TONE = { due: 'var(--st-waiting)', bad: 'var(--err)', active: 'var(--accent)', quiet: undefined } as const
|
||||
|
||||
const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen, onKey, profileOn, live, now }: {
|
||||
row: CatalogTableRow
|
||||
index: number
|
||||
active: boolean
|
||||
@@ -207,7 +236,12 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
onToggle: (i: number, e: ReactMouseEvent<HTMLInputElement>) => void
|
||||
onOpen: (table: string) => void
|
||||
onKey: (i: number, e: ReactKeyboardEvent<HTMLButtonElement>) => void
|
||||
profileOn: boolean
|
||||
live: CatalogProfileTaskStatus | null
|
||||
now: number
|
||||
}) {
|
||||
// 「剖析」一格:开了剖析的源每行都写;没开的源只写剖析过的表(以前开过,结论还在目录里)
|
||||
const cell = profileOn || r.last_profile || live ? profileCell(r, live, now) : null
|
||||
const progress = progressOf(r.counts)
|
||||
// 上次起草没写全:失败、部分完成各一种说法,后面接原因
|
||||
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
|
||||
@@ -254,6 +288,11 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
{r.is_view && <span className="shrink-0">· {CT.view}</span>}
|
||||
{r.relations > 0 && <span className="shrink-0 tnum">· {CT.relationCount(r.relations)}</span>}
|
||||
<span className="flex-1" />
|
||||
{cell && (
|
||||
<span className="min-w-0 shrink truncate" title={cell.title} style={{ color: PROFILE_TONE[cell.tone] }} data-row-profile={cell.tone}>
|
||||
<span className="sr-only">{PT.title}:</span>{cell.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="tnum shrink-0" title={CT.usageHint} data-row-usage={r.usage}>{CT.usage(r.usage)}</span>
|
||||
</span>
|
||||
{!r.in_schema && (
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CatalogActiveDraftJob, CatalogDraftJob, CatalogDraftTask, CatalogD
|
||||
//
|
||||
// 取回来的状态只往前走:已经结束的任务不会被一个晚到的「进行中」翻回去,已结束的表数不减少(停止接口的回答和
|
||||
// 正在路上的那次取进度谁先到都一样)。
|
||||
//
|
||||
// 同一个数据源的起草任务和剖析任务按建立顺序排队(服务端每个源一条队):起草建在剖析之后时是排队、waiting_for 为
|
||||
// profile,剖析结束后自动开始,界面照这样说(waitsForProfile)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 进行中多久取一次进度 */
|
||||
@@ -26,6 +29,10 @@ const DRAFT_FINISHED = 'catalog_draft_finished'
|
||||
export const isJobActive = (j: { status: string } | null | undefined): boolean =>
|
||||
!!j && (j.status === 'queued' || j.status === 'running')
|
||||
|
||||
/** 起草任务排在同一个数据源的剖析任务后面(剖析结束后自动开始)。老服务端没有 waiting_for */
|
||||
export const waitsForProfile = (j: { status: string; waiting_for?: string | null } | null | undefined): boolean =>
|
||||
!!j && j.status === 'queued' && j.waiting_for === 'profile'
|
||||
|
||||
const TASK_ENDED = new Set<CatalogDraftTaskStatus>(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
|
||||
/** 「只重试失败的表」带上的:失败、部分完成、已停止 */
|
||||
export const RETRYABLE = new Set<CatalogDraftTaskStatus>(['failed', 'partial', 'cancelled'])
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CatalogBusinessDate, CatalogColumnNotes, CatalogCounts, CatalogDetail, CatalogItem, CatalogNotes, CatalogRelation, CatalogSource, CatalogStatus,
|
||||
CatalogStructureColumn, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { isProfileDue } from './profile'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据目录界面的纯函数:清单的筛选和排序、单表目录摊平成「项」、编辑表单和目录之间的来回转换、表单校验。
|
||||
@@ -114,11 +115,13 @@ export const isUsed = (r: CatalogTableRow): boolean => r.in_schema && r.usage >
|
||||
|
||||
/**
|
||||
* 筛选并排序。默认保持服务端的顺序(使用次数多的在前,次数相同按表结构里的顺序);表结构里已经没有的表
|
||||
* 不论怎么排都在最后。used:只看运行中查询过的表(从顶部摘要点进来时带上,和摘要的数字对得上)
|
||||
* 不论怎么排都在最后。used:只看运行中查询过的表(从顶部摘要点进来时带上,和摘要的数字对得上);
|
||||
* due:只看需要剖析的表(和剖析范围「需要剖析的表」同一个口径)。这两项和审阅进度的筛选叠加
|
||||
*/
|
||||
export function visibleRows(rows: CatalogTableRow[], q: string, filter: ListFilter, sort: ListSort, used = false): CatalogTableRow[] {
|
||||
export function visibleRows(rows: CatalogTableRow[], q: string, filter: ListFilter, sort: ListSort, used = false, due = false): CatalogTableRow[] {
|
||||
const order = new Map(rows.map((r, i) => [r.table_name, i]))
|
||||
const picked = rows.filter((r) => matchesQuery(r, q) && (!used || isUsed(r)) && (filter === 'all' || progressOf(r.counts) === filter))
|
||||
const picked = rows.filter((r) => matchesQuery(r, q) && (!used || isUsed(r)) && (!due || isProfileDue(r))
|
||||
&& (filter === 'all' || progressOf(r.counts) === filter))
|
||||
const idx = (r: CatalogTableRow) => order.get(r.table_name) ?? 0
|
||||
return picked.sort((a, b) => {
|
||||
if (a.in_schema !== b.in_schema) return a.in_schema ? -1 : 1
|
||||
@@ -144,22 +147,27 @@ export function usageSummary(rows: CatalogTableRow[]): { used: number; pending:
|
||||
return out
|
||||
}
|
||||
|
||||
/** 各筛选项下有几张表(筛选按钮上的数字,按搜索词和「只看运行中查询过的表」算) */
|
||||
export function filterCounts(rows: CatalogTableRow[], q: string, used = false): Record<ListFilter, number> {
|
||||
/** 各筛选项下有几张表(筛选按钮上的数字,按搜索词、「只看运行中查询过的表」「只看需要剖析的表」算) */
|
||||
export function filterCounts(rows: CatalogTableRow[], q: string, used = false, due = false): Record<ListFilter, number> {
|
||||
const out: Record<ListFilter, number> = { all: 0, pending: 0, done: 0, none: 0 }
|
||||
for (const r of rows) {
|
||||
if (!matchesQuery(r, q) || (used && !isUsed(r))) continue
|
||||
if (!matchesQuery(r, q) || (used && !isUsed(r)) || (due && !isProfileDue(r))) continue
|
||||
out.all++
|
||||
out[progressOf(r.counts)]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 「需要剖析」那个开关上的数字:按搜索词和「只看运行中查询过的表」算,不管审阅进度筛到哪一项 */
|
||||
export function dueCount(rows: CatalogTableRow[], q: string, used = false): number {
|
||||
return rows.filter((r) => matchesQuery(r, q) && (!used || isUsed(r)) && isProfileDue(r)).length
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单的筛选、搜索、排序记在地址栏里(刷新、前进后退、把链接发给同事都不丢):q 搜索词,filter 筛选,sort 排序,
|
||||
* used=1 只看运行中查询过的表。默认值不写进地址;认不出的值按默认
|
||||
* used=1 只看运行中查询过的表,profile=due 只看需要剖析的表。默认值不写进地址;认不出的值按默认
|
||||
*/
|
||||
export interface ListParams { query: string; filter: ListFilter; sort: ListSort; used: boolean }
|
||||
export interface ListParams { query: string; filter: ListFilter; sort: ListSort; used: boolean; due?: boolean }
|
||||
|
||||
export function listParamsOf(p: URLSearchParams): ListParams {
|
||||
const filter = p.get('filter') as ListFilter | null
|
||||
@@ -169,6 +177,7 @@ export function listParamsOf(p: URLSearchParams): ListParams {
|
||||
filter: filter && LIST_FILTERS.includes(filter) ? filter : 'all',
|
||||
sort: sort && LIST_SORTS.includes(sort) ? sort : 'usage',
|
||||
used: p.get('used') === '1',
|
||||
due: p.get('profile') === 'due',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +189,7 @@ export function writeListParams(p: URLSearchParams, s: ListParams): URLSearchPar
|
||||
put('filter', s.filter === 'all' ? null : s.filter)
|
||||
put('sort', s.sort === 'usage' ? null : s.sort)
|
||||
put('used', s.used ? '1' : null)
|
||||
put('profile', s.due ? 'due' : null)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import type { CatalogProfileNumberKey, CatalogProfileSettings } from '../../types'
|
||||
import type {
|
||||
CatalogLastProfile, CatalogProfileNumberKey, CatalogProfileSettings, CatalogProfileSize, CatalogProfileTaskStatus, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { PROFILE_TEXT } from '../../lib/terms'
|
||||
import { formatTime, parseServerTime } from '../../lib/format'
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析的设置:数据源 options.catalog_profile 和表单之间的来回转换、校验、保存被拒时认出是哪一项。
|
||||
// 规则跟着服务端(backend/app/data/catalog_profile.py 的 ProfileSettings、profile_settings、
|
||||
// profile_settings_problem)走:缺省即关闭;读的时候宽松(某一项不对就用缺省),写的时候严格(超出范围的拒收)。
|
||||
//
|
||||
// 预算按表算(每张表最多查询数、每张表时长上限),没有整次的总量上限。以前的「查询次数上限」「总时长上限」按整次剖析算,
|
||||
// 含义不同:服务端启动时把这两个旧键删掉、保存时摘掉,这里读的时候也不认它们(表单里没有、保存时不带)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 数据源 options 里剖析设置的键 */
|
||||
export const PROFILE_OPTION = 'catalog_profile'
|
||||
|
||||
/** 设置里的数值项,也是表单上的顺序:先是按表的两项预算,再是单条查询和抽样 */
|
||||
export const PROFILE_NUMBER_KEYS: readonly CatalogProfileNumberKey[] = [
|
||||
'max_queries', 'query_timeout_s', 'sample_size', 'max_scan_rows', 'max_total_s',
|
||||
'table_max_queries', 'table_max_s', 'query_timeout_s', 'sample_size', 'max_scan_rows',
|
||||
]
|
||||
|
||||
/** 缺省值(服务端 ProfileSettings 的默认) */
|
||||
export const PROFILE_DEFAULTS: CatalogProfileSettings = {
|
||||
enabled: false, max_queries: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100_000, max_total_s: 120,
|
||||
enabled: false, table_max_queries: 40, table_max_s: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100_000,
|
||||
}
|
||||
|
||||
/** 各数值项的范围(服务端 _NUMBER_FIELDS):下限、上限、是否必须是整数 */
|
||||
export const PROFILE_LIMITS: Record<CatalogProfileNumberKey, { min: number; max: number; integer: boolean }> = {
|
||||
max_queries: { min: 1, max: 500, integer: true },
|
||||
table_max_queries: { min: 1, max: 200, integer: true },
|
||||
table_max_s: { min: 10, max: 600, integer: false },
|
||||
query_timeout_s: { min: 1, max: 60, integer: false },
|
||||
sample_size: { min: 10, max: 10_000, integer: true },
|
||||
max_scan_rows: { min: 0, max: 10_000_000, integer: true },
|
||||
max_total_s: { min: 10, max: 600, integer: false },
|
||||
}
|
||||
|
||||
/** 一个数:数字和数字写成的文字都认(同服务端 _number)。不合规返回 null */
|
||||
@@ -80,7 +87,8 @@ export const hasProfileOption = (options: Record<string, unknown> | null | undef
|
||||
|
||||
/**
|
||||
* 表单 → 要写进 options 的设置。原来没有(stored 为假)、现在也没开、一项都没填的不写(不给 options 平白多一个键);
|
||||
* 其余写开关和填了的项。填的不是数时原样交上去,由服务端拒收并说明是哪一项(表单上已经先拦过一次)
|
||||
* 其余写开关和填了的项。填的不是数时原样交上去,由服务端拒收并说明是哪一项(表单上已经先拦过一次)。
|
||||
* 旧的两个键(整次的查询次数上限、总时长上限)不在表单里,保存时不带——服务端也会摘掉
|
||||
*/
|
||||
export function profileOptionOf(form: ProfileForm, stored: boolean): Record<string, unknown> | undefined {
|
||||
const filled = PROFILE_NUMBER_KEYS.filter((k) => form.values[k].trim())
|
||||
@@ -107,7 +115,7 @@ export function profileProblems(form: ProfileForm): Partial<Record<CatalogProfil
|
||||
return out
|
||||
}
|
||||
|
||||
/** 表单此刻对应的设置:没填、填错的项按缺省(风险说明据此写出「最多 N 条」) */
|
||||
/** 表单此刻对应的设置:没填、填错的项按缺省(风险说明据此写出「每张表最多 N 条」) */
|
||||
export function profileSettingsOfForm(form: ProfileForm): CatalogProfileSettings {
|
||||
const out: CatalogProfileSettings = { ...PROFILE_DEFAULTS, enabled: form.enabled }
|
||||
for (const k of PROFILE_NUMBER_KEYS) {
|
||||
@@ -136,14 +144,41 @@ export function profileFieldOfRejection(e: { code?: string; field?: string }): '
|
||||
: 'section'
|
||||
}
|
||||
|
||||
/** 设置里有没有剖析设置这一项(options 的其余键原样带上) */
|
||||
export function withProfileOption(options: Record<string, unknown> | null | undefined, value: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
const { [PROFILE_OPTION]: _old, ...rest } = options ?? {}
|
||||
return value ? { ...rest, [PROFILE_OPTION]: value } : rest
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 剖析:范围、报告里的读数、409 的分类
|
||||
// 剖析:范围、读数、409 的分类
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 不指定表时服务端剖析几张(PROFILE_DEFAULT_TABLES) */
|
||||
export const PROFILE_DEFAULT_TABLES = 10
|
||||
/** 一次最多指定几张表(接口 tables 的 max_length) */
|
||||
export const PROFILE_MAX_TABLES = 200
|
||||
/** 一次最多指定几张表(接口 tables 的上限) */
|
||||
export const PROFILE_MAX_TABLES = 500
|
||||
|
||||
/**
|
||||
* 能不能剖析这张表:在表结构里、不是视图、有可剖析的项(profile_checks > 0)。老服务端不给 profile_checks 时不知道,
|
||||
* 按能剖析算(交给服务端,剖析不了的它会记跳过)
|
||||
*/
|
||||
export const isProfilable = (r: Pick<CatalogTableRow, 'in_schema' | 'is_view' | 'profile_checks'>): boolean =>
|
||||
r.in_schema && !r.is_view && (r.profile_checks == null || r.profile_checks > 0)
|
||||
|
||||
/** 服务端「需要剖析」的口径:能剖析、并且 profile_due(从没剖析过、上次没做完、之后出现了新的待核实关系) */
|
||||
export const isProfileDue = (r: CatalogTableRow): boolean => isProfilable(r) && r.profile_due === true
|
||||
|
||||
/** 范围的几张表:needed 需要剖析的、all 全部能剖析的(服务端挑表的顺序:有待核实关系的在前,再按使用次数) */
|
||||
export function profileScopeTables(rows: CatalogTableRow[], scope: 'needed' | 'all'): string[] {
|
||||
return rows.filter(scope === 'needed' ? isProfileDue : isProfilable).map((r) => r.table_name)
|
||||
}
|
||||
|
||||
/** 因为运行原因没查成的项(计入 unfinished、使表成为部分完成);其余跳过的原因是按规则不查 */
|
||||
export const UNFINISHED_REASONS = new Set(['budget', 'deadline', 'failed', 'timeout', 'error', 'rejected', 'slow', 'cancelled'])
|
||||
|
||||
const TASK_ENDED = new Set<CatalogProfileTaskStatus>(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
|
||||
export const isProfileTaskEnded = (t: { status: CatalogProfileTaskStatus }): boolean => TASK_ENDED.has(t.status)
|
||||
/** 「重试没做完的表」带上的:失败、部分完成、已停止(同服务端 retry) */
|
||||
export const PROFILE_RETRYABLE = new Set<CatalogProfileTaskStatus>(['failed', 'partial', 'cancelled'])
|
||||
|
||||
/** 覆盖率写成百分数:最多一位小数,不到 100% 的不进成 100%(同服务端 _percent) */
|
||||
export function coverageText(ratio: number): string {
|
||||
@@ -151,22 +186,94 @@ export function coverageText(ratio: number): string {
|
||||
return text === '100' && ratio < 1 ? '99.9%' : `${text}%`
|
||||
}
|
||||
|
||||
/** 已用时长的读数:「不到 1 秒」「8 秒」「1 分 05 秒」 */
|
||||
/** 用时的读数:「不到 1 秒」「8 秒」「1 分 05 秒」「1 小时 02 分」 */
|
||||
export function secondsText(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 1) return '不到 1 秒'
|
||||
return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒`
|
||||
if (s < 60) return `${s} 秒`
|
||||
if (s < 3600) return `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒`
|
||||
return `${Math.floor(s / 3600)} 小时 ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')} 分`
|
||||
}
|
||||
|
||||
/** 剖析接口回 409 的几种情况:按机读码认(服务端 api/coded.py,api/catalog.profile_catalog_tables 带上) */
|
||||
export type ProfileBlock = 'disabled' | 'inactive' | 'noSchema' | 'busy' | 'tampered' | 'other'
|
||||
/** 预算估算里的时长:「50 秒」「40 分钟」「2 小时 10 分钟」(只是上限的估算,不写到秒) */
|
||||
export function spanText(secs: number): string {
|
||||
const s = Math.max(0, Math.round(secs))
|
||||
if (s < 60) return `${s} 秒`
|
||||
const m = Math.round(s / 60)
|
||||
if (m < 60) return `${m} 分钟`
|
||||
const h = Math.floor(m / 60)
|
||||
return m % 60 ? `${h} 小时 ${m % 60} 分钟` : `${h} 小时`
|
||||
}
|
||||
|
||||
/** 多久以前:「刚刚」「12 分钟前」「3 小时前」「3 天前」,一个月以上写日期(表清单上「3 天前剖析」) */
|
||||
export function agoText(v: string | null | undefined, now = Date.now()): string {
|
||||
const d = parseServerTime(v)
|
||||
if (!d) return ''
|
||||
const diff = now - d.getTime()
|
||||
if (diff < 45_000) return '刚刚'
|
||||
const m = Math.round(diff / 60_000)
|
||||
if (m < 60) return `${Math.max(1, m)} 分钟前`
|
||||
const h = Math.round(diff / 3_600_000)
|
||||
if (h < 24) return `${h} 小时前`
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
return days <= 30 ? `${days} 天前` : formatTime(d)
|
||||
}
|
||||
|
||||
/** 行数估算写成人话,带来源(统计信息 / 数到上限);没有估算为 null */
|
||||
export function sizeText(size: CatalogProfileSize | null | undefined): { text: string; hint?: string; source?: string } | null {
|
||||
if (!size) return null
|
||||
if (size.rows != null) {
|
||||
return { text: size.method === 'stats' ? PROFILE_TEXT.rowsStats(size.rows) : PROFILE_TEXT.rowsCount(size.rows),
|
||||
hint: PROFILE_TEXT.rowsHint[size.method], source: PROFILE_TEXT.rowsSource[size.method] }
|
||||
}
|
||||
if (size.at_least != null) {
|
||||
return { text: PROFILE_TEXT.rowsAtLeast(size.at_least), hint: PROFILE_TEXT.rowsHint.count, source: PROFILE_TEXT.rowsSource.count }
|
||||
}
|
||||
return { text: PROFILE_TEXT.rowsUnknown }
|
||||
}
|
||||
|
||||
/**
|
||||
* 表清单上「剖析」那一列:这张表剖析到哪了。live 是页面正跟着的剖析任务里这张表的状态(排队、剖析中),比清单新;
|
||||
* 没有就按清单上的最近一次剖析。tone:due 需要剖析(从没剖析过、没做完、有新关系),bad 失败,quiet 其余
|
||||
*/
|
||||
export function profileCell(r: CatalogTableRow, live: CatalogProfileTaskStatus | null, now = Date.now()):
|
||||
{ text: string; title: string; tone: 'due' | 'bad' | 'active' | 'quiet' } | null {
|
||||
const T = PROFILE_TEXT
|
||||
if (live === 'pending' || live === 'running') return { text: T.listStatus[live], title: T.listStatus[live], tone: 'active' }
|
||||
const last: CatalogLastProfile | null | undefined = r.last_profile
|
||||
if (last === undefined && r.profile_checks === undefined) return null
|
||||
if (last && (last.status === 'pending' || last.status === 'running')) {
|
||||
return { text: T.listStatus[last.status], title: T.listStatus[last.status], tone: 'active' }
|
||||
}
|
||||
if (!last) {
|
||||
if (!r.in_schema || r.is_view) return null
|
||||
if (r.profile_checks === 0) return { text: T.listNoChecks, title: T.listNoChecksHint, tone: 'quiet' }
|
||||
return { text: T.listNever, title: T.dueReason.never, tone: 'due' }
|
||||
}
|
||||
const at = last.finished_at ?? last.started_at
|
||||
const when = at ? formatTime(at) : ''
|
||||
const label = last.status === 'ok' ? T.listAgo(agoText(at, now)) : T.listStatus[last.status] ?? last.status
|
||||
const title = [T.listHint(T.taskStatus[last.status] ?? last.status, when), last.error ?? '',
|
||||
r.profile_due && r.profile_due_reason ? T.dueReason[r.profile_due_reason] : ''].filter(Boolean).join('。')
|
||||
if (last.status === 'failed') return { text: label, title, tone: 'bad' }
|
||||
if (r.profile_due && r.profile_due_reason === 'new_relations' && isProfilable(r)) return { text: T.listNewRelations, title, tone: 'due' }
|
||||
return { text: label, title, tone: r.profile_due && isProfilable(r) ? 'due' : 'quiet' }
|
||||
}
|
||||
|
||||
/** 剖析接口回 409 的几种情况:按机读码认(服务端 api/coded.py) */
|
||||
export type ProfileBlock = 'disabled' | 'inactive' | 'noSchema' | 'busy' | 'tampered' | 'nothing' | 'other'
|
||||
|
||||
/** 这个源已有排队、进行中的剖析任务;已经结束的任务不需要停止 */
|
||||
export const PROFILE_BUSY = 'profile_busy'
|
||||
export const PROFILE_FINISHED = 'catalog_profile_finished'
|
||||
|
||||
const PROFILE_BLOCK_CODES: Record<string, ProfileBlock> = {
|
||||
profile_disabled: 'disabled',
|
||||
datasource_inactive: 'inactive',
|
||||
schema_missing: 'noSchema',
|
||||
profile_busy: 'busy',
|
||||
[PROFILE_BUSY]: 'busy',
|
||||
snapshot_tampered: 'tampered',
|
||||
profile_nothing: 'nothing',
|
||||
}
|
||||
|
||||
/** 没带机读码(老服务端)或认不出的按「无法开始剖析」说,给重试;不从原话里猜 */
|
||||
@@ -174,11 +281,5 @@ export function profileBlockOf(e: { code?: string }): ProfileBlock {
|
||||
return (e.code && PROFILE_BLOCK_CODES[e.code]) || 'other'
|
||||
}
|
||||
|
||||
/** 设置里有没有剖析设置这一项(options 的其余键原样带上) */
|
||||
export function withProfileOption(options: Record<string, unknown> | null | undefined, value: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
const { [PROFILE_OPTION]: _old, ...rest } = options ?? {}
|
||||
return value ? { ...rest, [PROFILE_OPTION]: value } : rest
|
||||
}
|
||||
|
||||
/** 剖析报告里一列码值的键(表名 + 列名):从报告去填含义、填完回到报告时按它对上是哪一列 */
|
||||
/** 剖析结果里一列码值的键(表名 + 列名):从结果去填含义、填完回到结果时按它对上是哪一列 */
|
||||
export const fillKey = (table: string, column: string): string => `${table}\u0000${column}`
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogActiveProfileJob, CatalogProfileJob, CatalogProfileTaskRow, CatalogProfileTaskStatus } from '../../types'
|
||||
import { PROFILE_BUSY, PROFILE_FINISHED, PROFILE_RETRYABLE, isProfileTaskEnded } from './profile'
|
||||
import { isJobActive } from './draftJob'
|
||||
|
||||
// ===========================================================================
|
||||
// 剖析任务(/api/datasources/{id}/catalog/profile-jobs)在页面上的状态:建、停、重试,进行中每 1.5 秒取一次进度,结束就停。
|
||||
// 照起草任务(draftJob.ts 的 useDraftJob)的做法:放在页面上而不是对话框里,关掉对话框任务照常进行,页头的进度入口、
|
||||
// 结束时的提示都靠它;再打开对话框看到的还是同一个任务。
|
||||
//
|
||||
// 取回来的状态只往前走:已经结束的任务不会被一个晚到的「进行中」翻回去,已结束的表数不减少(停止接口的回答和正在路上的
|
||||
// 那次取进度谁先到都一样)。
|
||||
//
|
||||
// 建任务、重试撞上 409 时照原样抛给调用方:剖析的几种 409(未开启、停用、没有表结构、快照被改动、正忙、挑不出表)各有
|
||||
// 说法和下一步,由对话框、表详情去说;正忙时可以用 showActive 改去跟着进行中的那一个。
|
||||
// ===========================================================================
|
||||
|
||||
/** 进行中多久取一次进度(同起草) */
|
||||
export const PROFILE_POLL_MS = 1500
|
||||
|
||||
export const retryableTasks = (j: CatalogProfileJob): CatalogProfileTaskRow[] => j.tasks.filter((t) => PROFILE_RETRYABLE.has(t.status))
|
||||
|
||||
export interface ProfileStartBody { tables?: string[]; scope?: 'needed' | 'all' }
|
||||
|
||||
export function useProfileJob(sourceId: string, active: CatalogActiveProfileJob | null | undefined, on: {
|
||||
/** 这一次取回来新结束的表(写进目录了,或者失败、停止了):重取清单,正在看的表在里面就重新载入 */
|
||||
onTablesEnded: (tables: string[]) => void
|
||||
/** 看着它从排队、进行中变成结束(这一页上亲眼看到的,进页面时已经结束的不算) */
|
||||
onFinished: (job: CatalogProfileJob) => void
|
||||
}) {
|
||||
// 任务挂在数据源上:换了数据源,上一个源的任务不再显示(那边照常进行)
|
||||
const [held, setHeld] = useState<{ src: string; job: CatalogProfileJob } | null>(null)
|
||||
const job = held?.src === sourceId ? held.job : null
|
||||
const jobRef = useRef(job)
|
||||
jobRef.current = job
|
||||
const sourceRef = useRef(sourceId)
|
||||
sourceRef.current = sourceId
|
||||
const onRef = useRef(on)
|
||||
onRef.current = on
|
||||
/** 点了停止、服务端还没停下来的任务 */
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
/** 取进度失败(网络抖了一下)、或取回来的是旧状态时,原样再排一拍 */
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
/** 换上取回来的状态。不往回走;返回是否换上了 */
|
||||
const accept = useCallback((next: CatalogProfileJob, src: string): boolean => {
|
||||
if (src !== sourceRef.current) return false
|
||||
const cur = jobRef.current
|
||||
const same = cur?.id === next.id
|
||||
if (same && cur) {
|
||||
if (!isJobActive(cur) && isJobActive(next)) return false
|
||||
if (isJobActive(next) && next.done < cur.done) return false
|
||||
}
|
||||
// 新结束的表:同一个任务比上一次多结束的;头一回见到的进行中任务(别处建的、刷新页面前建的)已经结束的那些也算,
|
||||
// 清单可能还没跟上。头一回见到的已结束任务(点开上一次的结果)不算,那些早就写进去了。跳过的表没写目录,不算
|
||||
const before = new Map<string, CatalogProfileTaskStatus>((same && cur ? cur.tasks : []).map((t) => [t.table_name, t.status]))
|
||||
const ended = same || isJobActive(next)
|
||||
? next.tasks
|
||||
.filter((t) => isProfileTaskEnded(t) && t.status !== 'skipped' && !isProfileTaskEnded({ status: before.get(t.table_name) ?? 'pending' }))
|
||||
.map((t) => t.table_name)
|
||||
: []
|
||||
jobRef.current = next
|
||||
setHeld({ src, job: next })
|
||||
if (ended.length) onRef.current.onTablesEnded(ended)
|
||||
if (same && cur && isJobActive(cur) && !isJobActive(next)) onRef.current.onFinished(next)
|
||||
return true
|
||||
}, [])
|
||||
|
||||
/** 换成另一个任务(刚建的、重试建的、正忙时进行中的那个、对话框和表详情里点开的上一次)。同一个任务照常只往前走 */
|
||||
const track = useCallback((next: CatalogProfileJob) => {
|
||||
accept(next, sourceRef.current)
|
||||
}, [accept])
|
||||
|
||||
// 进行中:隔 1.5 秒取一次,结束就停
|
||||
const jobId = job?.id
|
||||
const jobActive = isJobActive(job)
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobActive) return
|
||||
const src = sourceId
|
||||
let alive = true
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.profileJobs.get(src, jobId)
|
||||
if (alive && !accept(next, src)) setTick((n) => n + 1)
|
||||
} catch (e) {
|
||||
if (!alive) return
|
||||
// 任务没了(不属于这个数据源、库被换掉了):不再跟着它;别的失败(网络抖动、服务重启中)下一拍再取
|
||||
if (e instanceof ApiError && e.status === 404) setHeld((h) => (h?.job.id === jobId ? null : h))
|
||||
else setTick((n) => n + 1)
|
||||
}
|
||||
}, PROFILE_POLL_MS)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [sourceId, jobId, jobActive, job, tick, accept])
|
||||
|
||||
// 清单说有进行中的任务、这一页还不知道(别的标签页建的、刷新了页面、表详情里建的):取来跟着它
|
||||
const activeId = isJobActive(active) ? active?.id : undefined
|
||||
useEffect(() => {
|
||||
if (!activeId) return
|
||||
const cur = jobRef.current
|
||||
if (cur?.id === activeId) return
|
||||
const src = sourceId
|
||||
let alive = true
|
||||
api.dataCatalog.profileJobs.get(src, activeId).then(({ job: next }) => { if (alive) accept(next, src) }, () => {})
|
||||
return () => { alive = false }
|
||||
}, [sourceId, activeId, accept])
|
||||
|
||||
/** 建任务。409、422 等照原样抛给调用方 */
|
||||
const start = useCallback(async (body: ProfileStartBody): Promise<CatalogProfileJob> => {
|
||||
const src = sourceRef.current
|
||||
const { job: next } = await api.dataCatalog.profileJobs.create(src, body)
|
||||
accept(next, src)
|
||||
return next
|
||||
}, [accept])
|
||||
|
||||
/** 重试没做完的表:新任务只含失败、部分完成、已停止的表。409(正忙)、422(没有可重试的)照原样抛 */
|
||||
const retry = useCallback(async (from: CatalogProfileJob): Promise<CatalogProfileJob> => {
|
||||
const src = sourceRef.current
|
||||
const { job: next } = await api.dataCatalog.profileJobs.retry(src, from.id)
|
||||
accept(next, src)
|
||||
return next
|
||||
}, [accept])
|
||||
|
||||
/** 正忙(409 profile_busy)时改去跟着进行中的那一个;没有了返回 null */
|
||||
const showActive = useCallback(async (): Promise<CatalogProfileJob | null> => {
|
||||
const src = sourceRef.current
|
||||
const { job: current } = await api.dataCatalog.profileJobs.active(src)
|
||||
if (current) accept(current, src)
|
||||
return current
|
||||
}, [accept])
|
||||
|
||||
/**
|
||||
* 停止。服务端停下正在执行的那条查询(由数据库按单条时限截止)之前任务还是进行中:这期间 stopping 为这个任务。
|
||||
* 已经结束了(409)就取一次最新的
|
||||
*/
|
||||
const cancel = useCallback(async () => {
|
||||
const cur = jobRef.current
|
||||
if (!cur || !isJobActive(cur)) return
|
||||
const src = sourceRef.current
|
||||
setStopping(cur.id)
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.profileJobs.cancel(src, cur.id)
|
||||
accept(next, src)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409 && e.code === PROFILE_FINISHED) {
|
||||
const { job: next } = await api.dataCatalog.profileJobs.get(src, cur.id)
|
||||
accept(next, src)
|
||||
return
|
||||
}
|
||||
setStopping(null)
|
||||
throw e
|
||||
}
|
||||
}, [accept])
|
||||
|
||||
return { job, track, start, retry, showActive, cancel, stopping: !!job && stopping === job.id && isJobActive(job) }
|
||||
}
|
||||
|
||||
/** 正忙(409)的错误:这个源已有排队、进行中的剖析任务 */
|
||||
export const isProfileBusy = (e: unknown): boolean => e instanceof ApiError && e.status === 409 && e.code === PROFILE_BUSY
|
||||
+198
-31
@@ -2839,6 +2839,17 @@ export interface CatalogTableRow {
|
||||
updated_by: string | null
|
||||
/** 这张表最近一次起草任务里的那一项(任务还在进行就是进行中的状态);没起草过为 null。老服务端没有这个键 */
|
||||
last_draft?: CatalogLastDraft | null
|
||||
/** 这张表最近一次剖析(任务还在进行就是进行中的状态);没剖析过为 null。老服务端没有这个键 */
|
||||
last_profile?: CatalogLastProfile | null
|
||||
/** 按「需要剖析」的口径这张表要不要剖析(表结构里、不是视图、有可剖析的项,且满足 profile_due_reason 之一) */
|
||||
profile_due?: boolean
|
||||
/**
|
||||
* 为什么需要剖析:never 从没剖析过;unfinished 最近一次剖析没做完(部分完成、失败、已停止);new_relations 目录里有待核实的
|
||||
* 关系不在最近一次剖析的检查项里。不需要时为 null
|
||||
*/
|
||||
profile_due_reason?: CatalogProfileDueReason | null
|
||||
/** 按当前目录和表结构这张表要做几项检查;0 表示没有可剖析的项(视图为 0) */
|
||||
profile_checks?: number
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/catalog */
|
||||
@@ -2857,6 +2868,8 @@ export interface CatalogList {
|
||||
guidance?: string | null
|
||||
/** 排队或进行中的起草任务(页面据此显示进度);没有为 null。老服务端没有这一项 */
|
||||
active_job?: CatalogActiveDraftJob | null
|
||||
/** 排队或进行中的剖析任务(页面据此显示进度);没有为 null。老服务端没有这一项 */
|
||||
active_profile_job?: CatalogActiveProfileJob | null
|
||||
}
|
||||
|
||||
/** 表结构里的一列 */
|
||||
@@ -2899,6 +2912,8 @@ export interface CatalogDetail {
|
||||
structure: CatalogStructure | null
|
||||
system_notes: boolean
|
||||
usage: number
|
||||
/** 这张表最近一次剖析的完整结果(含检查清单、发现、发过的查询;任务还在进行就是进行中的状态);没剖析过为 null */
|
||||
last_profile?: CatalogProfileTaskDetail | null
|
||||
}
|
||||
|
||||
/** 单项审阅:确认、驳回、撤销审阅(回到来源的初始状态;人工填写的项直接删掉) */
|
||||
@@ -3027,6 +3042,11 @@ export interface CatalogDraftJob {
|
||||
notice?: string | null
|
||||
/** 任务整体出错的原因 */
|
||||
error: string | null
|
||||
/**
|
||||
* 排在同一个数据源的剖析任务后面(起草和剖析同源串行,按建立顺序):剖析结束后自动开始。不在等时为 null;
|
||||
* 老服务端没有这个键
|
||||
*/
|
||||
waiting_for?: 'profile' | null
|
||||
tasks: CatalogDraftTask[]
|
||||
}
|
||||
|
||||
@@ -3036,6 +3056,8 @@ export interface CatalogActiveDraftJob {
|
||||
status: CatalogDraftJobStatus
|
||||
total: number
|
||||
done: number
|
||||
/** 排在剖析任务后面(同 CatalogDraftJob.waiting_for) */
|
||||
waiting_for?: 'profile' | null
|
||||
}
|
||||
|
||||
/** 清单一行上这张表最近一次起草的结果 */
|
||||
@@ -3303,26 +3325,68 @@ export interface CatalogImpact {
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数据剖析(后端 app/data/catalog_profile.py):对业务库发少量只读查询,核实推断的关联关系、取码值候选、
|
||||
// 提议业务日期。默认关闭,按数据源在 options.catalog_profile 里开启。
|
||||
// 数据剖析(后端 app/data/catalog_profile.py、catalog_profile_jobs.py):对业务库发少量只读查询,核实推断的关联关系、
|
||||
// 取码值候选、提议业务日期。默认关闭,按数据源在 options.catalog_profile 里开启。
|
||||
// 剖析是服务端的后台任务(/api/datasources/{id}/catalog/profile-jobs):逐表剖析,每张表有自己的查询次数和时长预算,
|
||||
// 每张表一结束就写进目录;查询一条一条发,可以随时停止。和同一个数据源的起草任务按建立顺序排队,一个结束了另一个才开始。
|
||||
// 服务重启时还没结束的任务记 interrupted,不自动续跑(由人点「重试没做完的表」)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 剖析设置里的数值项 */
|
||||
export type CatalogProfileNumberKey = 'max_queries' | 'query_timeout_s' | 'sample_size' | 'max_scan_rows' | 'max_total_s'
|
||||
export type CatalogProfileNumberKey = 'table_max_queries' | 'table_max_s' | 'query_timeout_s' | 'sample_size' | 'max_scan_rows'
|
||||
|
||||
/** 一个数据源的剖析设置(服务端 ProfileSettings.to_dict) */
|
||||
/** 一个数据源的剖析设置(服务端 ProfileSettings.to_dict);任务里的 settings 是建任务时的快照 */
|
||||
export interface CatalogProfileSettings {
|
||||
enabled: boolean
|
||||
max_queries: number
|
||||
/** 每张表最多发几条查询,用完只停这张表 */
|
||||
table_max_queries: number
|
||||
/** 每张表最多剖析几秒,到时只停这张表 */
|
||||
table_max_s: number
|
||||
/** 单条查询时限(不超过数据源自身的查询时限,也不超过这张表剩下的时长) */
|
||||
query_timeout_s: number
|
||||
sample_size: number
|
||||
/** 0 表示一律不做整表统计 */
|
||||
max_scan_rows: number
|
||||
max_total_s: number
|
||||
}
|
||||
|
||||
/** 整次剖析中途停下的原因:查询次数用完、总时长用完、连续多条查询失败 */
|
||||
export type CatalogProfileStop = 'budget' | 'deadline' | 'failed'
|
||||
/** 为什么需要剖析(GET /catalog 每行的 profile_due_reason) */
|
||||
export type CatalogProfileDueReason = 'never' | 'unfinished' | 'new_relations'
|
||||
|
||||
/** 剖析的范围:selected 指定的表;needed 需要剖析的表;all 全部可剖析的表(不含视图) */
|
||||
export type CatalogProfileScope = 'selected' | 'needed' | 'all'
|
||||
|
||||
/**
|
||||
* 剖析任务的状态:排队、进行中、完成、部分完成(有失败、部分完成、已停止的表)、失败(任务整体出错)、已停止(使用者停止)、
|
||||
* 服务重启中断。和起草任务同一套
|
||||
*/
|
||||
export type CatalogProfileJobStatus = CatalogDraftJobStatus
|
||||
|
||||
/**
|
||||
* 任务里一张表的状态。ok:每项检查都有结论;partial:有项因为运行原因没查成(unfinished);failed:写入一直冲突或整张表出错;
|
||||
* skipped:视图、表结构里没有、空表、没有可剖析的项;cancelled:停止、止损、服务重启时还没开始、没做完
|
||||
*/
|
||||
export type CatalogProfileTaskStatus = CatalogDraftTaskStatus
|
||||
|
||||
/** 跳过一项的原因(CatalogProfileSkip.reason、检查项的 reason) */
|
||||
export type CatalogProfileSkipReason = 'budget' | 'deadline' | 'failed' | 'timeout' | 'error' | 'rejected' | 'slow' | 'cancelled'
|
||||
| 'masked' | 'too_large' | 'view' | 'unsupported' | 'no_data' | 'missing' | 'high_cardinality' | 'nothing'
|
||||
|
||||
/** 一类检查:关联关系、码值、日期列 */
|
||||
export type CatalogProfileCheckKind = 'relation' | 'codes' | 'date'
|
||||
|
||||
/** 发现的合计(任务里是逐表合计) */
|
||||
export interface CatalogProfileSummary {
|
||||
/** 有结论的关系条数 */
|
||||
relations_checked: number
|
||||
/** 其中状态为已验证的 */
|
||||
relations_verified: number
|
||||
/** 取到码值候选的列数 */
|
||||
codes: number
|
||||
/** 这些码值里含义还空着的个数之和 */
|
||||
codes_pending: number
|
||||
/** 提议了业务日期的表数(逐表时 0 或 1) */
|
||||
business_dates: number
|
||||
}
|
||||
|
||||
/** 一张表的行数:stats 数据库的统计信息(估算);count 数到上限为止;unknown 没能得到 */
|
||||
export interface CatalogProfileSize {
|
||||
@@ -3386,43 +3450,146 @@ export interface CatalogProfileSkip {
|
||||
kind: string
|
||||
target: string
|
||||
path: string | null
|
||||
/** budget / deadline / failed / timeout / error / rejected / masked / too_large / view / unsupported / no_data / missing / high_cardinality */
|
||||
reason: string
|
||||
/**
|
||||
* 运行原因没查成(计入 unfinished、使表成为部分完成):budget 这张表的查询次数用完、deadline 这张表的时长用完、failed 连续失败
|
||||
* 止损、timeout、error、rejected 未通过只读守卫、slow 超时比例止损、cancelled 停止或服务重启。
|
||||
* 按规则不查(不算没做完):masked、too_large、view、unsupported、no_data、missing、high_cardinality、nothing 没有可剖析的项
|
||||
*/
|
||||
reason: CatalogProfileSkipReason | (string & {})
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface CatalogProfileTable {
|
||||
/** 任务里的一张表(按 seq) */
|
||||
export interface CatalogProfileTaskRow {
|
||||
seq: number
|
||||
table_name: string
|
||||
status: CatalogProfileTaskStatus
|
||||
checks_total: number
|
||||
/** 结束了的项(含跳过的) */
|
||||
checks_done: number
|
||||
/** 每类检查 [结束了的, 一共];这类没有检查时是 [0, 0] */
|
||||
checks_by_kind: Record<CatalogProfileCheckKind, [number, number]>
|
||||
/** 进行中时正在做哪一项的整句(如「正在核对 orders.customer_id → customers.id」);其余时候 null */
|
||||
current: string | null
|
||||
queries: number
|
||||
elapsed_ms: number
|
||||
row_estimate: CatalogProfileSize | null
|
||||
findings: CatalogProfileFinding[]
|
||||
skipped: CatalogProfileSkip[]
|
||||
/** 日期类列的取值范围 */
|
||||
date_ranges: { column: string; min: string; max: string }[]
|
||||
summary: CatalogProfileSummary
|
||||
/** 跳过的项数 */
|
||||
skipped_count: number
|
||||
/** 其中因为运行原因没查成的项数 */
|
||||
unfinished: number
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
version: number
|
||||
/** 这张表没剖析(表结构里没有)或结果没写进去(写入一直冲突) */
|
||||
/** 整张表的原因:表结构里没有、视图、空表、没有可剖析的项、写入一直冲突、停止 / 中断 / 止损 */
|
||||
error: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
/** POST /datasources/{id}/catalog/profile */
|
||||
export interface CatalogProfileOut {
|
||||
profiled_at: string
|
||||
actor: string | null
|
||||
/** 检查清单里的一项 */
|
||||
export interface CatalogProfileCheck {
|
||||
kind: CatalogProfileCheckKind
|
||||
/** 哪一项:关系写「customer_id → customers.id」,码值、日期写列名 */
|
||||
target: string
|
||||
/** 审阅路径;没有为 null */
|
||||
path: string | null
|
||||
state: 'pending' | 'running' | 'done' | 'skipped'
|
||||
/** 跳过的原因(同 CatalogProfileSkip.reason);没跳过为 null */
|
||||
reason: CatalogProfileSkipReason | (string & {}) | null
|
||||
/** 可以直接显示的整句(结论或跳过的原因);没有为 null */
|
||||
detail: string | null
|
||||
queries: number
|
||||
}
|
||||
|
||||
/** 发过的一条查询。sql 的字面量都换成了 ?(IN 列表里抽到的键值不留在 AgentLab 的库里) */
|
||||
export interface CatalogProfileQuery {
|
||||
at: string
|
||||
kind: 'row_estimate' | CatalogProfileCheckKind
|
||||
target: string
|
||||
sql: string
|
||||
ms: number
|
||||
/** 返回的行数;没返回(超时、出错、守卫拒)时为 null */
|
||||
rows: number | null
|
||||
outcome: 'ok' | 'timeout' | 'error' | 'rejected'
|
||||
/** 超时、出错、守卫拒时的原因 */
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
/** GET /profile-jobs/{id}/tasks/{seq}:一张表的完整结果;GET /catalog/{table} 的 last_profile 也是这个形状 */
|
||||
export interface CatalogProfileTaskDetail extends CatalogProfileTaskRow {
|
||||
job_id: string
|
||||
job_status: CatalogProfileJobStatus
|
||||
created_by: string | null
|
||||
checks: CatalogProfileCheck[]
|
||||
findings: CatalogProfileFinding[]
|
||||
skipped: CatalogProfileSkip[]
|
||||
/** 日期类列的取值范围 */
|
||||
date_ranges: { column: string; min: string; max: string }[]
|
||||
query_log: CatalogProfileQuery[]
|
||||
}
|
||||
|
||||
/** 一次剖析任务 */
|
||||
export interface CatalogProfileJob {
|
||||
id: string
|
||||
source_id: string
|
||||
status: CatalogProfileJobStatus
|
||||
scope: CatalogProfileScope
|
||||
/** 建任务时的剖析设置快照 */
|
||||
settings: CatalogProfileSettings
|
||||
queries_used: number
|
||||
stopped: CatalogProfileStop | null
|
||||
tables: CatalogProfileTable[]
|
||||
total: { added: number; updated: number; removed: number }
|
||||
/**
|
||||
* 不指定表时,挑表途中估过行数、确知是空表而跳过的表(老后端没有这个键;指定了表时是空列表)。
|
||||
* 估行数的查询算在 queries_used 里,不算在各表的 queries 里
|
||||
*/
|
||||
empty_tables?: string[]
|
||||
/** 一张表都没有剖析时的说明 */
|
||||
note: string | null
|
||||
created_at: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
/** 谁建的(X-Actor) */
|
||||
created_by: string | null
|
||||
/** 重试建的任务记着原任务的 id */
|
||||
retry_of: string | null
|
||||
/** 排在同一个数据源的起草任务后面:起草结束后自动开始。不在等时为 null */
|
||||
waiting_for: 'draft' | null
|
||||
total: number
|
||||
/** 已结束的表数 */
|
||||
done: number
|
||||
counts: Record<CatalogProfileTaskStatus, number>
|
||||
/** 整个任务发出的查询数、其中超时的、失败的 */
|
||||
queries: number
|
||||
timeouts: number
|
||||
errors: number
|
||||
/** 任务级止损停下的原因:failed 连续 3 条查询失败;slow 最近 10 条里 7 条超时。没停为 null */
|
||||
stop_reason: 'failed' | 'slow' | null
|
||||
/** 最近查询多数超时时的提醒(一句中文);好转后服务端清掉 */
|
||||
notice: string | null
|
||||
/** 任务整体出错或止损的整句原因 */
|
||||
error: string | null
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
summary: CatalogProfileSummary
|
||||
tasks: CatalogProfileTaskRow[]
|
||||
}
|
||||
|
||||
/** 清单一行上这张表最近一次剖析 */
|
||||
export interface CatalogLastProfile {
|
||||
job_id: string
|
||||
seq: number
|
||||
status: CatalogProfileTaskStatus
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
summary: CatalogProfileSummary
|
||||
skipped_count: number
|
||||
unfinished: number
|
||||
error: string | null
|
||||
row_estimate: CatalogProfileSize | null
|
||||
}
|
||||
|
||||
/** 目录清单顶上的进行中剖析任务(GET /catalog 的 active_profile_job) */
|
||||
export interface CatalogActiveProfileJob {
|
||||
id: string
|
||||
status: CatalogProfileJobStatus
|
||||
total: number
|
||||
done: number
|
||||
waiting_for: 'draft' | null
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user