From f4755056e69574d4a0dd20740b37e3dadea43432 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 14:46:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=B7=9F=E4=B8=8A=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=8E=A2=E6=9F=A5=EF=BC=9A=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8A=E7=9A=84=E8=BF=9B=E5=BA=A6=E3=80=81?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E3=80=81=E7=BB=93=E6=9E=9C=EF=BC=8C=E6=8E=A2?= =?UTF-8?q?=E6=9F=A5=E8=8C=83=E5=9B=B4=E9=9D=A2=E6=9D=BF=EF=BC=9B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=B8=85=E5=8D=95=E7=9A=84=E5=87=A0=E7=A7=8D=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E3=80=8D=E3=80=81=E5=88=86=E6=89=B9=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=81=E6=90=9C=E7=B4=A2=E8=8C=83=E5=9B=B4=E5=A4=96?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E5=B9=B6=E5=8A=A0=E5=85=A5=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:探查改成了后台任务(POST /introspect 回 202 和任务),界面不能再等同步结果;几千张表的库要能设范围、看命中数, 目录清单要说清表为什么不在,范围外的表要找得到、加得进来。这一波直接进 main,不能有半截的功能。 - 数据源卡片:「探查结构」开始后台任务,进行中每秒取一次进度(进度条、正在读哪张、排在谁后面),「停止」停在当前一批之后; 结束时换上任务带回的数据源、说一句结果;最近一次没做完(部分读不到、停止、中断、失败)常驻一行,读不到的表可展开。 规则命中超过上限时照实说命中数并给「调整探查范围」;已有任务时跟上那一个;对象清单拿不到时重取卡片显示原因。 有范围外的表时写「探查范围 400 / 3,000」。换 schema 后的重新探查、按配置重新探查、新建后的探查都走任务。 - 探查范围面板(IntrospectScopeDialog):全部对象 / 按规则,包含、排除(排除优先)、显式列出、显式移出、包含视图、上限; 边改边数命中(停手 400 毫秒问服务端,不连库),超上限写明会被拒绝、「保存并探查」点不了;一律在范围内的表说明和举例; 按需加入的留痕;没有对象索引时「刷新对象索引」(dry_run)。界面不算命中,规则只在服务端一处。 - 数据目录:表清单一次画 200 行、「显示更多」;表结构里没有的按 absent_reason 分开说(只有真删除说「表已不在」),老服务端 不给原因时照旧;有范围外的表时搜索词同时在对象索引里找,命中列在清单下面、「加入范围」后清单重取;顶部说明按探查范围写 (老缓存照旧说只探查了前 N 张、请重新探查);表详情按原因说;起草、剖析排在探查后面时写明。 - 运行面板的查看记录读 added_to_scope,展开区写「按需加入探查范围」。 - 类型、接口、文案各写各的块(types.ts / typesApp.ts 末尾、client.ts 的 datasources 和 dataCatalog、terms.ts 的 INTROSPECT_TEXT / INTROSPECT_SCOPE_TEXT / CATALOG_ABSENT_TEXT / CATALOG_SCOPE_TEXT),SCHEMA_PARTIAL_TEXT 改成按探查范围说。 - 检查:check-manage 的假探查改回 202 和任务、加「数据 · 后台探查与探查范围」一节(超上限、刷新对象索引、边改边数、 保存并探查、进度、停止、结果);check-catalog 的「只探查了一部分表」改成老缓存的说法,加「探查范围:三种不在、分批显示、 加入范围」一节。两项检查 1,135 项全过。 - introspect_jobs 的一句报错不用「连不上」(check-copy)。 --- backend/app/data/introspect_jobs.py | 2 +- frontend/src/api/client.ts | 50 +++- frontend/src/lib/terms.ts | 161 +++++++++++- frontend/src/pages/DataSourcesTab.tsx | 194 ++++++++++++-- frontend/src/pages/IntrospectScopeDialog.tsx | 260 +++++++++++++++++++ frontend/src/pages/catalog/CatalogPage.tsx | 33 ++- frontend/src/pages/catalog/ProfileDialog.tsx | 7 +- frontend/src/pages/catalog/TableDetail.tsx | 8 +- frontend/src/pages/catalog/TableIndex.tsx | 126 ++++++++- frontend/src/run/decode.ts | 4 + frontend/src/types.ts | 183 ++++++++++++- frontend/src/typesApp.ts | 10 + scripts/check-catalog.mjs | 102 +++++++- scripts/check-manage.mjs | 154 ++++++++++- 14 files changed, 1224 insertions(+), 70 deletions(-) create mode 100644 frontend/src/pages/IntrospectScopeDialog.tsx diff --git a/backend/app/data/introspect_jobs.py b/backend/app/data/introspect_jobs.py index 0cdf8b64..37d9205e 100644 --- a/backend/app/data/introspect_jobs.py +++ b/backend/app/data/introspect_jobs.py @@ -51,7 +51,7 @@ STOPPED_ERROR = "探查已停止" SOURCE_GONE = "数据源不存在,可能已被删除" SOURCE_DISABLED = "数据源「{name}」已停用,探查已停止" JOB_ERROR = "探查任务出错:{line}" -CONNECT_ERROR = "读表结构时连不上数据库:{line}" +CONNECT_ERROR = "读表结构时无法连接数据库:{line}" UPLOAD_REFUSED = "上传的表格无需重新探查,重新上传即可更新" RECORDS_REFUSED = "记录表的结构由应用的表单维护,无需重新探查" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 079342e4..047c647e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,7 +2,8 @@ import type { ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileJob, CatalogProfileTaskDetail, CatalogReviewAction, CatalogReviewBatchItem, CatalogReviewBatchResult, CatalogReviewKind, CatalogReviewProgress, CatalogReviewQueue, - CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview, + CatalogScopeAdd, CatalogSearch, CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview, IntrospectJob, IntrospectScope, + IntrospectScopeRules, EditRequest, ImportRecord, ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut, RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging, @@ -132,18 +133,32 @@ export interface ProviderDraft { extra?: Record } -/** 探查结构的预览(dry_run):只看不存,缓存和同步时间都没动 */ +/** + * 探查结构的预览(dry_run):只看不存,表结构缓存和同步时间都没动。配置里的那个 schema 刷新了对象索引(stored),别的 schema + * 只看不存;table_count 是对象索引里的对象数,scope 是按探查范围的规则数的命中 + */ export interface IntrospectPreview { dry_run: true /** 这次探的是哪个 schema,'' 是默认 schema */ schema: string table_count: number - /** 带 schema 前缀的对象全名,最多 200 个 */ + /** 带 schema 前缀的对象全名,最多 200 个(举例用) */ tables: string[] truncated: boolean total: number schema_error: string available_schemas: string[] + /** 老服务端没有下面几项 */ + stored?: boolean + scope?: { hits: number; cap: number; in_scope: number; out_of_scope: number; over: boolean } & Record + index_method?: string + index_capped?: boolean +} + +/** 探查结构(后台任务)的回执、任务进度、停止:任务和数据源此刻的样子 */ +export interface IntrospectOut { + job: IntrospectJob + source: DataSource } /** 一张表的结构化列信息(和给模型看的 detail 文本出自同一份缓存) */ @@ -299,8 +314,28 @@ export const api = { ? upload('/datasources/upload', form, opts) : request('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal }) }, - introspect: (id: string, schema?: string) => - post(`/datasources/${id}/introspect${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`, {}), + /** + * 探查结构:后台任务(202,回 {job, source})。规则命中超过上限 409 scope_too_large(带 hits、cap);同源已有探查任务 + * 409 introspect_busy;对象清单都拿不到 400 introspect_failed(失败照实记进了表结构缓存)。真探查只按配置里的 schema + */ + introspect: (id: string) => post(`/datasources/${encodeURIComponent(id)}/introspect`, {}), + introspectJob: (id: string, jobId: string, opts?: RequestOptions) => + get(`/datasources/${encodeURIComponent(id)}/introspect-jobs/${encodeURIComponent(jobId)}`, opts), + activeIntrospectJob: (id: string, opts?: RequestOptions) => + get<{ job: IntrospectJob | null }>(`/datasources/${encodeURIComponent(id)}/introspect-jobs/active`, opts), + /** 停止探查:排队的直接停,进行中的停在当前这一批之后,读完的表照常更新进表结构。已经结束的 409 introspect_finished */ + cancelIntrospect: (id: string, jobId: string) => + post(`/datasources/${encodeURIComponent(id)}/introspect-jobs/${encodeURIComponent(jobId)}/cancel`, {}), + /** 探查范围:规则、命中数(对着存着的对象索引数,不连库)、一律在范围内的表、按需加入的留痕 */ + introspectScope: (id: string, opts?: RequestOptions) => + get(`/datasources/${encodeURIComponent(id)}/introspect-scope`, opts), + /** 按改了一半的规则数命中(不存、不连库) */ + previewIntrospectScope: (id: string, scope: IntrospectScopeRules, opts?: RequestOptions) => + request(`/datasources/${encodeURIComponent(id)}/introspect-scope/preview`, + { method: 'POST', body: JSON.stringify({ scope }), signal: opts?.signal }), + /** 保存探查范围的规则:下一次探查时生效。规则不合规 422 scope_invalid */ + saveIntrospectScope: (id: string, scope: IntrospectScopeRules) => + put(`/datasources/${encodeURIComponent(id)}/introspect-scope`, { scope }), /** 换个 schema 看看:只返回探到的结果,不覆盖缓存(Copilot 看到的还是原来那份) */ previewSchema: (id: string, schema?: string) => post(`/datasources/${id}/introspect${qs({ schema, dry_run: 'true' })}`, {}), @@ -466,6 +501,11 @@ export const api = { /** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */ putGuidance: (sourceId: string, guidance: string) => put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }), + /** 在对象索引里按名字、注释找表(含探查范围外的);清单只列范围内的,范围外的靠它找到再加入 */ + search: (sourceId: string, q: string, opts?: RequestOptions) => + get(`${catalogBase(sourceId)}/search${qs({ q, limit: 50 })}`, opts), + /** 把范围外的表加进探查范围:当场读结构,并记进探查范围的「显式列出」 */ + addToScope: (sourceId: string, tables: string[]) => post(`${catalogBase(sourceId)}/scope/add`, { tables }), /** * 剖析任务(后台进行:逐表对业务库发只读查询,一条一条发,每张表一结束就写进目录)。剖析会对业务库发查询,默认关闭, * 按数据源开启。和同一个数据源的起草任务按建立顺序排队(起草在排队、进行中时剖析建成 queued、waiting_for 为 draft) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index d90db81e..8f72bb96 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2355,6 +2355,7 @@ export const CATALOG_TEXT = { updatedAt: (at: string) => `${at} 修改`, notStarted: '尚未建立目录', missingBody: '表结构中已没有这张表(可能已删除或改名)。目录仍然保留,可以查看、驳回或删除其中的项。', + // 为什么不在(absent_reason)分开说的话见 CATALOG_ABSENT_TEXT;老服务端不给原因时用上面两句 sectionTable: '表级信息', sectionColumns: '列', sectionRelations: '关联关系', @@ -2614,12 +2615,14 @@ export const CATALOG_DRAFT_TEXT = { strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`, stripQueuedProfile: (total: number) => `起草任务排在剖析任务后面,剖析结束后自动开始(共 ${formatNumber(total)} 张表)`, + stripQueuedIntrospect: (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: '起草任务排在剖析任务后面,剖析结束后自动开始,点击查看', + headerQueuedIntrospect: '起草任务排在探查任务后面,探查结束后自动开始,点击查看', stripOpen: '查看进度', toast: { succeeded: (summary: string) => `数据目录起草完成:${summary}`, @@ -2783,16 +2786,161 @@ export const CATALOG_DRIFT_TEXT = { * 表结构里,数据目录列不出,助手也看不到它们的字段。数据目录页顶部和助手「参考了哪些表」那一行都要说出来。 */ export const SCHEMA_PARTIAL_TEXT = { - /** 数据目录页顶部:「这个数据源共有 205 张表,只探查了前 200 张」 */ + /** 数据目录页顶部:「这个数据源共有 3,000 张表,探查范围内 400 张」(有对象索引的源,next-round 03 A1) */ title: (total: number, explored: number) => + `这个数据源共有 ${formatNumber(total)} 张表,探查范围内 ${formatNumber(explored)} 张`, + body: (rest: number) => `其余 ${formatNumber(rest)} 张在探查范围外:对象索引里有它们的名字,但没有读取结构,` + + '不在清单中;助手点名范围外的表时会当场读取、加入范围。', + next: '要用的表不在清单中:在左上角的搜索框搜索它并「加入范围」,或回到数据源卡片调整「探查范围」后重新探查。', + /** 升级前只探查了一部分的老缓存(没有对象索引):「共 205 张表,只探查了前 200 张」 */ + legacyTitle: (total: number, explored: number) => `这个数据源共有 ${formatNumber(total)} 张表,只探查了前 ${formatNumber(explored)} 张`, - body: (rest: number) => `探查结构时每个数据源最多取 200 张表(按数据库列出的顺序):生产库动辄上千张,全部取回既慢也看不完。` - + `其余 ${formatNumber(rest)} 张不在数据目录中,助手也看不到它们的字段。`, - next: '所需的表不在清单中时,请回到数据源卡片换一个 schema,或改用只能看到所需表的数据库账号缩小范围,然后重新「探查结构」。', + legacyBody: (rest: number) => `其余 ${formatNumber(rest)} 张不在数据目录中,助手也看不到它们的字段。`, + legacyNext: '请回到数据源卡片重新「探查结构」:新的探查会列出全部对象,按探查范围读取结构。', /** 助手「参考了哪些表」那一行:「共 205 张,只探查了 200 张」 */ context: (total: number, explored: number) => `共 ${formatNumber(total)} 张,只探查了 ${formatNumber(explored)} 张`, } +// =========================================================================== +// 后台探查、探查范围、对象索引(next-round 03 A1、A2)。探查结构改成了后台任务:卡片上看进度、能停止、看结果; +// 几千张表的库只完整读取「探查范围」内的表,全库对象进对象索引 +// =========================================================================== + +/** 同源队里排在前面的事(waiting_for)的叫法 */ +export const LANE_KIND_LABEL: Record = { + draft: '起草任务', profile: '剖析任务', import: '导入目录', sync_draft: '起草', introspect: '探查任务', +} + +export const INTROSPECT_TEXT = { + button: '探查结构', + buttonHint: '读取探查范围内的表结构并缓存,助手据此编写 SQL。在后台进行,可以离开页面', + running: (done: number, total: number) => `探查中 ${formatNumber(done)}/${formatNumber(total)}`, + queued: '排队中', + queuedBehind: (kind: string) => `排在${LANE_KIND_LABEL[kind] ?? kind}后面,结束后自动开始`, + current: (table: string) => `正在读取 ${table}`, + stop: '停止', + stopHint: '停在当前这一批之后:已读完的表照常更新进表结构,没读的保留上一次的结构', + started: (name: string, total: number) => `「${name}」开始探查:探查范围内 ${formatNumber(total)} 张表,在后台进行`, + done: (name: string, n: number, outside: number) => + `「${name}」探查完成:${formatNumber(n)} 张表` + (outside ? `,另有 ${formatNumber(outside)} 张在探查范围外` : ''), + partial: (name: string, ok: number, failed: number) => + `「${name}」探查完成:${formatNumber(ok)} 张读到了结构,${formatNumber(failed)} 张读不到`, + cancelled: (name: string, ok: number, total: number) => + `「${name}」已停止探查:读完的 ${formatNumber(ok)} / ${formatNumber(total)} 张已更新进表结构`, + failed: (name: string, error: string) => `「${name}」探查失败:${error}`, + interrupted: (name: string) => `「${name}」的探查因服务重启中断:读完的已更新进表结构,请重新探查`, + empty: '连接成功,但该 schema 下没有对象,请检查 schema 名称是否正确', + /** 卡片上最近一次探查没做完时常驻的一行 */ + result: { + partial: (failed: number) => `上次探查有 ${formatNumber(failed)} 张表读不到结构,其余已更新`, + cancelled: (done: number, total: number) => + `上次探查已停止:读完 ${formatNumber(done)} / ${formatNumber(total)} 张,其余保留上一次的结构`, + interrupted: '上次探查因服务重启中断:读完的已更新进表结构,其余保留上一次的结构', + failed: (error: string) => `上次探查失败:${error}`, + } as const, + failuresHead: '读不到结构的表', + tooLargeAction: '调整探查范围', + rerun: '重新探查', + /** 卡片上的范围标签:「探查范围 400 / 3,000」 */ + scopeChip: (inScope: number, total: number) => `探查范围 ${formatNumber(inScope)} / ${formatNumber(total)}`, + scopeChipHint: '探查范围内的表读取完整结构;范围外的表在对象索引里有名字,助手点名时按需加入,数据目录页搜得到', + schemaChanged: (schema: string) => `已将 schema 改为 ${schema},正在按新配置探查`, + schemaChangedFailed: (schema: string, why: string) => + `schema 已改为 ${schema},但没能开始探查(${why}):助手读取的仍是原来的结构。请再次点击「探查结构」`, +} + +export const INTROSPECT_SCOPE_TEXT = { + open: '探查范围', + openHint: '设定完整读取结构的表:按通配规则包含、排除,或逐张列出', + title: (name: string) => `「${name}」的探查范围`, + intro: (cap: number, max: number) => `探查结构时只完整读取探查范围内的表(缺省最多 ${formatNumber(cap)} 张,最多可调到 ` + + `${formatNumber(max)} 张)。全库的对象都在对象索引里:数据目录页搜得到,助手点名范围外的表时当场读取、加入范围。`, + modeLabel: '范围', + mode: { all: '全部对象', rules: '按规则' } as Record<'all' | 'rules', string>, + modeHint: { + all: '对象索引里的全部表(排除的除外);超过上限时探查会被拒绝', + rules: '只要命中「包含」的表(排除的除外),加上显式列出的', + } as Record<'all' | 'rules', string>, + include: '包含', + includeHint: '每行一条通配,按表名比较,不区分大小写,如 ord_* 或 *_daily', + exclude: '排除', + excludeHint: '每行一条通配;排除优先于包含,但排不掉一律在范围内的表', + tables: '显式列出的表', + tablesHint: '每行一张,一律进范围', + removed: '显式移出的表', + removedHint: '每行一张;一律在范围内的表也只有在这里移出才会离开范围', + views: '包含视图', + maxTables: '上限(张)', + maxTablesHint: (max: number) => `1 到 ${formatNumber(max)} 之间;规则命中超过上限时探查会被拒绝,不会悄悄截断`, + counting: '正在计算命中数…', + hits: (n: number, cap: number) => `命中 ${formatNumber(n)} 张,上限 ${formatNumber(cap)} 张`, + over: (n: number, cap: number) => + `超过上限:命中 ${formatNumber(n)} 张,上限 ${formatNumber(cap)} 张。这样探查会被拒绝,请收窄规则或调高上限`, + breakdown: (rule: number, explicit: number, pinned: number) => + `规则命中 ${formatNumber(rule)} · 显式列出 ${formatNumber(explicit)} · 一律在范围内 ${formatNumber(pinned)}`, + onDemandCount: (n: number) => `另有按需加入的 ${formatNumber(n)} 张(不计上限)`, + outside: (n: number, total: number) => `范围外 ${formatNumber(n)} 张,对象索引共 ${formatNumber(total)} 个对象`, + noIndex: '还没有对象索引:刷新对象索引后才数得出命中数', + refreshIndex: '刷新对象索引', + refreshing: '正在刷新…', + refreshHint: '发一条数据字典查询,列出全库的表名和注释;不读取表结构,不改表结构缓存', + refreshed: (n: number) => `对象索引已刷新:${formatNumber(n)} 个对象`, + indexAt: (when: string) => `对象索引刷新于 ${when}`, + indexCapped: '对象索引超过 5 万个对象,只收了按名字排序的前 5 万个;超出的部分不判为已删除', + inspector: '数据库的数据字典查询不可用,对象清单由驱动列出,没有表注释', + pinnedHead: '一律在范围内的表', + pinnedBody: '有数据目录的表、已发布模板写死的 SQL 用到的表一律在探查范围内,规则排不掉,只有「显式移出」才会离开范围。', + pinnedMore: (n: number) => `等 ${formatNumber(n)} 张`, + onDemandHead: '按需加入的表', + onDemandBody: '助手查看、运行时查表结构点名了范围外的表,或者在数据目录页手工加入:当场读取结构,以后每次探查都带上。', + onDemandVia: { look: '助手查看', db_schema: '运行时查表结构', catalog: '数据目录页' } as Record, + onDemandBy: (by: string) => `由 ${by}`, + onDemandNeed: (need: string) => `需求:${need}`, + cancel: '取消', + save: '保存', + saveAndRun: '保存并探查', + saved: '已保存探查范围,下次探查时生效', + updated: (by: string | null, when: string) => (by ? `${by} 修改于 ${when}` : `修改于 ${when}`), + linesCount: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 条`, +} + +/** 表结构里没有这张表时为什么(目录清单、表详情)。只有 removed 说「表已不在」 */ +export const CATALOG_ABSENT_TEXT = { + label: { + removed: '表已不在(已删除或改名)', + missing: '数据源里没有这张表', + out_of_scope: '在探查范围外', + reflect_failed: '在探查范围内,但读不到结构', + unknown: '不在已探查的表里', + } as Record, + body: { + removed: '对象索引里已经没有这张表(已删除或改名)。目录仍然保留,可以查看、驳回或删除其中的项。', + missing: '数据源里没有这张表(全库的对象清单里也没有)。目录仍然保留,可以查看、驳回或删除其中的项。', + out_of_scope: '这张表在探查范围外:对象索引里有它,但没有读取结构。在数据源卡片的「探查范围」里加入它,' + + '或在清单的搜索框搜索它并「加入范围」。', + reflect_failed: '这张表在探查范围内,但上次探查读不到它的结构(权限不够或对象有问题)。目录仍然保留,修好后重新探查即可。', + unknown: '这张表不在已探查的表里:升级前的探查只取了一部分表,无法判断它是否还在。请回到数据源卡片重新「探查结构」。', + } as Record, +} + +/** 目录清单的分批渲染、在对象索引里找范围外的表并加入范围 */ +export const CATALOG_SCOPE_TEXT = { + more: (shown: number, total: number) => `显示更多(已显示 ${formatNumber(shown)} / ${formatNumber(total)} 张)`, + outsideHead: (n: number) => `探查范围外的表(${formatNumber(n)})`, + outsideHint: '在对象索引里有名字,没有读取结构。加入范围会当场读取结构,并记进探查范围的「显式列出」', + outsideMore: (n: number) => `另有 ${formatNumber(n)} 张匹配,请换个更具体的关键词`, + searching: '正在搜索对象索引…', + searchFailed: '搜索对象索引失败', + add: '加入范围', + adding: '正在加入…', + added: (names: string[]) => `已加入探查范围:${names.join('、')}`, + addFailed: (name: string, why: string) => `${name} 没有加入:${why}`, + readFailed: (name: string, why: string) => `${name} 已加入范围,但读不到结构:${why}`, + view: '视图', + estRows: (n: number) => `约 ${formatNumber(n)} 行`, +} + + // =========================================================================== // 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」:后台任务,逐表进行) // @@ -2872,6 +3020,7 @@ export const PROFILE_TEXT = { headerRunningHint: (done: number, total: number) => `数据剖析进行中:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,点击查看进度`, headerQueuedHint: '剖析任务排队中,点击查看', headerQueuedDraft: '剖析任务排在起草任务后面,起草结束后自动开始,点击查看', + headerQueuedIntrospect: '剖析任务排在探查任务后面,探查结束后自动开始,点击查看', // ---- 开始前:范围、预算 title: '数据剖析', @@ -2924,6 +3073,7 @@ export const PROFILE_TEXT = { interrupted: '服务重启,剖析中断', } as Record, waitingDraft: '排在起草任务后面,起草结束后自动开始。要剖析哪些表,开始时按起草后的目录再定', + waitingIntrospect: '排在探查任务后面,探查结束后自动开始。要剖析哪些表,开始时按探查后的表结构再定', // 停止时服务端不等正在执行的那条查询(它的结果记「失败」),库那边按单条时限截止它;收尾写完(至多十来秒)停止就答。 // 别写成「等这条查询结束」:单条时限设得长时,界面会让人以为要等满时限 stopping: '正在停止:正在执行的那条查询不再等待,由数据库按单条时限截止;已做完的项照常写进目录…', @@ -3149,6 +3299,7 @@ export const PROFILE_SKIP_REASON_LABEL: Record = { unsupported: '暂不支持', no_data: '没有数据', missing: '表结构中没有', + out_of_scope: '在探查范围外', high_cardinality: '取值太多', nothing: '没有可剖析的项', } @@ -5497,6 +5648,8 @@ export const LOOK_TEXT = { joined: (names: string[]) => `为连接补上 ${names.join('、')}`, missing: (where: string, names: string[]) => `没找到:${where}${where ? '的 ' : ''}${names.join('、')}`, partial: (total: number, explored: number) => `共 ${formatNumber(total)} 张,只探查了 ${formatNumber(explored)} 张`, + /** 点名或挑中探查范围外的表,当场读了结构、加进了范围(next-round 03 A2) */ + added: (names: string[]) => `按需加入探查范围:${names.join('、')}`, reason: (text: string) => `原因:${text}`, /** 应用润色时助手问回来的话:这一轮到此结束,回答就是下一轮 */ ask: '助手需要你确认', diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index cfd0c97e..b8dee988 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -6,8 +6,10 @@ import { } from 'lucide-react' import clsx from 'clsx' import { ApiError, api, uploadDecision } from '../api/client' -import type { IntrospectPreview, TableSchema, UploadProgress } from '../api/client' -import type { CurrentSnapshot, DataSource, UploadDecision, UploadMixedColumn, UploadResult } from '../types' +import type { IntrospectOut, IntrospectPreview, TableSchema, UploadProgress } from '../api/client' +import type { + CurrentSnapshot, DataSource, IntrospectJob, IntrospectJobBrief, UploadDecision, UploadMixedColumn, UploadResult, +} from '../types' import { useCatalog, useOnReconnect } from '../store/catalog' import { confirmDialog, DeleteButton, EmptyState, ErrorState, Field, HealthPill, IconButton, Modal, promptDialog, @@ -21,8 +23,8 @@ import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } fro import type { HealthRecord } from '../lib/health' import { workflowList, workflowsMentioning } from '../lib/mentions' import { - CATALOG_TEXT, MASK_BOUNDARY_TEXT, PROFILE_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, - UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, + CATALOG_TEXT, INTROSPECT_SCOPE_TEXT, INTROSPECT_TEXT, MASK_BOUNDARY_TEXT, PROFILE_TEXT, RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, + RECIPE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, } from '../lib/terms' import { useLoginOn } from '../store/auth' import { useRunClock } from '../run/useRunClock' @@ -33,6 +35,7 @@ import { } from './catalog/profile' import type { ProfileForm } from './catalog/profile' import { ImportWizard } from './import/ImportWizard' +import { IntrospectScopeDialog } from './IntrospectScopeDialog' import type { WizardEntry } from './import/ImportWizard' import { VersionsDialog } from './import/VersionsDialog' @@ -316,24 +319,90 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci // 上传的表格只有一个库,结构随版本冻结,谈不上「和配置的 schema 对不上」 const schemaDrift = !uploaded && cachedSchema != null && cachedSchema !== configured + // 探查是后台任务(next-round 03 A1):卡片跟着最近一个任务走,进行中、排队中时每秒取一次进度,结束了换上新的表数、 + // 说一句结果。页面刷新后进来,服务端给的 introspect_job 还在进行就接着跟 + const [job, setJob] = useState(row.introspect_job ?? null) + const [stopping, setStopping] = useState(false) + const [scoping, setScoping] = useState(false) + const jobActive = !!job && (job.status === 'queued' || job.status === 'running') + useEffect(() => { + const next = row.introspect_job ?? null + if (next && (!job || next.id !== job.id || (!jobActive && next.status !== job.status))) setJob(next) + }, [row.introspect_job?.id, row.introspect_job?.status]) + + const announce = (j: IntrospectJob, source: DataSource) => { + const outside = source.schema_scope?.out_of_scope ?? 0 + if (j.status === 'succeeded') toast.ok(INTROSPECT_TEXT.done(row.name, source.table_count ?? 0, outside)) + else if (j.status === 'partial') toast.warn(INTROSPECT_TEXT.partial(row.name, j.counts.ok, j.counts.failed)) + else if (j.status === 'cancelled') toast.info(INTROSPECT_TEXT.cancelled(row.name, j.counts.ok, j.total)) + else if (j.status === 'interrupted') toast.warn(INTROSPECT_TEXT.interrupted(row.name)) + else if (j.status === 'failed') toast.error(INTROSPECT_TEXT.failed(row.name, j.error ?? '')) + if (j.status === 'succeeded' && !source.table_count && !source.schema_error) toast.warn(INTROSPECT_TEXT.empty) + } + + useEffect(() => { + if (!job || !jobActive) return + let alive = true + const timer = setInterval(async () => { + try { + const out = await api.datasources.introspectJob(row.id, job.id) + if (!alive) return + setJob(out.job) + if (out.job.status !== 'queued' && out.job.status !== 'running') { + setStopping(false) + onChange(out.source) + announce(out.job, out.source) + } + } catch { + // 取不到这一次的进度:下一秒再取(断网时页面顶上有横幅) + } + }, 1000) + return () => { alive = false; clearInterval(timer) } + }, [job?.id, jobActive]) + + const started = (out: IntrospectOut) => { + onChange(out.source) + setJob(out.job) + toast.info(INTROSPECT_TEXT.started(row.name, out.job.total)) + } + + /** 开始探查。规则命中超过上限:照实说命中多少张,给「调整探查范围」;已经有任务在跑:跟上那一个 */ const introspect = async () => { - if (busy) return + if (busy || jobActive) return busySince.current = Date.now() setBusy('introspect') try { - const next = await api.datasources.introspect(row.id) - onChange(next) - if (next.table_count) { - toast.ok(`「${row.name}」探查到 ${formatNumber(next.table_count)} 个对象`) - } else if (!next.schema_error) { - // 连得上、也没报错,就是这个 schema 下真的没有对象。探查失败的红字 - // 卡片自己会显示,不再叠一条 toast - toast.warn('连接成功,但该 schema 下没有对象,请检查 schema 名称是否正确') + started(await api.datasources.introspect(row.id)) + } catch (e) { + const code = e instanceof ApiError ? e.code : undefined + if (code === 'scope_too_large') { + toast.error(e, { action: { label: INTROSPECT_TEXT.tooLargeAction, onClick: () => setScoping(true) } }) + } else if (code === 'introspect_busy') { + toast.info(humanizeError(e).reason || humanizeError(e).title) + const active = await api.datasources.activeIntrospectJob(row.id).catch(() => null) + if (active?.job) setJob(active.job) + } else { + toast.error(e) + // 对象清单都拿不到:失败照实记进了表结构缓存(原因、能选的 schema),重取一次卡片才看得到 + if (code === 'introspect_failed') onReload() } + } finally { + setBusy('') + } + } + + const stop = async () => { + if (!job || stopping) return + setStopping(true) + try { + const out = await api.datasources.cancelIntrospect(row.id, job.id) + setJob(out.job) + onChange(out.source) + announce(out.job, out.source) } catch (e) { toast.error(e) } finally { - setBusy('') + setStopping(false) } } @@ -390,13 +459,15 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci return } try { - const next = await api.datasources.introspect(row.id) - onChange(next) - toast.ok(`已将「${row.name}」的 schema 改为 ${schema},探查到 ${formatNumber(next.table_count ?? 0)} 个对象`) + // 探查是后台任务:开始了就跟着进度,结束时说结果 + const out = await api.datasources.introspect(row.id) + onChange(out.source) + setJob(out.job) + toast.info(INTROSPECT_TEXT.schemaChanged(schema)) } catch (e) { // 配置已经改了、缓存还是旧的:卡片上那行「结构和配置对不上」会一直提醒 const h = humanizeError(e) - toast.error(`schema 已改为 ${schema},但重新探查失败(${h.title}):助手读取的仍是原来的结构。请再次点击「探查结构」`, { detail: h.raw }) + toast.error(INTROSPECT_TEXT.schemaChangedFailed(schema, h.reason || h.title), { detail: h.raw }) } finally { setBusy('') } @@ -481,11 +552,25 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci {/* 上传的表格不探查:结构在导入时随版本冻结,服务端也不接受(要更新就重新上传) */} {!uploaded && ( - + )} + {!uploaded && jobActive && ( + + )} + {!uploaded && ( + )} {row.open_staging && ( @@ -523,6 +608,12 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
{row.options?.schema && schema {row.options.schema}} + {!uploaded && !!row.schema_scope?.out_of_scope && ( + + {INTROSPECT_TEXT.scopeChip(row.schema_scope.in_scope, row.schema_scope.index_total)} + + )} {masks.length > 0 && ( 遮罩 {formatNumber(masks.length)} 列 @@ -549,6 +640,10 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci )} {!!row.tools?.length && {row.tools.join(' · ')}}
+ {scoping && ( + setScoping(false)} onSaved={onReload} + onRun={() => void introspect()} /> + )} {masking && ( setMasking(false)} onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} /> @@ -568,6 +663,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci )} + {!uploaded && job && } + {row.schema_error && (
+
+ {text} + {full?.current && {INTROSPECT_TEXT.current(full.current)}} +
+
+
+
+
+ ) + } + const failed = full ? full.counts.failed : (job as IntrospectJobBrief).failed + const ok = full ? full.counts.ok : job.done - (job as IntrospectJobBrief).cancelled - (job as IntrospectJobBrief).failed + const text = job.status === 'partial' ? INTROSPECT_TEXT.result.partial(failed) + : job.status === 'cancelled' ? INTROSPECT_TEXT.result.cancelled(ok, job.total) + : job.status === 'interrupted' ? INTROSPECT_TEXT.result.interrupted + : job.status === 'failed' ? INTROSPECT_TEXT.result.failed(job.error ?? '') + : '' + if (!text) return null + return ( +
+ +
+

{text}

+ {!!full?.failures.length && ( +
+ {INTROSPECT_TEXT.failuresHead}({formatNumber(full.failures.length)}) +
    + {full.failures.map((f) => ( +
  • {f.name}{f.error ? `:${f.error}` : ''}
  • + ))} +
+
+ )} +
+
+ ) +} + /** 连接地址。端口为空时不留尾巴冒号,淡色写上默认端口 */ function Address({ row, meta, uploaded }: { row: any; meta?: any; uploaded: boolean }) { if (uploaded) { diff --git a/frontend/src/pages/IntrospectScopeDialog.tsx b/frontend/src/pages/IntrospectScopeDialog.tsx new file mode 100644 index 00000000..a8d2bdcf --- /dev/null +++ b/frontend/src/pages/IntrospectScopeDialog.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Info, RefreshCw } from 'lucide-react' +import { api } from '../api/client' +import type { IntrospectScope, IntrospectScopeRules } from '../types' +import { Modal, Spinner, toast } from '../components/ui' +import { formatDateTime, formatNumber } from '../lib/format' +import { INTROSPECT_SCOPE_TEXT as ST } from '../lib/terms' + +// =========================================================================== +// 探查范围(docs/design/next-round/03-数据接入与目录维护.md A1):几千张表的库只完整读取范围内的表。 +// +// 规则写在这里,存在数据源上(options.introspect_scope,只经 PUT /introspect-scope 改,数据源表单不碰它)。命中数是服务端 +// 对着存着的对象索引现数的(POST /introspect-scope/preview,不连库):边改边看命中多少张、超没超上限,超了的话探查会被 +// 拒绝——服务端不悄悄截断,界面也提前说。还没有对象索引的源先「刷新对象索引」(一条数据字典查询,只看不存表结构)。 +// 界面不算命中:规则怎么匹配、哪些表一律在范围内都只在服务端一处(界面层不放逻辑)。 +// =========================================================================== + +const lines = (text: string): string[] => [...new Set(text.split('\n').map((x) => x.trim()).filter(Boolean))] +const joined = (items: string[] | undefined): string => (items ?? []).join('\n') + +interface Draft { + mode: 'all' | 'rules' + include: string + exclude: string + tables: string + removed: string + views: boolean + max: string +} + +const draftOf = (rules: IntrospectScopeRules): Draft => ({ + mode: rules.mode, include: joined(rules.include), exclude: joined(rules.exclude), tables: joined(rules.tables), + removed: joined(rules.removed), views: rules.views, max: String(rules.max_tables), +}) + +const rulesOf = (d: Draft): IntrospectScopeRules => ({ + mode: d.mode, include: lines(d.include), exclude: lines(d.exclude), tables: lines(d.tables), removed: lines(d.removed), + views: d.views, max_tables: Number.parseInt(d.max, 10), +}) + +export function IntrospectScopeDialog({ source, onClose, onSaved, onRun }: { + source: { id: string; name: string } + onClose: () => void + /** 保存之后(数据源上的规则变了:卡片要重新取一次) */ + onSaved: () => void + /** 「保存并探查」:保存完由卡片开始探查 */ + onRun: () => void +}) { + const [data, setData] = useState(null) + const [draft, setDraft] = useState(null) + const [preview, setPreview] = useState(null) + const [counting, setCounting] = useState(false) + const [saving, setSaving] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState('') + const initial = useRef('') + + const load = async () => { + const got = await api.datasources.introspectScope(source.id) + setData(got) + setPreview(got.preview) + const d = draftOf(got.scope) + initial.current = JSON.stringify(rulesOf(d)) + setDraft(d) + } + useEffect(() => { load().catch((e) => setError(String((e as Error).message ?? e))) }, [source.id]) + + const limit = data?.defaults.max_limit ?? 2000 + const maxValue = draft ? Number.parseInt(draft.max, 10) : NaN + const maxBad = !draft || !Number.isInteger(maxValue) || maxValue < 1 || maxValue > limit + const rules = useMemo(() => (draft && !maxBad ? rulesOf(draft) : null), [draft, maxBad]) + const dirty = !!rules && JSON.stringify(rules) !== initial.current + + // 边改边数:停手 400 毫秒再问服务端,旧的请求作废 + useEffect(() => { + if (!rules || !data?.index.ready) return + const ctl = new AbortController() + const timer = setTimeout(() => { + setCounting(true) + api.datasources.previewIntrospectScope(source.id, rules, { signal: ctl.signal }) + .then((got) => { setPreview(got.preview); setError('') }) + .catch((e) => { if (!ctl.signal.aborted) setError(String((e as Error).message ?? e)) }) + .finally(() => { if (!ctl.signal.aborted) setCounting(false) }) + }, 400) + return () => { ctl.abort(); clearTimeout(timer) } + }, [JSON.stringify(rules), data?.index.ready]) + + const refresh = async () => { + setRefreshing(true) + try { + const got = await api.datasources.previewSchema(source.id) + if (got.schema_error) toast.warn(got.schema_error) + else toast.ok(ST.refreshed(got.table_count)) + await load() + } catch (e) { + toast.error(e) + } finally { + setRefreshing(false) + } + } + + const save = async (run: boolean) => { + if (!rules) return + setSaving(true) + try { + const got = await api.datasources.saveIntrospectScope(source.id, rules) + setData(got) + initial.current = JSON.stringify(rules) + onSaved() + toast.ok(ST.saved) + if (run) onRun() + onClose() + } catch (e) { + toast.error(e) + } finally { + setSaving(false) + } + } + + const set = (patch: Partial) => setDraft((d) => (d ? { ...d, ...patch } : d)) + const area = (key: 'include' | 'exclude' | 'tables' | 'removed', label: string, hint: string, max: number) => { + const n = draft ? lines(draft[key]).length : 0 + return ( +