diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 960078c3..f6f12789 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(`/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(`${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 }) => diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 979c7541..89763c3a 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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 = { - 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 = { - 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, + 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, + stopShort: { failed: '连续多条查询失败', slow: '数据库响应慢' } as Record, + stopNext: { + failed: '后面的表没有剖析。请先在数据源卡片上测试连接、确认账号有只读权限,再重试没做完的表。', + slow: '后面的表没有剖析。可以在业务库不忙的时候重试没做完的表,或在剖析设置中调低单条查询时限、每张表最多查询数。', + } as Record, + 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, + filterNone: '没有符合的表', + retry: (n: number) => `重试没做完的表(${formatNumber(n)} 张)`, + retryHint: '新建一次剖析,只含失败、部分完成和已停止的表;已写进目录的结论不受影响', + again: '重新选择范围', close: '完成', + tasks: '逐表进度', + + // ---- 逐表一行 + taskStatus: { + pending: '排队', running: '剖析中', ok: '完成', partial: '部分完成', failed: '失败', skipped: '跳过', cancelled: '已停止', + } as Record, + checkKind: { relation: '关系', codes: '码值', date: '日期' } as Record, + 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, + 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, + rowsSource: { stats: '来自数据库统计信息', count: '数到整表统计行数上限为止' } as Record, 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, + queryOutcome: { ok: '成功', timeout: '超时', error: '失败', rejected: '未通过只读守卫' } as Record, + 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, blockedNext: { disabled: '开启后即可在这里剖析。', inactive: '启用后回到这里重试。', noSchema: '探查完成后回到这里重试。', - busy: '同一个数据源同一时间只能进行一次剖析。', + busy: '同一个数据源同一时间只有一个剖析任务:可以查看它的进度,等它结束后再开始新的剖析。', tampered: '请重新上传表格,或联系管理员核对数据文件。', + nothing: '可以改选「全部表」,或在表清单中选定要剖析的表。', other: '请稍后重试。', } as Record, + 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, + listHint: (status: string, at: string) => `上次剖析(${at}):${status}`, + dueReason: { + never: '从没剖析过', + unfinished: '上次剖析没做完', + new_relations: '剖析之后目录里出现了新的待核实关联关系', + } as Record, + 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 = { - budget: '查询次数已用完', - deadline: '总时长已用完', - failed: '连续多条查询失败', -} - -/** 停下之后怎么办 */ -export const PROFILE_STOP_NEXT: Record = { - budget: '其余检查未执行。可在数据剖析设置中调高查询次数上限,或缩小剖析范围后重新剖析。', - deadline: '其余检查未执行。可在数据剖析设置中调高总时长上限,或缩小剖析范围后重新剖析。', - failed: '其余检查未执行。请先在数据源卡片上测试连接,确认可用后重新剖析。', -} - -/** 跳过一项的原因(skipped.reason)。整句说明在 skipped.detail 里 */ +/** 跳过一项的原因(skipped.reason、检查项的 reason)。整句说明在 detail 里 */ export const PROFILE_SKIP_REASON_LABEL: Record = { - 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 = { no_data: '没有数据', missing: '表结构中没有', high_cardinality: '取值太多', + nothing: '没有可剖析的项', } /** 跳过的是哪一类检查(skipped.kind) */ diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index c7da8429..a7585768 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -536,7 +536,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci )} {profile.enabled && ( - + {PROFILE_TEXT.chip} )} diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 09a1ddc0..c8b0b738 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -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 }>({ 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 } | 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 } | 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( + 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() { {CT.draft} ) - // 页头的「数据剖析」:剖析进行中换成计时,点开看进度(关掉弹窗剖析照常进行) - const headerProfile = jobRunning && myJob?.phase === 'running' - ? ( - - ) - : ( - - ) - // 页头的「起草」窄屏只留图标,标题才放得下 + // 页头的「数据剖析」「起草」:任务在排队、进行中时换成进度入口(「剖析 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 = ( + + ) + 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 = ( - ) @@ -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() { + profileEnabled={profileSettings.enabled} focusPath={inReview ? review.item || null : null} usageOpen={inReview} + profile={tableProfile} /> ) : inReview ? @@ -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 && ( 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 (
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 @@ -367,6 +372,11 @@ export function DraftDialog({
)} + {profileActive && ( +

+ {DT.queueHint} +

+ )} {startError && {startError}} )} @@ -451,6 +461,11 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
+ {waitsForProfile(job) && ( +

+ {DT.waitingProfile} +

+ )} {notice && {notice}}

{summary}

{(job.model || usage.calls > 0 || failures) && ( diff --git a/frontend/src/pages/catalog/ProfileDialog.tsx b/frontend/src/pages/catalog/ProfileDialog.tsx index 028f2d65..c3ad94e1 100644 --- a/frontend/src/pages/catalog/ProfileDialog.tsx +++ b/frontend/src/pages/catalog/ProfileDialog.tsx @@ -1,60 +1,90 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { ArrowDown, ArrowUp, ArrowUpRight, CircleCheck, Equal, Minus, OctagonX, ScanSearch, ShieldAlert, TriangleAlert, UserCheck } from 'lucide-react' +import { + ArrowDown, ArrowUp, ArrowUpRight, ChevronRight, CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, Equal, Info, Minus, + RotateCcw, ScanSearch, ShieldAlert, TriangleAlert, UserCheck, +} from 'lucide-react' import type { LucideIcon } from 'lucide-react' import clsx from 'clsx' +import { ApiError, api } from '../../api/client' import type { - CatalogProfileCodesFinding, CatalogProfileDateFinding, CatalogProfileOut, CatalogProfileRelationFinding, CatalogProfileSettings, - CatalogProfileSize, CatalogProfileSkip, CatalogProfileTable, CatalogTableRow, + CatalogProfileCheck, CatalogProfileCodesFinding, CatalogProfileDateFinding, CatalogProfileJob, CatalogProfileQuery, + CatalogProfileRelationFinding, CatalogProfileSettings, CatalogProfileSkip, CatalogProfileTaskDetail, CatalogProfileTaskRow, + CatalogProfileTaskStatus, CatalogTableRow, } from '../../types' -import { ErrorState, Modal, Notice, Spinner, useTicker } from '../../components/ui' +import { Modal, Notice, Spinner, useRadioGroup, useTicker } from '../../components/ui' +import { errorMessage } from '../../lib/errors' +import { formatDateTime, formatNumber, formatTime } from '../../lib/format' import { - CATALOG_CARDINALITY_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_SKIP_KIND_LABEL, PROFILE_SKIP_REASON_LABEL, PROFILE_STOP_LABEL, PROFILE_STOP_NEXT, PROFILE_TEXT as PT, + CATALOG_CARDINALITY_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_SKIP_KIND_LABEL, PROFILE_SKIP_REASON_LABEL, PROFILE_TEXT as PT, } from '../../lib/terms' import { STATUS_TONE, StatusChip } from './parts' -import { PROFILE_DEFAULT_TABLES, PROFILE_MAX_TABLES, coverageText, fillKey, secondsText } from './profile' +import { + PROFILE_MAX_TABLES, PROFILE_RETRYABLE, UNFINISHED_REASONS, coverageText, fillKey, isProfilable, profileBlockOf, profileScopeTables, + secondsText, sizeText, spanText, +} from './profile' import type { ProfileBlock } from './profile' +import { isJobActive } from './draftJob' +import { isProfileBusy, retryableTasks } from './profileJob' +import type { ProfileStartBody } from './profileJob' // =========================================================================== -// 数据目录页的「数据剖析」。接口是同步的:一次请求做完整个剖析才返回,服务端不支持中途停止,所以这里不给「停止」, -// 进行中只写已用时长和总时长上限,并明说无法中途停止。关掉窗口剖析照常进行(状态在页面上,页头有进度入口), -// 完成后弹提示、可以再打开报告。 +// 数据目录页的「数据剖析」。剖析是服务端的后台任务(profileJob.ts):这里选范围、看预算、建任务;之后显示页面跟着的 +// 那个任务(进行中的逐表进度、结束后的结果),任务本身的状态在页面上,关掉对话框照常进行。照起草对话框的结构: // -// 四段:选范围、看预算、确认开始 → 进行中 → 报告(用了几条查询、为什么停、每张表的发现和跳过的项); -// 开始不了(409)时写明是哪一种情况和下一步,未开启的就地打开剖析设置。 +// 开始前:范围(已选 / 当前筛选 / 需要剖析的表(默认)/ 全部表,个数按清单上每行的 profile_due、profile_checks 算)、按表写的 +// 预算和这次最多约几条查询、醒目一句「只读查询、一条一条发、可随时停止」;同一个数据源有起草任务时按钮写「排队剖析」。 +// 进行中:几张 / 共几张、已发几条查询、用时,按表数的真进度条,停止;逐表一行(状态、各类检查的进度、查询数、用时、正在做 +// 哪一项),点一行展开这张表的检查清单、发现和发过的查询。 +// 结束后:汇总(各状态几张、核实了什么)、止损和停止的原因照实写、按「有发现 / 有跳过 / 失败」筛、重试没做完的表;码值的 +// 「填写含义」照旧(填完回到这里,接着下一列)。 +// 开始不了(409):每种情况一句说法和下一步(未开启就地打开剖析设置;正忙写清是谁的、到哪了,可以去看它的进度)。 // =========================================================================== -/** 一次剖析的状态。放在页面上而不是弹窗里:关掉弹窗剖析照常进行,完成后还能再打开报告 */ -export type ProfileJob = - | { phase: 'running'; startedAt: number; tables: string[] | null; settings: CatalogProfileSettings } - /** filled:从报告去填了含义的码值列(fillKey)→ 保存后还剩几个含义待填写,报告里按它改写那一列 */ - | { phase: 'done'; report: CatalogProfileOut; ms: number; filled?: Record } - | { phase: 'blocked'; kind: ProfileBlock; message: string; tables: string[] | null } - | { phase: 'failed'; error: unknown; tables: string[] | null } +type Scope = 'selected' | 'filtered' | 'needed' | 'all' +type Filter = 'all' | 'findings' | 'skipped' | 'failed' +const FILTERS: readonly Filter[] = ['all', 'findings', 'skipped', 'failed'] -type Scope = 'selected' | 'filtered' | 'default' - -/** 报告里每类最多逐条列几项,其余合成一句 */ +/** 结果里每类最多逐条列几项,其余折起来 */ const SKIP_SHOWN = 6 const CODES_SHOWN = 12 export function ProfileDialog({ - sourceName, settings, rows, visible, selected, filtered, job, returnTo, onStart, onClose, onSettings, onGoSource, onOpenTable, onFillCodes, - onReset, + sourceId, sourceName, settings, rows, visible, selected, filtered, job, initial, initialScope = null, draftActive, stopping, filled, + returnTo = null, onStart, onCancel, onRetry, onShowActive, onTrack, onClose, onSettings, onGoSource, onOpenTable, onFillCodes, }: { + sourceId: string sourceName: string /** 这个数据源此刻的剖析设置(数据源列表里读的) */ settings: CatalogProfileSettings + /** 全部表(服务端的顺序) */ rows: CatalogTableRow[] + /** 当前筛选结果 */ visible: CatalogTableRow[] selected: string[] + /** 清单上有没有生效的搜索或筛选:有时才给「当前筛选结果」这一项 */ filtered: boolean - /** null:还没开始(选范围) */ - job: ProfileJob | null - /** 填完一列码值的含义、回到报告:焦点落在下一列还有待填写的「填写含义」上 */ + /** 页面跟着的剖析任务(进行中的、刚结束的) */ + job: CatalogProfileJob | null + /** 打开时显示什么:setup 选范围、开始;job 显示页面跟着的那个任务 */ + initial: 'setup' | 'job' + /** 从表清单的「剖析所选」打开:范围默认是已选的表 */ + initialScope?: 'selected' | null + /** 同一个数据源有起草任务在排队、进行中:剖析会排在它后面 */ + draftActive: boolean + /** 点了停止、服务端还没停下来 */ + stopping: boolean + /** 从结果去填了含义的码值列(fillKey)→ 保存后还剩几个含义待填写 */ + filled: Record + /** 填完一列码值的含义、回到结果:展开那张表,焦点落在下一列还有待填写的「填写含义」上 */ returnTo?: { table: string; column: string } | null - /** tables 为 null 时由服务端挑表 */ - onStart: (tables: string[] | null) => void + onStart: (body: ProfileStartBody) => Promise + onCancel: () => Promise + onRetry: (from: CatalogProfileJob) => Promise + /** 正忙(409)时改去跟着进行中的那一个 */ + onShowActive: () => Promise + /** 设置阶段点开了上一次剖析的结果(或者发现别处建的进行中的任务):页面改跟这个任务 */ + onTrack: (job: CatalogProfileJob) => void onClose: () => void /** 打开这个数据源的剖析设置 */ onSettings: () => void @@ -62,29 +92,156 @@ export function ProfileDialog({ onGoSource: () => void onOpenTable: (table: string) => void onFillCodes: (table: string, column: string) => void - /** 回到选范围 */ - onReset: () => void }) { - const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows]) const scopes = useMemo(() => { - const liveSet = new Set(live) - const out: { key: Scope; label: string; hint?: string; tables: string[] | null }[] = [] - const sel = selected.filter((t) => liveSet.has(t)) - if (sel.length) out.push({ key: 'selected', label: PT.scopeSelected(sel.length), tables: sel }) - const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name) - if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: PT.scopeFiltered(vis.length), tables: vis }) - out.push({ key: 'default', label: PT.scopeDefault, hint: PT.scopeDefaultHint(PROFILE_DEFAULT_TABLES), tables: null }) + const out: { key: Scope; label: string; hint?: string; tables: string[]; leftOut: number; body: ProfileStartBody }[] = [] + const byName = new Map(rows.map((r) => [r.table_name, r])) + // 已选、筛选结果里剖析不了的表(视图、表结构里没有、没有可剖析的项)不交给服务端,写明有几张 + const explicit = (names: string[]) => { + const tables = names.filter((n) => { const r = byName.get(n); return !!r && isProfilable(r) }) + return { tables, leftOut: names.length - tables.length } + } + if (selected.length) { + const s = explicit(selected) + out.push({ key: 'selected', label: PT.scopeSelected(s.tables.length), ...s, body: { tables: s.tables } }) + } + if (filtered && visible.length && visible.length !== rows.length) { + const f = explicit(visible.map((r) => r.table_name)) + out.push({ key: 'filtered', label: PT.scopeFiltered(f.tables.length), ...f, body: { tables: f.tables } }) + } + const needed = profileScopeTables(rows, 'needed') + out.push({ key: 'needed', label: PT.scopeNeeded(needed.length), hint: needed.length ? PT.scopeNeededHint : PT.scopeNeededNone, + tables: needed, leftOut: 0, body: { scope: 'needed' } }) + const all = profileScopeTables(rows, 'all') + out.push({ key: 'all', label: PT.scopeAll(all.length), hint: PT.scopeAllHint, tables: all, leftOut: 0, body: { scope: 'all' } }) return out - }, [live, selected, visible, filtered]) - const [scope, setScope] = useState(() => scopes[0].key) + }, [rows, visible, selected, filtered]) + const [scope, setScope] = useState(() => { + if (initialScope === 'selected' && scopes.some((s) => s.key === 'selected')) return 'selected' + // 默认「需要剖析的表」;一张都没有时选「全部表」(选一个开始不了的范围没有意义) + return scopes.find((s) => s.key === 'needed')!.tables.length ? 'needed' : 'all' + }) const picked = scopes.find((s) => s.key === scope) ?? scopes[0] - const tooMany = (picked.tables?.length ?? 0) > PROFILE_MAX_TABLES - const running = job?.phase === 'running' - useTicker(1000, running) + const tooMany = !!picked.body.tables && picked.tables.length > PROFILE_MAX_TABLES + + const [mode, setMode] = useState<'setup' | 'job' | 'blocked'>(initial === 'job' && job ? 'job' : 'setup') + const [blocked, setBlocked] = useState<{ kind: ProfileBlock; message: string; active: CatalogProfileJob | null; body: ProfileStartBody } | null>(null) + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(null) + /** 是 409(正忙)转过来显示的那一个 */ + const [busy, setBusy] = useState(false) + const [acting, setActing] = useState(false) + const [actionError, setActionError] = useState(null) + /** 上一次剖析(没做完的才提),设置阶段写一行、可以点开 */ + const [last, setLast] = useState(null) + const onTrackRef = useRef(onTrack) + onTrackRef.current = onTrack + + const inSetup = mode === 'setup' || (mode === 'job' && !job) + // 设置阶段:取上一次剖析。别处(另一个标签页、表详情)建的任务还在进行:直接显示它 + useEffect(() => { + if (!inSetup) return + let alive = true + api.dataCatalog.profileJobs.latest(sourceId).then(({ job: latest }) => { + if (!alive || !latest) return + if (isJobActive(latest)) { + onTrackRef.current(latest) + setBusy(true) + setMode('job') + } else if (latest.status !== 'succeeded') { + setLast(latest) + } + }, () => {}) + return () => { alive = false } + }, [inSetup, sourceId]) + + /** 开始不了:409 各有说法;正忙时顺手取进行中的那一个(写清是谁的、到哪了);别的错误留在设置里写明 */ + const fail = async (e: unknown, body: ProfileStartBody) => { + if (e instanceof ApiError && e.status === 409) { + const kind = profileBlockOf(e) + const active = isProfileBusy(e) ? await api.dataCatalog.profileJobs.active(sourceId).then((r) => r.job, () => null) : null + setBlocked({ kind, message: e.message, active, body }) + setMode('blocked') + } else { + setStartError(PT.startFailed(errorMessage(e))) + } + } + + const begin = async (body: ProfileStartBody = picked.body) => { + if (starting) return + setStarting(true) + setStartError(null) + try { + await onStart(body) + setBusy(false) + setBlocked(null) + setMode('job') + } catch (e) { + await fail(e, body) + } finally { + setStarting(false) + } + } + + const act = async (fn: () => Promise) => { + setActing(true) + setActionError(null) + try { + await fn() + } catch (e) { + setActionError(errorMessage(e)) + } finally { + setActing(false) + } + } + const stop = () => act(onCancel) + // 重试是新建一个任务:正忙(别处又建了一个)时和开始一样转到「开始不了」,写清是谁的 + const retry = (from: CatalogProfileJob) => act(async () => { + try { + await onRetry(from) + setBusy(false) + } catch (e) { + if (e instanceof ApiError && e.status === 409) await fail(e, { tables: retryableTasks(from).map((t) => t.table_name) }) + else throw e + } + }) + const viewActive = async () => { + const current = await onShowActive().catch(() => null) + if (current) { + setBusy(true) + setBlocked(null) + setMode('job') + } + } + const backToSetup = () => { + setBlocked(null) + setStartError(null) + setActionError(null) + setBusy(false) + setLast(null) + setMode('setup') + } + const openLast = (j: CatalogProfileJob) => { + onTrack(j) + setBusy(false) + setMode('job') + } + + // 因为没开启被拒的:就地开了(设置存好、这里拿到的设置变成开启)就回到选范围,可以直接开始 + useEffect(() => { + if (mode === 'blocked' && blocked?.kind === 'disabled' && settings.enabled) { + setBlocked(null) + setMode('setup') + } + }, [mode, blocked, settings.enabled]) + + const phase: 'setup' | 'blocked' | 'running' | 'done' = inSetup ? 'setup' : mode === 'blocked' ? 'blocked' : isJobActive(job) ? 'running' : 'done' + const canStart = settings.enabled && picked.tables.length > 0 && !tooMany && !starting + const retryable = job && phase === 'done' ? retryableTasks(job).length : 0 let body: ReactNode let footer: ReactNode - if (!job) { + if (phase === 'setup') { body = ( <>

{PT.intro}

@@ -99,17 +256,18 @@ export function ProfileDialog({ {PT.scope}
{scopes.map((s) => { - const over = (s.tables?.length ?? 0) > PROFILE_MAX_TABLES + const over = !!s.body.tables && s.tables.length > PROFILE_MAX_TABLES + const note = over ? PT.scopeTooMany(PROFILE_MAX_TABLES) : !s.tables.length && s.key !== 'needed' ? PT.scopeEmpty + : s.leftOut ? PT.scopeLeftOut(s.leftOut) : s.hint return ( @@ -117,103 +275,88 @@ export function ProfileDialog({ })}
- {settings.enabled && } -

- {PT.noCancel} + {settings.enabled && } +

+ {PT.readOnly}

+ {draftActive && settings.enabled && ( +

+ {PT.queueHint} +

+ )} + {last && ( +
+ + {PT.last(PT.jobTitle[last.status], formatTime(last.finished_at ?? last.created_at))} + +
+ )} + {startError && {startError}} ) footer = ( <> - ) - } else if (job.phase === 'running') { - const used = Date.now() - job.startedAt - const max = job.settings.max_total_s * 1000 - body = ( -
-
- - {PT.running} - - - {PT.elapsed(secondsText(used), secondsText(max))} - -
- {/* 不是进度:服务端不报进度,这条只量已用时长占总时长上限的多少,剖析最晚在上限处停下 */} -
-
-
- {PT.runningHint} -

{PT.leavePage}

-
- ) - footer = - } else if (job.phase === 'done') { - body = + } else if (phase === 'blocked' && blocked) { + body = void viewActive()} + onChooseAll={() => { setScope('all'); void begin({ scope: 'all' }) }} onRetry={() => void begin(blocked.body)} /> footer = ( <> - - - - ) - } else if (job.phase === 'blocked') { - const action = job.kind === 'disabled' - ? - : job.kind === 'busy' || job.kind === 'other' - ? - : - body = ( - -

{PT.blocked[job.kind]}

-

{job.message}

- {job.kind !== 'other' &&

{PT.blockedNext[job.kind]}

} -
{action}
-
- ) - footer = ( - <> - + ) - } else { + } else if (job) { + const running = phase === 'running' body = ( -
-

{PT.failedTitle}

- onStart(job.tables)} /> -
- ) - footer = ( <> - - + + {actionError && {actionError}} ) + footer = running + ? ( + <> + + + + ) + : ( + <> + + {retryable > 0 && ( + + )} + + + ) } return ( - -
{body}
+ +
{body}
) } -/** 确认前写明预算:按这个数据源的剖析设置,最多发几条查询、单条和总时长、抽样规模、整表统计的门槛 */ -function Budget({ settings: s, sourceName, onSettings }: { settings: CatalogProfileSettings; sourceName: string; onSettings: () => void }) { - const lines = [PT.budgetQueries(s.max_queries, s.query_timeout_s), PT.budgetTotal(s.max_total_s), PT.budgetSample(s.sample_size), +/** 确认前写明预算:按表的查询数和时长、单条时限、抽样规模、整表统计的门槛,再按这次的表数估一个上限 */ +function Budget({ settings: s, sourceName, tables, onSettings }: { + settings: CatalogProfileSettings; sourceName: string; tables: number; onSettings: () => void +}) { + const lines = [PT.budgetPerTable(s.table_max_queries, s.table_max_s), PT.budgetQuery(s.query_timeout_s), PT.budgetSample(s.sample_size), PT.budgetScan(s.max_scan_rows)] return ( -
-
+
+
{PT.budget} {PT.budgetFrom(sourceName)} @@ -228,159 +371,479 @@ function Budget({ settings: s, sourceName, onSettings }: { settings: CatalogProf ))} + {tables > 0 && ( +

+ {PT.budgetTotal(tables, tables * s.table_max_queries, spanText(tables * s.table_max_s))} +

+ )}
) } +/** 开始不了(409):是哪种情况、服务端的原话、下一步和对应的按钮 */ +function Blocked({ blocked: b, onSettings, onGoSource, onViewActive, onChooseAll, onRetry }: { + blocked: { kind: ProfileBlock; message: string; active: CatalogProfileJob | null } + onSettings: () => void + onGoSource: () => void + onViewActive: () => void + onChooseAll: () => void + onRetry: () => void +}) { + const action = b.kind === 'disabled' + ? + : b.kind === 'busy' + ? + : b.kind === 'nothing' + ? + : b.kind === 'other' + ? + : + return ( + +

{PT.blocked[b.kind]}

+

{b.message}

+ {b.kind === 'busy' && b.active && ( +

{PT.busyAt(b.active.created_by, b.active.done, b.active.total)}

+ )} +

{PT.blockedNext[b.kind]}

+
{action}
+
+ ) +} + // --------------------------------------------------------------------------- -// 报告 +// 任务:进度、汇总、逐表 // --------------------------------------------------------------------------- -function Report({ report: r, ms, filled, returnTo, onSettings, onOpenTable, onFillCodes }: { - report: CatalogProfileOut - ms: number +const hasFindings = (t: CatalogProfileTaskRow) => t.summary.relations_checked + t.summary.codes + t.summary.business_dates > 0 +const FILTER_TEST: Record boolean> = { + all: () => true, + findings: hasFindings, + skipped: (t) => t.skipped_count > 0 || t.status === 'skipped', + failed: (t) => t.status === 'failed', +} + +function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onSettings, onOpenTable, onFillCodes }: { + sourceId: string + job: CatalogProfileJob + running: boolean + stopping: boolean + busy: boolean filled: Record returnTo: { table: string; column: string } | null onSettings: () => void onOpenTable: (table: string) => void onFillCodes: (table: string, column: string) => void }) { - const changed = r.total.added + r.total.updated + r.total.removed - const stop = r.stopped - // 一项发现、一项跳过都没有的表合成一行,几十张表时报告不被空卡片撑长 - const quiet = r.tables.filter((t) => !t.error && !t.findings.length && !t.skipped.length && !t.date_ranges.length) - const shown = r.tables.filter((t) => !quiet.includes(t)) - // 填完一列回到报告:焦点交给它后面第一个还有含义待填写的「填写含义」,没有了就落回刚填的那一列。 - // 弹窗自己的初始焦点在下一帧,这里先聚焦,它看到焦点已在弹窗里就不再动 - const root = useRef(null) - useEffect(() => { - if (!returnTo) return - const buttons = [...(root.current?.querySelectorAll('[data-profile-fill-key]') ?? [])] - const at = buttons.findIndex((b) => b.dataset.profileFillKey === fillKey(returnTo.table, returnTo.column)) - const target = buttons.slice(at + 1).find((b) => Number(b.dataset.pending) > 0) ?? buttons[at] - target?.focus() - target?.scrollIntoView({ block: 'nearest' }) - }, [returnTo]) + const now = useTicker(1000, running) + const queued = job.status === 'queued' + const title = running ? (stopping ? PT.stopping : PT.jobTitle[job.status]) : PT.jobTitle[job.status] + const started = job.started_at ? Date.parse(job.started_at) : NaN + const ended = job.finished_at ? Date.parse(job.finished_at) : NaN + const elapsedMs = Number.isFinite(started) ? (running ? Math.max(now, Date.now()) : Number.isFinite(ended) ? ended : started) - started : null + const pct = job.total ? (job.done / job.total) * 100 : 0 + const bad = job.tasks.some((t) => PROFILE_RETRYABLE.has(t.status)) + const tone = job.status === 'failed' ? 'var(--err)' : running ? 'var(--accent)' : bad ? 'var(--warn)' : 'var(--st-done)' + const progress = PT.progress(job.done, job.total) + const badQueries = PT.queriesBad(job.timeouts, job.errors) + const notice = running ? job.notice?.trim() ?? '' : '' + const changed = job.added + job.updated + job.removed + const findings = PT.findings(job.summary) + const [filter, setFilter] = useState('all') + const radio = useRadioGroup(FILTERS, filter, setFilter) + const tasks = running ? job.tasks : job.tasks.filter(FILTER_TEST[filter]) + const counts = useMemo(() => Object.fromEntries(FILTERS.map((f) => [f, job.tasks.filter(FILTER_TEST[f]).length])) as Record, + [job.tasks]) + // 展开的表:填完码值回到结果时,那张表照旧展开 + const [open, setOpen] = useState>(() => { + const t = returnTo ? job.tasks.find((x) => x.table_name === returnTo.table) : null + return new Set(t ? [t.seq] : []) + }) + const toggle = (seq: number) => setOpen((cur) => { + const next = new Set(cur) + if (next.has(seq)) next.delete(seq) + else next.add(seq) + return next + }) return ( -
-
- {stop - ? - : } -
-

{stop ? PT.stoppedTitle(PROFILE_STOP_LABEL[stop] ?? stop) : PT.done}

-

- {PT.summary(r.queries_used, r.settings.max_queries, r.tables.length)} - · {secondsText(ms)} -

-

- {changed ? PT.changes(r.total.added, r.total.updated, r.total.removed) : {PT.noChange}} +

+ {busy && {PT.busyShown}} +
+ {running && } + {title} + + + {progress} + {!queued && · {PT.queriesSent(job.queries)}} + {badQueries && ({badQueries})} + {elapsedMs != null && · {PT.elapsed(secondsText(elapsedMs))}} + +
+
+
+
+ {queued && job.waiting_for === 'draft' && ( +

+ {PT.waitingDraft} +

+ )} + {notice && {notice}} + {job.created_by &&

{PT.createdBy(job.created_by)}

} + {running &&

{PT.hideHint}

} + {!running && ( +
+

{PT.counts(job.total, job.counts)}

+

{findings || PT.noFindings}

+

+ {changed ? PT.changes(job.added, job.updated, job.removed) : PT.noChange}

-
- {stop && ( - -

{PROFILE_STOP_NEXT[stop] ?? ''}

- {stop !== 'failed' && ( + )} + {job.stop_reason && ( + +

{PT.stopTitle[job.stop_reason]}

+ {job.error &&

{job.error}

} +

{PT.stopNext[job.stop_reason]}

+ {job.stop_reason === 'slow' && ( )}
)} - {r.note && {r.note}} - {/* 挑表途中确知是空表、跳过的表(服务端 empty_tables):一张都没剖析时 note 已经说了,这里只在剖析了别的表时列出 */} - {r.tables.length > 0 && (r.empty_tables?.length ?? 0) > 0 && ( -

- {UT.emptyTables(r.empty_tables!.length)}:{r.empty_tables!.join('、')} -

- )} - {shown.map((t) => )} - {quiet.length > 0 && ( -

- {PT.noFindings}:{quiet.map((t) => t.table_name).join('、')} -

+ {!job.stop_reason && job.status === 'failed' && job.error && ( + {job.error} )} + {!running && job.status === 'cancelled' &&

{PT.cancelledNote}

} + {job.status === 'interrupted' && {PT.interruptedNote}} +
+
+ {PT.tasks} + + {!running && job.tasks.length > 1 && ( +
+ {FILTERS.map((f) => ( + + ))} +
+ )} +
+
    + {tasks.map((t) => ( + toggle(t.seq)} filled={filled} + focusAfter={returnTo && returnTo.table === t.table_name ? returnTo.column : null} + onOpenTable={onOpenTable} onFillCodes={onFillCodes} /> + ))} + {!tasks.length &&
  • {PT.filterNone}
  • } +
+
) } -function sizeText(size: CatalogProfileSize | null): { text: string; hint?: string } | null { - if (!size) return null - if (size.rows != null) return { text: size.method === 'stats' ? PT.rowsStats(size.rows) : PT.rowsCount(size.rows), hint: PT.rowsHint[size.method] } - if (size.at_least != null) return { text: PT.rowsAtLeast(size.at_least), hint: PT.rowsHint.count } - return null +export const PROFILE_TASK_ICON: Record = { + pending: { Icon: CircleDashed, color: 'var(--text-faint)' }, + running: { Icon: null, color: 'var(--accent)' }, + ok: { Icon: CircleCheck, color: 'var(--st-done)' }, + partial: { Icon: TriangleAlert, color: 'var(--warn)' }, + failed: { Icon: CircleX, color: 'var(--err)' }, + skipped: { Icon: CircleMinus, color: 'var(--text-faint)' }, + cancelled: { Icon: CircleSlash, color: 'var(--text-faint)' }, } -function TableReport({ t, filled, onOpenTable, onFillCodes }: { - t: CatalogProfileTable +/** 各类检查的进度:「关系 3/4 · 码值 5/6 · 日期 1/1」,这类没有检查的不写 */ +export function checksText(t: Pick): string { + return (['relation', 'codes', 'date'] as const) + .map((k) => { + const [done, total] = t.checks_by_kind?.[k] ?? [0, 0] + return total > 0 ? PT.kindProgress(PT.checkKind[k], done, total) : '' + }) + .filter(Boolean).join(' · ') +} + +/** + * 一张表的结论:进行中写正在做哪一项;结束了写发现(核实了几条关系、取到几列码值……),没查成的写几项,整张表的原因照服务端 + * 的原话。jobError:任务整体的原因(止损、服务重启时服务端把后面的表也记上同一句),和它一样的不再逐表重复 + */ +export function taskNote(t: CatalogProfileTaskRow, jobError: string | null): string { + if (t.status === 'pending') return '' + if (t.status === 'running') return t.current ?? '' + const own = t.error && t.error !== jobError ? t.error : '' + if (t.status === 'skipped' || t.status === 'failed') return own + const parts = [PT.findings(t.summary, true), t.unfinished ? PT.unfinished(t.unfinished) : '', own] + if (t.status === 'ok' && !parts.some(Boolean)) return PT.noFindings + return parts.filter(Boolean).join(';') +} + +function TaskItem({ sourceId, job, task: t, open, onToggle, filled, focusAfter, onOpenTable, onFillCodes }: { + sourceId: string + job: CatalogProfileJob + task: CatalogProfileTaskRow + open: boolean + onToggle: () => void filled: Record + focusAfter: string | null onOpenTable: (table: string) => void onFillCodes: (table: string, column: string) => void }) { - const relations = t.findings.filter((f): f is CatalogProfileRelationFinding => f.kind === 'relation') - const codes = t.findings.filter((f): f is CatalogProfileCodesFinding => f.kind === 'codes') - const date = t.findings.find((f): f is CatalogProfileDateFinding => f.kind === 'business_date') - const size = sizeText(t.row_estimate) - const changed = t.added + t.updated + t.removed + const { Icon, color } = PROFILE_TASK_ICON[t.status] + const checks = checksText(t) + const size = t.status !== 'skipped' ? sizeText(t.row_estimate) : null + const usedUp = t.queries > 0 && t.queries >= job.settings.table_max_queries + const note = taskNote(t, job.error) + const started = t.status !== 'pending' + const panelId = `catalog-profile-task-${t.seq}` return ( -
-
-

{t.table_name}

- - {PT.tableQueries(t.queries)} - {size && · {size.text}} - {changed > 0 && · {PT.changes(t.added, t.updated, t.removed)}} +
  • + + {started && (checks || t.queries > 0 || size) && ( + + {checks && {checks}} + {t.queries > 0 && ( + + {usedUp ? PT.tableQueriesUsedUp(t.queries) : PT.tableQueries(t.queries)} + + )} + {t.elapsed_ms > 0 && {secondsText(t.elapsed_ms)}} + {size && {size.text}} + {t.skipped_count > 0 && t.status !== 'skipped' && {PT.skippedN(t.skipped_count)}} + )} -
  • -
    - {t.error && ( -

    - {t.error} -

    + {note && ( + + {note} + )} - {relations.length > 0 && ( - - {relations.map((f) => )} - - )} - {codes.length > 0 && ( - - {codes.map((f) => ( - onFillCodes(t.table_name, f.column)} /> - ))} - - )} - {date && ( - -
  • - - {PT.dateProposal(date.column)} - ,{PT.dateSpan(date.min.slice(0, 19), date.max.slice(0, 19))} + + {open && ( +
    + +
    + )} +
  • + ) +} + +/** + * 展开的一张表:按 seq 取完整结果(检查清单、发现、发过的查询)。还在剖析的表,每多结束一项、多发一条查询就重取一次 + */ +function TaskDetailLoader({ sourceId, jobId, task: t, filled, focusAfter, onOpenTable, onFillCodes }: { + sourceId: string + jobId: string + task: CatalogProfileTaskRow + filled: Record + focusAfter: string | null + onOpenTable: (table: string) => void + onFillCodes: (table: string, column: string) => void +}) { + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + const [again, setAgain] = useState(0) + const sig = `${t.status}:${t.checks_done}:${t.queries}` + useEffect(() => { + let alive = true + api.dataCatalog.profileJobs.task(sourceId, jobId, t.seq).then(({ task }) => { + if (!alive) return + setDetail(task) + setError(null) + }, (e) => { if (alive) setError(errorMessage(e)) }) + return () => { alive = false } + }, [sourceId, jobId, t.seq, sig, again]) + if (!detail) { + return error + ? ( +

    + {PT.loadFailed(error)} + +

    + ) + :

    {PT.loading}

    + } + return onFillCodes(t.table_name, column)} /> +} + +// --------------------------------------------------------------------------- +// 一张表的剖析结果:逐表展开里、表详情的「数据剖析」一节共用 +// --------------------------------------------------------------------------- + +/** + * mode job:任务里展开的一行,写检查清单(每项的状态、原因、用了几条查询)、发现、发过的查询; + * mode detail:表详情里,写行数和来源、日期范围、发现、跳过的项、发过的查询(检查清单在任务里看)。 + * onFill 不给时码值不带「填写含义」 + */ +export function ProfileTaskBody({ detail: d, mode, filled = {}, focusAfter = null, onOpenTable, onFill }: { + detail: CatalogProfileTaskDetail + mode: 'job' | 'detail' + filled?: Record + focusAfter?: string | null + onOpenTable?: (table: string) => void + onFill?: (column: string) => void +}) { + const relations = d.findings.filter((f): f is CatalogProfileRelationFinding => f.kind === 'relation') + const codes = d.findings.filter((f): f is CatalogProfileCodesFinding => f.kind === 'codes') + const date = d.findings.find((f): f is CatalogProfileDateFinding => f.kind === 'business_date') + const size = sizeText(d.row_estimate) + // 检查清单里没有的跳过项(行数估算、整张表):任务里也要看得到 + const skipped = mode === 'detail' ? d.skipped : d.skipped.filter((s) => s.kind !== 'relation' && s.kind !== 'codes' && s.kind !== 'date') + const root = useRef(null) + // 填完一列回到结果:焦点交给它后面第一个还有含义待填写的「填写含义」,没有了就落回刚填的那一列。弹窗自己的初始焦点在 + // 下一帧,这里先聚焦,它看到焦点已在弹窗里就不再动 + useEffect(() => { + if (!focusAfter) return + const buttons = [...(root.current?.querySelectorAll('[data-profile-fill-key]') ?? [])] + const at = buttons.findIndex((b) => b.dataset.profileFillKey === fillKey(d.table_name, focusAfter)) + const target = buttons.slice(at + 1).find((b) => Number(b.dataset.pending) > 0) ?? buttons[at] + target?.focus() + target?.scrollIntoView({ block: 'nearest' }) + }, [focusAfter, d.table_name]) + return ( +
    + {(size || (mode === 'job' && onOpenTable && d.status !== 'skipped')) && ( +
    + {size && ( + + {PT.rowsTitle}:{size.text}{size.source && ({size.source})} + + )} + + {mode === 'job' && onOpenTable && ( + + )} +
    + )} + {d.error && ( +

    + {d.error} +

    + )} + {mode === 'job' && d.checks.length > 0 && } + {relations.length > 0 && ( + + {relations.map((f) => )} + + )} + {codes.length > 0 && ( + + {codes.map((f) => ( + onFill(f.column) : undefined} /> + ))} + + )} + {date && ( + +
  • + + {PT.dateProposal(date.column)} + ,{PT.dateSpan(date.min.slice(0, 19), date.max.slice(0, 19))} +
  • +
    + )} + {d.date_ranges.length > 0 && (!date || d.date_ranges.length > 1) && ( + + {d.date_ranges.map((r) => ( +
  • + {r.column}:{PT.dateSpan(r.min.slice(0, 19), r.max.slice(0, 19))}
  • -
    - )} - {!date && t.date_ranges.length > 0 && ( - - {t.date_ranges.map((d) => ( -
  • - {d.column}:{PT.dateSpan(d.min.slice(0, 19), d.max.slice(0, 19))} -
  • - ))} -
    - )} - {!t.error && !t.findings.length && !t.date_ranges.length && ( -

    {PT.noFindings}

    - )} - {t.skipped.length > 0 && } -
    -
    + ))} + + )} + {!d.findings.length && !d.error && (d.status === 'ok' || d.status === 'partial') && ( +

    {PT.noFindings}

    + )} + {skipped.length > 0 && } + {d.query_log.length > 0 && } +
    + ) +} + +const CHECK_ICON: Record = { + pending: { Icon: CircleDashed, color: 'var(--text-faint)' }, + running: { Icon: null, color: 'var(--accent)' }, + done: { Icon: CircleCheck, color: 'var(--st-done)' }, + skipped: { Icon: CircleMinus, color: 'var(--text-faint)' }, +} + +/** 检查清单:每项的状态、原因(没查成的用警告色:不是结论错了,旧结论保留)、用了几条查询 */ +function Checks({ checks }: { checks: CatalogProfileCheck[] }) { + const done = checks.filter((c) => c.state === 'done' || c.state === 'skipped').length + return ( +
    +
    {PT.checksTitle(done, checks.length)}
    +
      + {checks.map((c, i) => { + const { Icon, color } = CHECK_ICON[c.state] + const unfinished = c.state === 'skipped' && !!c.reason && UNFINISHED_REASONS.has(c.reason) + return ( +
    • + {Icon ? : } + + {PT.checkKind[c.kind] ?? c.kind} + {c.target} + · {PT.checkState[c.state] ?? c.state} + {c.reason && ( + + {PROFILE_SKIP_REASON_LABEL[c.reason] ?? c.reason} + + )} + {c.detail && {c.detail}} + + {c.queries > 0 && {PT.checkQueries(c.queries)}} +
    • + ) + })} +
    +
    + ) +} + +/** 发过的查询(字面量换成了 ?):折起来,点开看每一条的类别、目标、结果、用时、行数和 SQL */ +function QueryLog({ list }: { list: CatalogProfileQuery[] }) { + return ( +
    + {PT.queriesTitle(list.length)} +

    {PT.queriesHint}

    +
      + {list.map((q, i) => ( +
    1. +
      + {PT.queryKind[q.kind] ?? q.kind} + {q.target} + + + {PT.queryOutcome[q.outcome] ?? q.outcome} + + {PT.queryMs(q.ms)} + {q.rows != null && {PT.queryRows(q.rows)}} + {formatTime(q.at)} +
      +
      {q.sql}
      + {q.detail &&

      {q.detail}

      } +
    2. + ))} +
    +
    ) } @@ -397,7 +860,7 @@ type RelationOutcome = 'confirmed' | 'raised' | 'same' | 'lowered' | 'kept' /** * 一条关系这次剖析的结论,按实际变化说:原本就是已验证、这次照样核实的写「仍为已验证,覆盖率 x」,不能说「升为」—— - * 重新剖析、目录没变时报告头写「目录没有变化」,下面却说升了,自相矛盾。老服务端不给剖析前的状态,按升级说 + * 重新剖析、目录没变时汇总写「目录没有变化」,下面却说升了,自相矛盾。服务端不给剖析前的状态时按升级说 */ function relationOutcome(f: CatalogProfileRelationFinding): { kind: RelationOutcome; text: string; tone: string; icon: LucideIcon } { const reason = f.coverage < 0.95 ? PT.relationLowCoverage : PT.relationNotUnique @@ -448,11 +911,11 @@ function RelationLine({ f }: { f: CatalogProfileRelationFinding }) { function CodesLine({ f, fillKey: key, filled, onFill }: { f: CatalogProfileCodesFinding fillKey: string - /** 从报告去填过含义:保存后还剩几个待填写。没去填过为 undefined,按剖析时的说 */ + /** 从结果去填过含义:保存后还剩几个待填写。没去填过为 undefined,按剖析时的说 */ filled: number | undefined - onFill: () => void + onFill?: () => void }) { - // 几个含义待填写读服务端的 pending(别的来源写过的含义会沿用),不从 summary 的原话里认;老服务端不给时不说 + // 几个含义待填写读服务端的 pending(别的来源写过的含义会沿用),不从 summary 的原话里认 const pending = filled ?? f.pending ?? 0 const done = filled != null && filled === 0 return ( @@ -478,16 +941,18 @@ function CodesLine({ f, fillKey: key, filled, onFill }: { {UT.codesFilled} )} - + {onFill && ( + + )}
    ) } -/** 跳过的项:原因(短)+ 哪一项 + 服务端给的整句。多了折起来,每类停下的原因只列前几项 */ +/** 跳过的项:原因(短)+ 哪一项 + 服务端给的整句。多了折起来 */ function Skipped({ list }: { list: CatalogProfileSkip[] }) { const [all, setAll] = useState(false) const shown = all ? list : list.slice(0, SKIP_SHOWN) @@ -497,13 +962,14 @@ function Skipped({ list }: { list: CatalogProfileSkip[] }) {
      {shown.map((s, i) => (
    • - + {PROFILE_SKIP_REASON_LABEL[s.reason] ?? s.reason} {PROFILE_SKIP_KIND_LABEL[s.kind] ?? s.kind} {s.target} - {s.detail} + {s.detail}
    • ))} @@ -517,4 +983,3 @@ function Skipped({ list }: { list: CatalogProfileSkip[] }) {
    ) } - diff --git a/frontend/src/pages/catalog/ProfileSettings.tsx b/frontend/src/pages/catalog/ProfileSettings.tsx index 072b92f2..ddd9a3ec 100644 --- a/frontend/src/pages/catalog/ProfileSettings.tsx +++ b/frontend/src/pages/catalog/ProfileSettings.tsx @@ -14,6 +14,8 @@ import type { ProfileForm } from './profile' // 数据剖析的设置:开关、五项预算、风险说明。数据源编辑框里是其中一段;导入表格的源没有编辑框,数据目录页在 // 剖析被拒(未开启)时也要就地打开它,所以另有一个只改这一项的弹窗(options 的其余键原样带上)。 // 剖析会对业务库发查询,开关默认关着;开启后五项预算才展开,风险说明写的是此刻填的数。 +// 预算按表算:每张表最多查询数、每张表时长上限(用完只停这张表),另有单条查询时限、抽样键值数、整表统计行数上限; +// 没有整次剖析的总量上限(任务自己在连续失败、多数超时时停下)。 // =========================================================================== /** 保存被拒时的落点:某一项,或者整段(认不出是哪一项) */ @@ -57,7 +59,7 @@ export function ProfileSettingsSection({ idPrefix, form, onChange, serverError }

    - {PT.risk(s.max_queries, s.query_timeout_s, s.max_total_s)} + {PT.risk(s.table_max_queries, s.table_max_s, s.query_timeout_s)}

    )}
    diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index 0f0882c4..ebfbc768 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -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(null) const [loadError, setLoadError] = useState(null) @@ -620,6 +628,11 @@ export function TableDetail({ focusPath={focusPath} />} + {profile && ( + r.table_name === table) ?? null} + enabled={profileEnabled} profile={profile} onFillCodes={detail.in_schema ? fillCodesOf : undefined} /> + )} +

    {CATALOG_IMPACT_TEXT.title}

    @@ -1037,3 +1050,151 @@ function RelationEditor({ drafts, initial, problems, rows, onChange }: {
    ) } + +// --------------------------------------------------------------------------- +// 数据剖析 +// --------------------------------------------------------------------------- + +export interface TableProfileProps { + /** 页面跟着的剖析任务(进行中的、刚结束的) */ + job: CatalogProfileJob | null + /** 建只含这张表的剖析任务;409、422 照原样抛 */ + onStart: (table: string) => Promise + /** 正忙(409)时取进行中的那一个,页面改跟着它 */ + onShowActive: () => Promise + /** 打开这个数据源的剖析设置 */ + 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 = ( + +

    {liveActive.status === 'running' ? PT.detailRunning : PT.detailPending}

    + {liveActive.status === 'running' && liveActive.current &&

    {liveActive.current}

    } + +
    + ) + } + 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 ( +
    +
    +

    {PT.title}

    + {PT.detailHint} + + {profilable && ( + + )} +
    +
    + {!enabled && profilable && ( +

    + {PT.profileThisDisabled} + +

    + )} + {blocked && ( + +

    {PT.blocked[blocked.kind] ?? PT.blocked.other}

    +

    {blocked.message}

    + {blocked.where &&

    {blocked.where}

    } + {blocked.kind === 'busy' && ( + + )} + {blocked.kind === 'disabled' && ( + + )} +
    + )} + {status} + {!last && !liveActive && ( +

    + {!detail.in_schema ? PT.detailMissing : isView ? PT.detailView : !profilable ? PT.detailNoChecks : PT.detailNever} +

    + )} + {last && ended && ( + <> +
    + {LastIcon && } + {PT.taskStatus[last.status]} + {lastAt && {PT.detailLast(formatTime(lastAt))}} + {last.created_by && · {PT.detailBy(last.created_by)}} + {last.queries > 0 && · {PT.tableQueries(last.queries)}} + + {canOpenJob && ( + + )} +
    + {PT.findings(last.summary, true) && ( +

    {PT.findings(last.summary, true)}

    + )} + + + )} +
    +
    + ) +} diff --git a/frontend/src/pages/catalog/TableIndex.tsx b/frontend/src/pages/catalog/TableIndex.tsx index c9386d7e..a821e24c 100644 --- a/frontend/src/pages/catalog/TableIndex.tsx +++ b/frontend/src/pages/catalog/TableIndex.tsx @@ -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 + /** 只看需要剖析的表 */ + dueOnly?: boolean + onDueOnly?: (v: boolean) => void + /** 需要剖析的表有几张(按搜索词算) */ + dueTotal?: number }) { const radio = useRadioGroup(LIST_FILTERS, filter, onFilter) const listRef = useRef(null) @@ -53,6 +67,8 @@ export function TableIndex({ const lastClicked = useRef(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({ ))}
    + {onDueOnly && (profileOn || dueOnly) && ( +
    + +
    + )} {usedOnly && (
    {rows.map((r, i) => ( + onToggle={toggle} onOpen={onOpen} onKey={onRowKey} profileOn={profileOn} live={profileLive?.get(r.table_name) ?? null} now={now} /> ))} @@ -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) => void onOpen: (table: string) => void onKey: (i: number, e: ReactKeyboardEvent) => 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 && · {CT.view}} {r.relations > 0 && · {CT.relationCount(r.relations)}} + {cell && ( + + {PT.title}:{cell.text} + + )} {CT.usage(r.usage)} {!r.in_schema && ( diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts index 4ec9d547..cb856979 100644 --- a/frontend/src/pages/catalog/draftJob.ts +++ b/frontend/src/pages/catalog/draftJob.ts @@ -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(['ok', 'partial', 'failed', 'skipped', 'cancelled']) /** 「只重试失败的表」带上的:失败、部分完成、已停止 */ export const RETRYABLE = new Set(['failed', 'partial', 'cancelled']) diff --git a/frontend/src/pages/catalog/model.ts b/frontend/src/pages/catalog/model.ts index e2974a2e..7de1f733 100644 --- a/frontend/src/pages/catalog/model.ts +++ b/frontend/src/pages/catalog/model.ts @@ -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 { +/** 各筛选项下有几张表(筛选按钮上的数字,按搜索词、「只看运行中查询过的表」「只看需要剖析的表」算) */ +export function filterCounts(rows: CatalogTableRow[], q: string, used = false, due = false): Record { const out: Record = { 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 } diff --git a/frontend/src/pages/catalog/profile.ts b/frontend/src/pages/catalog/profile.ts index 236b7a99..2168fb5d 100644 --- a/frontend/src/pages/catalog/profile.ts +++ b/frontend/src/pages/catalog/profile.ts @@ -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 = { - 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 | null | undef /** * 表单 → 要写进 options 的设置。原来没有(stored 为假)、现在也没开、一项都没填的不写(不给 options 平白多一个键); - * 其余写开关和填了的项。填的不是数时原样交上去,由服务端拒收并说明是哪一项(表单上已经先拦过一次) + * 其余写开关和填了的项。填的不是数时原样交上去,由服务端拒收并说明是哪一项(表单上已经先拦过一次)。 + * 旧的两个键(整次的查询次数上限、总时长上限)不在表单里,保存时不带——服务端也会摘掉 */ export function profileOptionOf(form: ProfileForm, stored: boolean): Record | undefined { const filled = PROFILE_NUMBER_KEYS.filter((k) => form.values[k].trim()) @@ -107,7 +115,7 @@ export function profileProblems(form: ProfileForm): Partial | null | undefined, value: Record | undefined): Record { + 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): 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(['ok', 'partial', 'failed', 'skipped', 'cancelled']) +export const isProfileTaskEnded = (t: { status: CatalogProfileTaskStatus }): boolean => TASK_ENDED.has(t.status) +/** 「重试没做完的表」带上的:失败、部分完成、已停止(同服务端 retry) */ +export const PROFILE_RETRYABLE = new Set(['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 = { 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 | null | undefined, value: Record | undefined): Record { - const { [PROFILE_OPTION]: _old, ...rest } = options ?? {} - return value ? { ...rest, [PROFILE_OPTION]: value } : rest -} - -/** 剖析报告里一列码值的键(表名 + 列名):从报告去填含义、填完回到报告时按它对上是哪一列 */ +/** 剖析结果里一列码值的键(表名 + 列名):从结果去填含义、填完回到结果时按它对上是哪一列 */ export const fillKey = (table: string, column: string): string => `${table}\u0000${column}` diff --git a/frontend/src/pages/catalog/profileJob.ts b/frontend/src/pages/catalog/profileJob.ts new file mode 100644 index 00000000..23dd2ca7 --- /dev/null +++ b/frontend/src/pages/catalog/profileJob.ts @@ -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(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((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 => { + 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 => { + 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 => { + 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 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index edd239da..78de787e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 + /** 进行中时正在做哪一项的整句(如「正在核对 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 + /** 整个任务发出的查询数、其中超时的、失败的 */ + 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 } // ===========================================================================