mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
前端跟上后台探查:数据源卡片上的进度、停止、结果,探查范围面板;目录清单的几种「不在」、分批显示、搜索范围外的表并加入范围
为什么:探查改成了后台任务(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)。
This commit is contained in:
@@ -51,7 +51,7 @@ STOPPED_ERROR = "探查已停止"
|
||||
SOURCE_GONE = "数据源不存在,可能已被删除"
|
||||
SOURCE_DISABLED = "数据源「{name}」已停用,探查已停止"
|
||||
JOB_ERROR = "探查任务出错:{line}"
|
||||
CONNECT_ERROR = "读表结构时连不上数据库:{line}"
|
||||
CONNECT_ERROR = "读表结构时无法连接数据库:{line}"
|
||||
UPLOAD_REFUSED = "上传的表格无需重新探查,重新上传即可更新"
|
||||
RECORDS_REFUSED = "记录表的结构由应用的表单维护,无需重新探查"
|
||||
|
||||
|
||||
@@ -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<string, any>
|
||||
}
|
||||
|
||||
/** 探查结构的预览(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<string, number | boolean>
|
||||
index_method?: string
|
||||
index_capped?: boolean
|
||||
}
|
||||
|
||||
/** 探查结构(后台任务)的回执、任务进度、停止:任务和数据源此刻的样子 */
|
||||
export interface IntrospectOut {
|
||||
job: IntrospectJob
|
||||
source: DataSource
|
||||
}
|
||||
|
||||
/** 一张表的结构化列信息(和给模型看的 detail 文本出自同一份缓存) */
|
||||
@@ -299,8 +314,28 @@ export const api = {
|
||||
? upload<UploadResult>('/datasources/upload', form, opts)
|
||||
: request<UploadResult>('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal })
|
||||
},
|
||||
introspect: (id: string, schema?: string) =>
|
||||
post<any>(`/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<IntrospectOut>(`/datasources/${encodeURIComponent(id)}/introspect`, {}),
|
||||
introspectJob: (id: string, jobId: string, opts?: RequestOptions) =>
|
||||
get<IntrospectOut>(`/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<IntrospectOut>(`/datasources/${encodeURIComponent(id)}/introspect-jobs/${encodeURIComponent(jobId)}/cancel`, {}),
|
||||
/** 探查范围:规则、命中数(对着存着的对象索引数,不连库)、一律在范围内的表、按需加入的留痕 */
|
||||
introspectScope: (id: string, opts?: RequestOptions) =>
|
||||
get<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope`, opts),
|
||||
/** 按改了一半的规则数命中(不存、不连库) */
|
||||
previewIntrospectScope: (id: string, scope: IntrospectScopeRules, opts?: RequestOptions) =>
|
||||
request<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope/preview`,
|
||||
{ method: 'POST', body: JSON.stringify({ scope }), signal: opts?.signal }),
|
||||
/** 保存探查范围的规则:下一次探查时生效。规则不合规 422 scope_invalid */
|
||||
saveIntrospectScope: (id: string, scope: IntrospectScopeRules) =>
|
||||
put<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope`, { scope }),
|
||||
/** 换个 schema 看看:只返回探到的结果,不覆盖缓存(Copilot 看到的还是原来那份) */
|
||||
previewSchema: (id: string, schema?: string) =>
|
||||
post<IntrospectPreview>(`/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<CatalogSearch>(`${catalogBase(sourceId)}/search${qs({ q, limit: 50 })}`, opts),
|
||||
/** 把范围外的表加进探查范围:当场读结构,并记进探查范围的「显式列出」 */
|
||||
addToScope: (sourceId: string, tables: string[]) => post<CatalogScopeAdd>(`${catalogBase(sourceId)}/scope/add`, { tables }),
|
||||
/**
|
||||
* 剖析任务(后台进行:逐表对业务库发只读查询,一条一条发,每张表一结束就写进目录)。剖析会对业务库发查询,默认关闭,
|
||||
* 按数据源开启。和同一个数据源的起草任务按建立顺序排队(起草在排队、进行中时剖析建成 queued、waiting_for 为 draft)
|
||||
|
||||
+157
-4
@@ -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<string, string> = {
|
||||
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<string, string>,
|
||||
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<string, string>,
|
||||
body: {
|
||||
removed: '对象索引里已经没有这张表(已删除或改名)。目录仍然保留,可以查看、驳回或删除其中的项。',
|
||||
missing: '数据源里没有这张表(全库的对象清单里也没有)。目录仍然保留,可以查看、驳回或删除其中的项。',
|
||||
out_of_scope: '这张表在探查范围外:对象索引里有它,但没有读取结构。在数据源卡片的「探查范围」里加入它,'
|
||||
+ '或在清单的搜索框搜索它并「加入范围」。',
|
||||
reflect_failed: '这张表在探查范围内,但上次探查读不到它的结构(权限不够或对象有问题)。目录仍然保留,修好后重新探查即可。',
|
||||
unknown: '这张表不在已探查的表里:升级前的探查只取了一部分表,无法判断它是否还在。请回到数据源卡片重新「探查结构」。',
|
||||
} as Record<string, string>,
|
||||
}
|
||||
|
||||
/** 目录清单的分批渲染、在对象索引里找范围外的表并加入范围 */
|
||||
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<CatalogDraftJobStatus, string>,
|
||||
waitingDraft: '排在起草任务后面,起草结束后自动开始。要剖析哪些表,开始时按起草后的目录再定',
|
||||
waitingIntrospect: '排在探查任务后面,探查结束后自动开始。要剖析哪些表,开始时按探查后的表结构再定',
|
||||
// 停止时服务端不等正在执行的那条查询(它的结果记「失败」),库那边按单条时限截止它;收尾写完(至多十来秒)停止就答。
|
||||
// 别写成「等这条查询结束」:单条时限设得长时,界面会让人以为要等满时限
|
||||
stopping: '正在停止:正在执行的那条查询不再等待,由数据库按单条时限截止;已做完的项照常写进目录…',
|
||||
@@ -3149,6 +3299,7 @@ export const PROFILE_SKIP_REASON_LABEL: Record<string, string> = {
|
||||
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: '助手需要你确认',
|
||||
|
||||
@@ -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<IntrospectJob | IntrospectJobBrief | null>(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
|
||||
</button>
|
||||
{/* 上传的表格不探查:结构在导入时随版本冻结,服务端也不接受(要更新就重新上传) */}
|
||||
{!uploaded && (
|
||||
<button className="btn btn-sm tnum" disabled={!!busy} onClick={() => void introspect()}
|
||||
title="读取表结构并缓存,助手据此编写 SQL">
|
||||
<button className="btn btn-sm tnum" disabled={!!busy || jobActive} onClick={() => void introspect()}
|
||||
title={INTROSPECT_TEXT.buttonHint} data-introspect-start="">
|
||||
{busy === 'introspect'
|
||||
? <><Spinner size={11} /> 探查中 {formatDuration(clock - busySince.current)}</>
|
||||
: <><RefreshCw size={11} aria-hidden /> 探查结构</>}
|
||||
? <><Spinner size={11} /> {INTROSPECT_TEXT.button} {formatDuration(clock - busySince.current)}</>
|
||||
: jobActive && job
|
||||
? <><Spinner size={11} /> {job.status === 'queued' ? INTROSPECT_TEXT.queued : INTROSPECT_TEXT.running(job.done, job.total)}</>
|
||||
: <><RefreshCw size={11} aria-hidden /> {INTROSPECT_TEXT.button}</>}
|
||||
</button>
|
||||
)}
|
||||
{!uploaded && jobActive && (
|
||||
<button className="btn btn-sm" disabled={stopping} onClick={() => void stop()} title={INTROSPECT_TEXT.stopHint}
|
||||
data-introspect-stop="">
|
||||
{stopping ? <Spinner size={11} /> : <X size={11} aria-hidden />} {INTROSPECT_TEXT.stop}
|
||||
</button>
|
||||
)}
|
||||
{!uploaded && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setScoping(true)} title={INTROSPECT_SCOPE_TEXT.openHint}
|
||||
data-introspect-scope-open="">
|
||||
<Layers size={11} aria-hidden /> <span className="hidden sm:inline">{INTROSPECT_SCOPE_TEXT.open}</span>
|
||||
</button>
|
||||
)}
|
||||
{row.open_staging && (
|
||||
@@ -523,6 +608,12 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2.5 pt-1.5 text-2xs text-faint">
|
||||
<Address row={row} meta={meta} uploaded={uploaded} />
|
||||
{row.options?.schema && <span className="chip">schema {row.options.schema}</span>}
|
||||
{!uploaded && !!row.schema_scope?.out_of_scope && (
|
||||
<span className="chip tnum" title={INTROSPECT_TEXT.scopeChipHint}
|
||||
data-introspect-scope={`${row.schema_scope.in_scope}/${row.schema_scope.index_total}`}>
|
||||
{INTROSPECT_TEXT.scopeChip(row.schema_scope.in_scope, row.schema_scope.index_total)}
|
||||
</span>
|
||||
)}
|
||||
{masks.length > 0 && (
|
||||
<span className="chip" data-mask-columns={masks.join(',')} title={`在证据面板中显示为「已遮罩」:${masks.join('、')}`}>
|
||||
<EyeOff size={10} aria-hidden /> 遮罩 {formatNumber(masks.length)} 列
|
||||
@@ -549,6 +640,10 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
|
||||
)}
|
||||
{!!row.tools?.length && <span className="mono">{row.tools.join(' · ')}</span>}
|
||||
</div>
|
||||
{scoping && (
|
||||
<IntrospectScopeDialog source={row} onClose={() => setScoping(false)} onSaved={onReload}
|
||||
onRun={() => void introspect()} />
|
||||
)}
|
||||
{masking && (
|
||||
<MaskColumnsDialog row={row} onClose={() => setMasking(false)}
|
||||
onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} />
|
||||
@@ -568,6 +663,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!uploaded && job && <IntrospectJobLine job={job} />}
|
||||
|
||||
{row.schema_error && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -669,6 +766,59 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上探查任务的那一行:进行中写进度条、正在读哪张、排在谁后面;最近一次没做完(部分读不到、停止、中断、失败)常驻一行
|
||||
* 写明结果,读不到的表展开看。做完了的不占地方(结果已经在表数和同步时间上)
|
||||
*/
|
||||
function IntrospectJobLine({ job }: { job: IntrospectJob | IntrospectJobBrief }) {
|
||||
const full = 'counts' in job ? job : null
|
||||
if (job.status === 'queued' || job.status === 'running') {
|
||||
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0
|
||||
const text = job.status === 'queued'
|
||||
? (job.waiting_for ? INTROSPECT_TEXT.queuedBehind(job.waiting_for) : INTROSPECT_TEXT.queued)
|
||||
: INTROSPECT_TEXT.running(job.done, job.total)
|
||||
return (
|
||||
<div className="mx-3 mb-2 space-y-1 text-2xs" data-introspect-job={job.status} data-introspect-progress={`${job.done}/${job.total}`}>
|
||||
<div className="flex items-center gap-2 text-dim">
|
||||
<span className="tnum">{text}</span>
|
||||
{full?.current && <span className="mono min-w-0 truncate text-faint">{INTROSPECT_TEXT.current(full.current)}</span>}
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={text}
|
||||
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={job.done}>
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className="mx-3 mb-2 flex items-start gap-1.5 text-2xs leading-relaxed"
|
||||
style={{ color: job.status === 'failed' ? 'var(--err)' : 'var(--warn)' }} data-introspect-result={job.status}>
|
||||
<AlertTriangle size={11} className="mt-px shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>{text}</p>
|
||||
{!!full?.failures.length && (
|
||||
<details className="text-faint">
|
||||
<summary className="cursor-pointer">{INTROSPECT_TEXT.failuresHead}({formatNumber(full.failures.length)})</summary>
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{full.failures.map((f) => (
|
||||
<li key={f.name} className="break-all"><span className="mono">{f.name}</span>{f.error ? `:${f.error}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 连接地址。端口为空时不留尾巴冒号,淡色写上默认端口 */
|
||||
function Address({ row, meta, uploaded }: { row: any; meta?: any; uploaded: boolean }) {
|
||||
if (uploaded) {
|
||||
|
||||
@@ -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<IntrospectScope | null>(null)
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [preview, setPreview] = useState<IntrospectScope['preview']>(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<Draft>) => 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 (
|
||||
<label className="block space-y-1" data-scope-field={key}>
|
||||
<span className="flex items-baseline gap-2 text-xs font-medium">
|
||||
{label}<span className="flex-1" />
|
||||
<span className={n > max ? 'text-[var(--err)] tnum text-2xs' : 'tnum text-2xs text-faint'}>{ST.linesCount(n, max)}</span>
|
||||
</span>
|
||||
<textarea className="field mono min-h-[64px] text-2xs" rows={3} value={draft?.[key] ?? ''} spellCheck={false}
|
||||
onChange={(e) => set({ [key]: e.target.value } as Partial<Draft>)} data-scope-input={key} />
|
||||
<span className="block text-2xs text-faint">{hint}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const over = !!preview?.over
|
||||
return (
|
||||
<Modal open onClose={onClose} dirty={dirty} width={640} title={ST.title(source.name)}
|
||||
footer={<>
|
||||
<button className="btn" onClick={onClose}>{ST.cancel}</button>
|
||||
<button className="btn" disabled={saving || !rules || !dirty} onClick={() => void save(false)} data-scope-save="">
|
||||
{ST.save}
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={saving || !rules || over} onClick={() => void save(true)}
|
||||
data-scope-save-run="">
|
||||
{saving ? <Spinner size={11} /> : null} {ST.saveAndRun}
|
||||
</button>
|
||||
</>}>
|
||||
{!data || !draft ? (
|
||||
error ? <p className="text-xs text-[var(--err)]" role="alert">{error}</p> : <Spinner size={14} />
|
||||
) : (
|
||||
<div className="space-y-3 text-xs" data-scope-dialog="">
|
||||
<p className="leading-relaxed text-dim">{ST.intro(data.defaults.max_tables, data.defaults.max_limit)}</p>
|
||||
|
||||
<div className="rounded-lg border px-3 py-2" role="status" data-scope-hits={preview ? `${preview.hits}/${preview.cap}` : 'none'}
|
||||
style={over ? { borderColor: 'color-mix(in srgb, var(--err) 40%, var(--border))' } : undefined}>
|
||||
{!data.index.ready ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Info size={12} className="text-[var(--accent)]" aria-hidden />
|
||||
<span className="min-w-0 flex-1">{ST.noIndex}</span>
|
||||
<button className="btn btn-sm" disabled={refreshing} onClick={() => void refresh()} title={ST.refreshHint}
|
||||
data-scope-refresh="">
|
||||
{refreshing ? <Spinner size={11} /> : <RefreshCw size={11} aria-hidden />} {refreshing ? ST.refreshing : ST.refreshIndex}
|
||||
</button>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className={over ? 'font-medium text-[var(--err)]' : 'font-medium'}>
|
||||
{over && <AlertTriangle size={12} className="mr-1 inline" aria-hidden />}
|
||||
{over ? ST.over(preview.hits, preview.cap) : ST.hits(preview.hits, preview.cap)}
|
||||
{counting && <span className="ml-2 text-2xs font-normal text-faint">{ST.counting}</span>}
|
||||
</p>
|
||||
<p className="tnum text-2xs text-dim">{ST.breakdown(preview.rule, preview.explicit, preview.pinned)}</p>
|
||||
{preview.on_demand > 0 && <p className="tnum text-2xs text-dim">{ST.onDemandCount(preview.on_demand)}</p>}
|
||||
<p className="tnum text-2xs text-faint">{ST.outside(preview.out_of_scope, preview.index_total)}</p>
|
||||
<p className="flex flex-wrap items-center gap-2 text-2xs text-faint">
|
||||
{data.index.at && <span>{ST.indexAt(formatDateTime(data.index.at))}</span>}
|
||||
<button type="button" className="underline decoration-dotted underline-offset-2 hover:text-dim"
|
||||
disabled={refreshing} onClick={() => void refresh()} title={ST.refreshHint} data-scope-refresh="">
|
||||
{refreshing ? ST.refreshing : ST.refreshIndex}
|
||||
</button>
|
||||
</p>
|
||||
{data.index.capped && <p className="text-2xs text-[var(--warn)]">{ST.indexCapped}</p>}
|
||||
{data.index.method === 'inspector' && <p className="text-2xs text-faint">{ST.inspector}</p>}
|
||||
</div>
|
||||
) : <Spinner size={12} />}
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-xs font-medium">{ST.modeLabel}</legend>
|
||||
<div className="flex flex-wrap gap-3" role="radiogroup">
|
||||
{(['all', 'rules'] as const).map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5" title={ST.modeHint[m]}>
|
||||
<input type="radio" name="scope-mode" checked={draft.mode === m} onChange={() => set({ mode: m })}
|
||||
data-scope-mode={m} />
|
||||
{ST.mode[m]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xs text-faint">{ST.modeHint[draft.mode]}</p>
|
||||
</fieldset>
|
||||
|
||||
{draft.mode === 'rules' && area('include', ST.include, ST.includeHint, data.defaults.rules_max)}
|
||||
{area('exclude', ST.exclude, ST.excludeHint, data.defaults.rules_max)}
|
||||
{area('tables', ST.tables, ST.tablesHint, data.defaults.max_limit)}
|
||||
{area('removed', ST.removed, ST.removedHint, data.defaults.max_limit)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={draft.views} onChange={(e) => set({ views: e.target.checked })} data-scope-views="" />
|
||||
{ST.views}
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium">{ST.maxTables}</span>
|
||||
<input className="field tnum !w-28" inputMode="numeric" value={draft.max} aria-invalid={maxBad}
|
||||
onChange={(e) => set({ max: e.target.value.replace(/[^\d]/g, '') })} data-scope-input="max_tables" />
|
||||
</label>
|
||||
<span className={maxBad ? 'pb-1 text-2xs text-[var(--err)]' : 'pb-1 text-2xs text-faint'}>{ST.maxTablesHint(limit)}</span>
|
||||
</div>
|
||||
|
||||
<section className="space-y-1 rounded-lg border px-3 py-2" data-scope-pinned={preview?.pinned ?? 0}>
|
||||
<h4 className="text-xs font-medium">{ST.pinnedHead}{preview ? `(${formatNumber(preview.pinned)})` : ''}</h4>
|
||||
<p className="text-2xs leading-relaxed text-dim">{ST.pinnedBody}</p>
|
||||
{!!data.pinned_sample.length && (
|
||||
<p className="mono text-2xs text-faint">
|
||||
{data.pinned_sample.join('、')}
|
||||
{(preview?.pinned ?? 0) > data.pinned_sample.length ? ` ${ST.pinnedMore(preview?.pinned ?? 0)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!!data.on_demand.length && (
|
||||
<section className="space-y-1 rounded-lg border px-3 py-2" data-scope-on-demand={data.on_demand.length}>
|
||||
<h4 className="text-xs font-medium">{ST.onDemandHead}({formatNumber(data.on_demand.length)})</h4>
|
||||
<p className="text-2xs leading-relaxed text-dim">{ST.onDemandBody}</p>
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{data.on_demand.map((t) => (
|
||||
<li key={t.name} className="text-2xs" data-scope-trail={t.name}>
|
||||
<span className="mono text-fg">{t.name}</span>
|
||||
<span className="text-faint">
|
||||
{' · '}{ST.onDemandVia[t.via ?? ''] ?? t.via}
|
||||
{t.by ? ` · ${ST.onDemandBy(t.by)}` : ''}
|
||||
{t.at ? ` · ${formatDateTime(t.at)}` : ''}
|
||||
</span>
|
||||
{t.need && <span className="block truncate text-faint" title={t.need}>{ST.onDemandNeed(t.need)}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.scope.updated_at && (
|
||||
<p className="text-2xs text-faint">{ST.updated(data.scope.updated_by ?? null, formatDateTime(data.scope.updated_at))}</p>
|
||||
)}
|
||||
{error && <p className="text-2xs text-[var(--err)]" role="alert">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -443,7 +443,9 @@ export function CatalogPage() {
|
||||
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))
|
||||
? (profileQueued ? (profileStrip.waiting_for === 'draft' ? PT.headerQueuedDraft
|
||||
: profileStrip.waiting_for === 'introspect' ? PT.headerQueuedIntrospect : PT.headerQueuedHint)
|
||||
: PT.headerRunningHint(profileStrip.done, profileStrip.total))
|
||||
: PT.actionHint
|
||||
// 最近查询多数超时的提醒:对话框关着时也要看得到(页头的入口换成提醒色,悬停写全文)
|
||||
const profileNotice = profileActive ? profileJob?.notice?.trim() || null : null
|
||||
@@ -458,7 +460,9 @@ export function CatalogPage() {
|
||||
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))
|
||||
? (draftQueued ? (waitsForProfile(stripJob) ? DT.headerQueuedProfile
|
||||
: stripJob.waiting_for === 'introspect' ? DT.headerQueuedIntrospect : DT.headerQueuedHint)
|
||||
: DT.headerRunningHint(stripJob.done, stripJob.total))
|
||||
: CT.draft
|
||||
const headerDraft = (
|
||||
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={draftLabel} title={draftTitle} data-catalog-draft=""
|
||||
@@ -532,6 +536,9 @@ export function CatalogPage() {
|
||||
dueOnly={dueOnly}
|
||||
onDueOnly={dueKnown || dueOnly ? setDueOnly : undefined}
|
||||
dueTotal={dueTotal}
|
||||
sourceId={sourceId}
|
||||
outside={data?.scope?.out_of_scope ?? 0}
|
||||
onAdded={() => { void load(); void useCatalog.getState().reload('datasources') }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -586,7 +593,7 @@ export function CatalogPage() {
|
||||
/>
|
||||
{hasTables && data?.schema_truncated && (
|
||||
<SchemaPartialNotice total={data.schema_total ?? 0} explored={rows.filter((r) => r.in_schema).length}
|
||||
onGoSource={() => navigate(back)} />
|
||||
scoped={!!data.scope} onGoSource={() => navigate(back)} />
|
||||
)}
|
||||
{hasTables && usage.used > 0 && !empty && <UsageSummary {...usage} active={usedOnly ? filter : null} onFocus={focusUsed} />}
|
||||
{hasTables && stripJob && !draftOpen && <DraftStrip job={stripJob} notice={stripNotice} onOpen={openDraft} />}
|
||||
@@ -663,7 +670,10 @@ function DraftStrip({ job, notice, onOpen }: {
|
||||
onOpen: () => void
|
||||
}) {
|
||||
// 排在剖析任务后面(同一个数据源的起草和剖析按建立顺序排队):写明剖析结束后自动开始
|
||||
const text = job.status === 'queued' ? (waitsForProfile(job) ? DT.stripQueuedProfile(job.total) : DT.stripQueued(job.total)) : DT.strip(job.done, job.total)
|
||||
const text = job.status === 'queued'
|
||||
? (waitsForProfile(job) ? DT.stripQueuedProfile(job.total)
|
||||
: job.waiting_for === 'introspect' ? DT.stripQueuedIntrospect(job.total) : DT.stripQueued(job.total))
|
||||
: DT.strip(job.done, job.total)
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-xs"
|
||||
style={{ background: 'color-mix(in srgb, var(--accent) 6%, var(--bg-panel))' }}
|
||||
@@ -749,10 +759,13 @@ function useMedia(query: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 表结构只探查了一部分(每个数据源最多取 200 张表):没探查到的表不在清单里,助手也看不到。顶部常驻一条说明,
|
||||
* 写清一共几张、探查了几张、为什么只取了这些,以及下一步——回到数据源卡片换 schema 或缩小范围后重新探查
|
||||
* 有探查范围外的表(next-round 03 A1):清单只列范围内的,顶部常驻一条说明,写清一共几张、范围内几张、范围外的去哪找
|
||||
* (搜索框搜到后「加入范围」,或回数据源卡片调整探查范围)。升级前只探查了一部分的老缓存(没有对象索引,scoped 为假)
|
||||
* 照旧说「只探查了前 N 张」、请重新探查
|
||||
*/
|
||||
function SchemaPartialNotice({ total, explored, onGoSource }: { total: number; explored: number; onGoSource: () => void }) {
|
||||
function SchemaPartialNotice({ total, explored, scoped, onGoSource }: {
|
||||
total: number; explored: number; scoped: boolean; onGoSource: () => void
|
||||
}) {
|
||||
const shownTotal = Math.max(total, explored)
|
||||
return (
|
||||
<div className="flex shrink-0 items-start gap-2 border-b px-4 py-2 text-xs"
|
||||
@@ -760,9 +773,9 @@ function SchemaPartialNotice({ total, explored, onGoSource }: { total: number; e
|
||||
data-catalog-schema-partial={`${shownTotal},${explored}`}>
|
||||
<Database size={12} className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
|
||||
<div className="min-w-0 flex-1 leading-relaxed">
|
||||
<p className="font-medium">{SPT.title(shownTotal, explored)}</p>
|
||||
<p className="text-2xs text-dim">{SPT.body(Math.max(shownTotal - explored, 0))}</p>
|
||||
<p className="text-2xs text-dim">{SPT.next}</p>
|
||||
<p className="font-medium">{(scoped ? SPT.title : SPT.legacyTitle)(shownTotal, explored)}</p>
|
||||
<p className="text-2xs text-dim">{(scoped ? SPT.body : SPT.legacyBody)(Math.max(shownTotal - explored, 0))}</p>
|
||||
<p className="text-2xs text-dim">{scoped ? SPT.next : SPT.legacyNext}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm shrink-0" onClick={onGoSource} data-catalog-schema-partial-go="">
|
||||
{CT.back}
|
||||
|
||||
@@ -492,9 +492,10 @@ function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onS
|
||||
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
|
||||
<div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${pct}%`, background: tone }} />
|
||||
</div>
|
||||
{queued && job.waiting_for === 'draft' && (
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-dim" data-profile-waiting="draft">
|
||||
<Info size={11} className="mt-px shrink-0" style={{ color: 'var(--accent)' }} aria-hidden /> <span>{PT.waitingDraft}</span>
|
||||
{queued && (job.waiting_for === 'draft' || job.waiting_for === 'introspect') && (
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-dim" data-profile-waiting={job.waiting_for}>
|
||||
<Info size={11} className="mt-px shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
<span>{job.waiting_for === 'draft' ? PT.waitingDraft : PT.waitingIntrospect}</span>
|
||||
</p>
|
||||
)}
|
||||
{notice && <Notice tone="warn" className="break-words" attr={{ 'data-profile-notice': '' }}>{notice}</Notice>}
|
||||
|
||||
@@ -14,7 +14,8 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
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_ABSENT_TEXT, 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, PROFILE_TEXT as PT,
|
||||
} from '../../lib/terms'
|
||||
@@ -560,7 +561,10 @@ export function TableDetail({
|
||||
</Notice>
|
||||
)}
|
||||
{!detail.in_schema && (
|
||||
<Notice tone="warn" attr={{ 'data-catalog-missing': '' }}>{CT.missingBody}</Notice>
|
||||
// 为什么不在:服务端给的原因分开说,只有真删除才说「表已不在」;老服务端不给原因时照旧
|
||||
<Notice tone="warn" attr={{ 'data-catalog-missing': detail.absent_reason ?? '' }}>
|
||||
{(detail.absent_reason && CATALOG_ABSENT_TEXT.body[detail.absent_reason]) || CT.missingBody}
|
||||
</Notice>
|
||||
)}
|
||||
{detail.system_notes && <SystemNotesNotice />}
|
||||
{edit && <Notice tone="info">{CT.editHint}。{CT.clearHint}。</Notice>}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { AlertTriangle, History, Info, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import { AlertTriangle, History, Info, Plus, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogProfileTaskStatus, CatalogTableRow } from '../../types'
|
||||
import { useRadioGroup, useTicker } from '../../components/ui'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogProfileTaskStatus, CatalogSearchItem, CatalogTableRow } from '../../types'
|
||||
import { Spinner, toast, 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,
|
||||
CATALOG_ABSENT_TEXT as AT, CATALOG_DRAFT_TEXT as DT, CATALOG_KIND_LABEL, CATALOG_SCOPE_TEXT as ScT, CATALOG_TEXT as CT,
|
||||
CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT,
|
||||
} from '../../lib/terms'
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
@@ -24,11 +26,18 @@ import type { ListFilter, ListSort } from './model'
|
||||
// 开了数据剖析的源(或者剖析过的表)第二行多一格「剖析」:未剖析 / 3 天前剖析 / 剖析部分完成 / 剖析失败 / 剖析中 / 排队剖析,
|
||||
// 需要剖析的(从没剖析过、上次没做完、之后出现了新的待核实关系)用提醒色,悬停写上次剖析的时间和原因。筛选组下面另有
|
||||
// 「需要剖析」开关,和审阅进度的筛选叠加(审阅进度的四项数字照旧按审阅算),口径同剖析范围里的「需要剖析的表」。
|
||||
//
|
||||
// 几千张表的库(next-round 03 A1、A2):清单只列探查范围内的表,一次画 PAGE 行,「显示更多」再画下一批(不引入虚拟列表);
|
||||
// 表结构里没有的表按服务端给的原因分开说(表已不在、在探查范围外、读不到结构……),只有真删除才说「表已不在」。有范围外的
|
||||
// 表时,搜索词同时在对象索引里找,范围外的命中列在清单下面,可以「加入范围」。
|
||||
// ===========================================================================
|
||||
|
||||
/** 清单一次画几行 */
|
||||
const PAGE = 200
|
||||
|
||||
export function TableIndex({
|
||||
rows, total, counts, query, onQuery, filter, onFilter, usedOnly = false, onUsedOnly, sort, onSort, selected, onSelected, active, onOpen,
|
||||
onDraft, onProfile, profileOn = false, profileLive, dueOnly = false, onDueOnly, dueTotal = 0,
|
||||
onDraft, onProfile, profileOn = false, profileLive, dueOnly = false, onDueOnly, dueTotal = 0, sourceId, outside = 0, onAdded,
|
||||
}: {
|
||||
/** 筛选、排序后的行 */
|
||||
rows: CatalogTableRow[]
|
||||
@@ -60,6 +69,12 @@ export function TableIndex({
|
||||
onDueOnly?: (v: boolean) => void
|
||||
/** 需要剖析的表有几张(按搜索词算) */
|
||||
dueTotal?: number
|
||||
/** 这个数据源(在对象索引里找范围外的表、加入范围用) */
|
||||
sourceId?: string
|
||||
/** 探查范围外有几张表:有的话搜索词同时在对象索引里找 */
|
||||
outside?: number
|
||||
/** 加入范围之后(清单要重取) */
|
||||
onAdded?: () => void
|
||||
}) {
|
||||
const radio = useRadioGroup(LIST_FILTERS, filter, onFilter)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
@@ -67,6 +82,15 @@ export function TableIndex({
|
||||
const lastClicked = useRef<number | null>(null)
|
||||
const picked = rows.filter((r) => selected.has(r.table_name)).length
|
||||
const all = rows.length > 0 && picked === rows.length
|
||||
// 分批画:换了搜索词、筛选、排序就回到第一批;打开的那张在后面的批次里时多画到它
|
||||
const [limit, setLimit] = useState(PAGE)
|
||||
useEffect(() => { setLimit(PAGE) }, [query, filter, sort, usedOnly, dueOnly])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const i = rows.findIndex((r) => r.table_name === active)
|
||||
if (i >= limit) setLimit(Math.ceil((i + 1) / PAGE) * PAGE)
|
||||
}, [active, rows])
|
||||
const shown = rows.length > limit ? rows.slice(0, limit) : rows
|
||||
// 「3 天前剖析」「12 分钟前剖析」隔一分钟重算一次
|
||||
const now = useTicker(60_000, profileOn)
|
||||
|
||||
@@ -111,7 +135,7 @@ export function TableIndex({
|
||||
}, [])
|
||||
|
||||
const onRowKey = useCallback((i: number, e: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
const n = latest.current.rows.length
|
||||
const n = listRef.current?.querySelectorAll('[data-catalog-open-row]').length ?? latest.current.rows.length
|
||||
let j = -1
|
||||
if (e.key === 'ArrowDown') j = Math.min(n - 1, i + 1)
|
||||
else if (e.key === 'ArrowUp') j = Math.max(0, i - 1)
|
||||
@@ -204,10 +228,21 @@ export function TableIndex({
|
||||
</div>
|
||||
|
||||
<ul ref={listRef} className="relative min-h-0 flex-1 overflow-y-auto" aria-label={CT.tableCount(rows.length, total)}>
|
||||
{rows.map((r, i) => (
|
||||
{shown.map((r, i) => (
|
||||
<Row key={r.table_name} row={r} index={i} active={r.table_name === active} checked={selected.has(r.table_name)}
|
||||
onToggle={toggle} onOpen={onOpen} onKey={onRowKey} profileOn={profileOn} live={profileLive?.get(r.table_name) ?? null} now={now} />
|
||||
))}
|
||||
{rows.length > shown.length && (
|
||||
<li className="border-b border-hairline px-3 py-2">
|
||||
<button type="button" className="btn btn-sm w-full tnum" onClick={() => setLimit((n) => n + PAGE)}
|
||||
data-catalog-more={`${shown.length}/${rows.length}`}>
|
||||
{ScT.more(shown.length, rows.length)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{sourceId && outside > 0 && query.trim() && (
|
||||
<OutsideMatches sourceId={sourceId} query={query} onAdded={onAdded} />
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{selected.size > 0 && (
|
||||
@@ -296,8 +331,11 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
<span className="tnum shrink-0" title={CT.usageHint} data-row-usage={r.usage}>{CT.usage(r.usage)}</span>
|
||||
</span>
|
||||
{!r.in_schema && (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-2xs text-[var(--warn)]" data-row-missing="">
|
||||
<AlertTriangle size={10} aria-hidden /> {CT.missing}
|
||||
// 为什么不在:服务端给的原因(老服务端不给时照旧说表结构中已没有)
|
||||
<span className="mt-0.5 flex items-center gap-1 text-2xs"
|
||||
style={{ color: r.absent_reason === 'out_of_scope' || r.absent_reason === 'unknown' ? 'var(--st-waiting)' : 'var(--warn)' }}
|
||||
data-row-missing={r.absent_reason ?? ''}>
|
||||
<AlertTriangle size={10} aria-hidden /> {(r.absent_reason && AT.label[r.absent_reason]) || CT.missing}
|
||||
</span>
|
||||
)}
|
||||
{last && (
|
||||
@@ -318,3 +356,71 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 搜索词在对象索引里的命中(探查范围外的那些):每行一张,「加入范围」当场读结构、记进探查范围的显式列出,加完清单重取。
|
||||
* 停手 300 毫秒再查,旧的请求作废
|
||||
*/
|
||||
function OutsideMatches({ sourceId, query, onAdded }: { sourceId: string; query: string; onAdded?: () => void }) {
|
||||
const [items, setItems] = useState<CatalogSearchItem[] | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [adding, setAdding] = useState('')
|
||||
useEffect(() => {
|
||||
const ctl = new AbortController()
|
||||
setItems(null)
|
||||
setFailed(false)
|
||||
const timer = setTimeout(() => {
|
||||
api.dataCatalog.search(sourceId, query.trim(), { signal: ctl.signal })
|
||||
.then((got) => {
|
||||
const outside = got.items.filter((i) => !i.in_scope)
|
||||
setItems(outside)
|
||||
setTotal(Math.max(got.total - (got.items.length - outside.length), outside.length))
|
||||
})
|
||||
.catch(() => { if (!ctl.signal.aborted) setFailed(true) })
|
||||
}, 300)
|
||||
return () => { ctl.abort(); clearTimeout(timer) }
|
||||
}, [sourceId, query])
|
||||
|
||||
const add = async (name: string) => {
|
||||
setAdding(name)
|
||||
try {
|
||||
const out = await api.dataCatalog.addToScope(sourceId, [name])
|
||||
if (out.added.length) toast.ok(ScT.added(out.added))
|
||||
for (const f of out.failed) toast.warn(ScT.readFailed(f.name, f.error))
|
||||
for (const r of out.refused) toast.warn(ScT.addFailed(r.name, r.reason))
|
||||
setItems((cur) => (cur ?? []).filter((i) => i.name !== name || !!out.refused.length))
|
||||
onAdded?.()
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setAdding('')
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) return <li className="px-3 py-2 text-2xs text-faint">{ScT.searchFailed}</li>
|
||||
if (items === null) return <li className="flex items-center gap-1.5 px-3 py-2 text-2xs text-faint"><Spinner size={10} /> {ScT.searching}</li>
|
||||
if (!items.length) return null
|
||||
return (
|
||||
<li className="border-b border-hairline" data-catalog-outside={items.length}>
|
||||
<p className="px-3 pb-1 pt-2.5 text-2xs font-medium text-dim" title={ScT.outsideHint}>{ScT.outsideHead(total)}</p>
|
||||
<ul>
|
||||
{items.map((i) => (
|
||||
<li key={i.name} className="flex items-center gap-2 px-3 py-1.5 text-2xs" data-catalog-outside-row={i.name}>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="mono block truncate text-xs text-fg" title={i.qualified}>{i.name}</span>
|
||||
<span className="block truncate text-faint">
|
||||
{[i.comment, i.kind === 'view' ? ScT.view : '', i.est_rows ? ScT.estRows(i.est_rows) : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-xs shrink-0" disabled={!!adding} onClick={() => void add(i.name)}
|
||||
title={ScT.outsideHint} data-catalog-add-scope={i.name}>
|
||||
{adding === i.name ? <Spinner size={10} /> : <Plus size={10} aria-hidden />} {adding === i.name ? ScT.adding : ScT.add}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{total > items.length && <p className="px-3 pb-2 text-2xs text-faint">{ScT.outsideMore(total - items.length)}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3222,6 +3222,9 @@ export function lookEventOf(op: CopilotOp): LookEvent | null {
|
||||
...(total != null ? { total } : {}),
|
||||
...(total != null && explored != null && explored < total ? { explored } : {}),
|
||||
...(num(x.catalog_tables) != null ? { catalog_tables: num(x.catalog_tables) } : {}),
|
||||
// 按需加入探查范围的表(next-round 03 A2);老服务端没有这一项
|
||||
...(names(x.added_to_scope).length ? { added_to_scope: names(x.added_to_scope) } : {}),
|
||||
...(num(x.prefiltered) != null ? { prefiltered: num(x.prefiltered) } : {}),
|
||||
}
|
||||
})
|
||||
const status = op.status === 'index' || op.status === 'failed' ? op.status : 'ok'
|
||||
@@ -3256,6 +3259,7 @@ function lookLine(o: LookObject): string {
|
||||
o.named.length ? LOOK_TEXT.named(o.named) : '',
|
||||
o.picked?.length ? LOOK_TEXT.picked(o.picked) : '',
|
||||
o.joined?.length ? LOOK_TEXT.joined(o.joined) : '',
|
||||
o.added_to_scope?.length ? LOOK_TEXT.added(o.added_to_scope) : '',
|
||||
].filter(Boolean)
|
||||
// 口径卡(caliber)是蓝图对到的已发布口径卡上的指标(docs/design/normal-user-apps.md N2)
|
||||
const kind = o.kind
|
||||
|
||||
+179
-4
@@ -3061,7 +3061,7 @@ export interface CatalogDraftJob {
|
||||
* 排在同一个数据源的剖析任务后面(起草和剖析同源串行,按建立顺序):剖析结束后自动开始。不在等时为 null;
|
||||
* 老服务端没有这个键
|
||||
*/
|
||||
waiting_for?: 'profile' | null
|
||||
waiting_for?: 'profile' | 'introspect' | null
|
||||
tasks: CatalogDraftTask[]
|
||||
}
|
||||
|
||||
@@ -3072,7 +3072,7 @@ export interface CatalogActiveDraftJob {
|
||||
total: number
|
||||
done: number
|
||||
/** 排在剖析任务后面(同 CatalogDraftJob.waiting_for) */
|
||||
waiting_for?: 'profile' | null
|
||||
waiting_for?: 'profile' | 'introspect' | null
|
||||
}
|
||||
|
||||
/** 清单一行上这张表最近一次起草的结果 */
|
||||
@@ -3563,7 +3563,7 @@ export interface CatalogProfileJob {
|
||||
/** 重试建的任务记着原任务的 id */
|
||||
retry_of: string | null
|
||||
/** 排在同一个数据源的起草任务后面:起草结束后自动开始。不在等时为 null */
|
||||
waiting_for: 'draft' | null
|
||||
waiting_for: 'draft' | 'introspect' | null
|
||||
total: number
|
||||
/** 已结束的表数 */
|
||||
done: number
|
||||
@@ -3605,7 +3605,7 @@ export interface CatalogActiveProfileJob {
|
||||
status: CatalogProfileJobStatus
|
||||
total: number
|
||||
done: number
|
||||
waiting_for: 'draft' | null
|
||||
waiting_for: 'draft' | 'introspect' | null
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -4496,3 +4496,178 @@ export type RunEndReason = 'rejected' | 'stopped'
|
||||
export interface Run {
|
||||
end_reason?: RunEndReason | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 大库:对象索引、探查范围、后台探查(docs/design/next-round/03-数据接入与目录维护.md A1、A2,第 2 波 W2-a)
|
||||
|
||||
/** 探查范围的摘要(服务端 schema_cache.scope):范围内几张、读到结构的、读不到的、范围外的、上限、各原因几张 */
|
||||
export interface SchemaScope {
|
||||
in_scope: number
|
||||
reflected: number
|
||||
failed: number
|
||||
out_of_scope: number
|
||||
cap: number
|
||||
index_total: number
|
||||
pinned: number
|
||||
explicit: number
|
||||
rule: number
|
||||
on_demand: number
|
||||
/** dictionary 字典查询 / inspector 退回 Inspector(没有注释) */
|
||||
index_method?: string
|
||||
/** 对象索引超过了 5 万个的上限,只收了按名字排序的前面那些 */
|
||||
index_capped?: boolean
|
||||
index_at?: string
|
||||
}
|
||||
|
||||
export type IntrospectJobStatus = 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled' | 'interrupted'
|
||||
|
||||
/** 同源队里排在前面的事(waiting_for):起草、剖析、导入目录、同步起草 */
|
||||
export type LaneKind = 'draft' | 'profile' | 'import' | 'sync_draft' | 'introspect'
|
||||
|
||||
/** 数据源卡片上最近一个探查任务(DataSourceOut.introspect_job) */
|
||||
export interface IntrospectJobBrief {
|
||||
id: string
|
||||
status: IntrospectJobStatus
|
||||
total: number
|
||||
done: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
waiting_for: LaneKind | null
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/introspect-jobs/{job_id} 的 job */
|
||||
export interface IntrospectJob {
|
||||
id: string
|
||||
source_id: string
|
||||
status: IntrospectJobStatus
|
||||
/** manual 人点的 | schedule 定时维护 */
|
||||
trigger: string
|
||||
schema: string
|
||||
created_by: string | null
|
||||
created_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
total: number
|
||||
done: number
|
||||
counts: Record<'pending' | 'running' | 'ok' | 'failed' | 'cancelled', number>
|
||||
/** 正在读的那张表 */
|
||||
current: string | null
|
||||
/** 读不到结构的表(最多 50 张)和原因 */
|
||||
failures: { name: string; error: string | null }[]
|
||||
error: string | null
|
||||
/** 建任务那一刻的范围:规则原样、各原因几张、对象索引的情况 */
|
||||
scope: Partial<SchemaScope> & { rules?: IntrospectScopeRules; hits?: number; added?: number; removed?: number }
|
||||
waiting_for: LaneKind | null
|
||||
}
|
||||
|
||||
/** 探查范围的规则(数据源 options.introspect_scope) */
|
||||
export interface IntrospectScopeRules {
|
||||
mode: 'all' | 'rules'
|
||||
include: string[]
|
||||
exclude: string[]
|
||||
/** 显式列出:一律进范围 */
|
||||
tables: string[]
|
||||
/** 显式移出:有数据目录、被已发布模板用到的表也排得掉 */
|
||||
removed: string[]
|
||||
views: boolean
|
||||
max_tables: number
|
||||
updated_by?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 按规则对着对象索引数的命中 */
|
||||
export interface IntrospectScopePreview {
|
||||
in_scope: number
|
||||
/** 计上限的那部分(规则、一律在范围内、显式列出;按需加入的另算) */
|
||||
hits: number
|
||||
cap: number
|
||||
out_of_scope: number
|
||||
index_total: number
|
||||
pinned: number
|
||||
explicit: number
|
||||
rule: number
|
||||
on_demand: number
|
||||
over: boolean
|
||||
}
|
||||
|
||||
/** 按需加入、目录页手工加入范围的留痕 */
|
||||
export interface IntrospectScopeTrail {
|
||||
name: string
|
||||
scope_reason: string | null
|
||||
/** look 助手查看 | db_schema 运行时查表结构 | catalog 目录页 */
|
||||
via: 'look' | 'db_schema' | 'catalog' | null
|
||||
by: string | null
|
||||
run_id: string | null
|
||||
conversation_id: string | null
|
||||
ref: string | null
|
||||
need: string | null
|
||||
at: string | null
|
||||
}
|
||||
|
||||
/** GET / PUT /datasources/{id}/introspect-scope、POST …/preview */
|
||||
export interface IntrospectScope {
|
||||
scope: IntrospectScopeRules
|
||||
defaults: { max_tables: number; max_limit: number; index_limit: number; rules_max: number }
|
||||
/** 没有对象索引时为 null:先刷新对象索引才数得出命中 */
|
||||
preview: IntrospectScopePreview | null
|
||||
index: { ready: boolean; schema: string; method: string | null; capped: boolean; at: string | null; total: number | null }
|
||||
/** 一律在范围内的表(举前 20 个例子) */
|
||||
pinned_sample: string[]
|
||||
on_demand: IntrospectScopeTrail[]
|
||||
}
|
||||
|
||||
export interface DataSource {
|
||||
/** 探查范围的摘要;升级前探查的源、上传表格为 null */
|
||||
schema_scope?: SchemaScope | null
|
||||
/** 最近一个探查任务;没有(或升级前同步探查的)为 null */
|
||||
introspect_job?: IntrospectJobBrief | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 表结构里没有这张表时为什么(目录清单、表详情的 absent_reason):removed 表已不在(真删除)、missing 数据源里从来没有、
|
||||
* out_of_scope 在探查范围外、reflect_failed 在范围里却读不到结构、unknown 说不清(升级前只探查了一部分的老缓存)
|
||||
*/
|
||||
export type CatalogAbsentReason = 'removed' | 'missing' | 'out_of_scope' | 'reflect_failed' | 'unknown'
|
||||
|
||||
export interface CatalogTableRow {
|
||||
absent_reason?: CatalogAbsentReason | null
|
||||
}
|
||||
|
||||
export interface CatalogDetail {
|
||||
absent_reason?: CatalogAbsentReason | null
|
||||
}
|
||||
|
||||
export interface CatalogList {
|
||||
/** 探查范围的摘要;升级前探查的源、上传表格为 null */
|
||||
scope?: SchemaScope | null
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/catalog/search 的一项:对象索引里按名字、注释找到的表(含探查范围外的) */
|
||||
export interface CatalogSearchItem {
|
||||
name: string
|
||||
qualified: string
|
||||
kind: 'table' | 'view'
|
||||
comment: string | null
|
||||
est_rows: number | null
|
||||
in_scope: boolean
|
||||
scope_reason: string | null
|
||||
absent_reason: CatalogAbsentReason | null
|
||||
}
|
||||
|
||||
export interface CatalogSearch {
|
||||
items: CatalogSearchItem[]
|
||||
total: number
|
||||
/** 有对象索引(没有的只在表结构里找) */
|
||||
indexed: boolean
|
||||
}
|
||||
|
||||
/** POST /datasources/{id}/catalog/scope/add */
|
||||
export interface CatalogScopeAdd {
|
||||
added: string[]
|
||||
already: string[]
|
||||
failed: { name: string; error: string }[]
|
||||
refused: { name: string; reason: string }[]
|
||||
scope: SchemaScope | null
|
||||
}
|
||||
|
||||
@@ -864,3 +864,13 @@ export interface CapabilityReviewOutcome {
|
||||
error: string | null
|
||||
code: string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 大库下的查看(docs/design/next-round/03-数据接入与目录维护.md A2,第 2 波 W2-a)
|
||||
|
||||
export interface LookObject {
|
||||
/** 点名或挑中探查范围外的表,当场读了结构、加进了探查范围(全名) */
|
||||
added_to_scope?: string[]
|
||||
/** 候选太多、先按需求检索:交给挑选模型的表有几张 */
|
||||
prefiltered?: number
|
||||
}
|
||||
|
||||
@@ -364,6 +364,8 @@ function rowOf(t) {
|
||||
const live = (it) => (it && it.status !== 'rejected' ? it : null)
|
||||
return {
|
||||
table_name: t.name, qualified: t.name, is_view: t.is_view, in_schema: !t.missing,
|
||||
// 为什么不在(next-round 03 A1):夹具给了 absent 才带,不给时同老服务端(界面照旧说表结构中已没有)
|
||||
...(t.absent ? { absent_reason: t.absent } : {}),
|
||||
label: live(n.label)?.value ?? null, label_status: live(n.label)?.status ?? null, kind: live(n.kind)?.value ?? null,
|
||||
counts: countsOf(n), relations: (n.relations ?? []).filter((r) => r.status !== 'rejected').length,
|
||||
usage: t.usage, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
|
||||
@@ -381,10 +383,12 @@ function listOf(state, src) {
|
||||
const active = state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status))
|
||||
return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构',
|
||||
schema_truncated: total > explored, schema_total: total, guidance: state.guidance[src] ?? null,
|
||||
// 有对象索引的源才有探查范围的摘要(state.scope 里给了);没给是升级前探查的源
|
||||
...(state.scope?.[src] ? { scope: state.scope[src] } : {}),
|
||||
active_job: active ? (({ id, status, total: n, done }) => ({ id, status, total: n, done }))(jobOut(active)) : null }
|
||||
}
|
||||
const detailOf = (src, t) => ({
|
||||
table_name: t.name, in_schema: !t.missing, notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
|
||||
table_name: t.name, in_schema: !t.missing, ...(t.absent ? { absent_reason: t.absent } : {}), notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
|
||||
structure: t.missing ? null : {
|
||||
qualified: t.name, is_view: t.is_view, comment: t.comment, columns: t.columns,
|
||||
primary_key: t.columns.filter((c) => c.pk).map((c) => c.name), foreign_keys: [], unique: [],
|
||||
@@ -654,6 +658,25 @@ function catalogHandlers(state, replies = {}) {
|
||||
state.sources = state.sources.map((s) => (s.id === id ? { ...s, ...body, password: undefined } : s))
|
||||
return json(state.sources.find((s) => s.id === id))(route)
|
||||
})],
|
||||
// 在对象索引里找表(范围外的)、加入范围:要放在单表的 GET 前面(search 也对得上单表的写法)
|
||||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/search$/, (route, { key, url }) => {
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const items = (state.outside?.[parts(key)[2]] ?? []).filter((o) => o.name.includes(q) || (o.comment ?? '').includes(q))
|
||||
return json({ items: items.map((o) => ({ qualified: o.name, kind: 'table', est_rows: null, in_scope: false,
|
||||
scope_reason: null, absent_reason: 'out_of_scope', ...o })), total: items.length, indexed: true })(route)
|
||||
}],
|
||||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/scope\/add$/, (route, { key, body }) => {
|
||||
const src = parts(key)[2]
|
||||
const added = []
|
||||
for (const name of body?.tables ?? []) {
|
||||
const i = (state.outside?.[src] ?? []).findIndex((o) => o.name === name)
|
||||
if (i < 0) continue
|
||||
state.outside[src].splice(i, 1)
|
||||
state.tables[src].push(table(name, 0, [pk(), col('opened_at', 'TEXT')], null))
|
||||
added.push(name)
|
||||
}
|
||||
return json({ added, already: [], failed: [], refused: [], scope: state.scope?.[src] ?? null })(route)
|
||||
}],
|
||||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+$/, reply('get', (route, { key }) => {
|
||||
const { src, t } = findTable(state, key)
|
||||
return t ? json(detailOf(src, t))(route) : json({ detail: `数据源中没有表 ${parts(key)[4]},可能已被删除或尚未探查结构` }, 404)(route)
|
||||
@@ -2263,7 +2286,7 @@ await section('数据目录 · 空状态与导入表格', async () => {
|
||||
const profileBox = (page) => page.locator('[data-profile-dialog]')
|
||||
|
||||
await section('数据目录 · 只探查了一部分表', async () => {
|
||||
// 探查结构每个数据源最多取 200 张表:多出来的不在清单里,助手也看不到。顶部要说出来,并给出下一步
|
||||
// 升级前探查的源(没有对象索引):老缓存只探查了前 200 张,顶部照旧说出来,下一步是重新探查(新的探查列出全部对象)
|
||||
const state = freshState()
|
||||
const explored = state.tables[S1].filter((t) => !t.missing).length
|
||||
const total = explored + 57
|
||||
@@ -2275,10 +2298,8 @@ await section('数据目录 · 只探查了一部分表', async () => {
|
||||
await bar.getAttribute('data-catalog-schema-partial'))
|
||||
const text = await bar.innerText().catch(() => '')
|
||||
check('……写成「共有 N 张表,只探查了前 M 张」', text.includes(`这个数据源共有 ${total} 张表,只探查了前 ${explored} 张`), text)
|
||||
check('……说明为什么只取了这些、其余的助手也看不到', text.includes('最多取 200 张表') && text.includes('其余 57 张不在数据目录中')
|
||||
&& text.includes('助手也看不到'), text.replace(/\s+/g, ' '))
|
||||
check('……给出下一步:换 schema 或缩小范围后重新探查', text.includes('换一个 schema') && text.includes('缩小范围')
|
||||
&& text.includes('探查结构'), text.replace(/\s+/g, ' '))
|
||||
check('……其余的助手也看不到', text.includes('其余 57 张不在数据目录中') && text.includes('助手也看不到'), text.replace(/\s+/g, ' '))
|
||||
check('……给出下一步:回到数据源卡片重新探查', text.includes('重新「探查结构」') && text.includes('全部对象'), text.replace(/\s+/g, ' '))
|
||||
await shot(page, 'catalog-schema-partial')
|
||||
await cleanCopy(page, '只探查了一部分表')
|
||||
await bar.locator('[data-catalog-schema-partial-go]').click()
|
||||
@@ -2294,6 +2315,75 @@ await section('数据目录 · 只探查了一部分表', async () => {
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 探查范围:三种不在、分批显示、加入范围', async () => {
|
||||
// 几千张表的库(next-round 03 A1、A2):清单只列探查范围内的;表结构里没有的按原因分开说,只有真删除说「表已不在」;
|
||||
// 一次画 200 行,「显示更多」再画;搜索词同时在对象索引里找范围外的表,「加入范围」之后清单里就有了
|
||||
const state = freshState()
|
||||
for (let i = 1; i <= 120; i++) state.tables[S1].push(table(`arch_evt_${String(i).padStart(3, '0')}`, 0, [pk()], null))
|
||||
state.tables[S1].push(table('gone_promos', 0, null, { label: item('已删除的促销', 'human') }, { missing: true, absent: 'removed' }))
|
||||
state.tables[S1].push(table('cold_ledger', 0, null, { label: item('冷数据台账', 'human') }, { missing: true, absent: 'out_of_scope' }))
|
||||
state.tables[S1].push(table('locked_audit', 0, null, { label: item('权限受限的审计表', 'human') }, { missing: true, absent: 'reflect_failed' }))
|
||||
const explored = state.tables[S1].filter((t) => !t.missing).length
|
||||
state.schemaTotal = { [S1]: explored + 2600 }
|
||||
state.scope = { [S1]: { in_scope: explored + 1, reflected: explored, failed: 1, out_of_scope: 2600, cap: 500, index_total: explored + 2600,
|
||||
pinned: 4, explicit: 0, rule: explored - 3, on_demand: 0 } }
|
||||
state.outside = { [S1]: [{ name: 'complaint_ticket', comment: '客户投诉工单' }, { name: 'complaint_reason', comment: '投诉原因字典' }] }
|
||||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||||
await page.locator('[data-catalog-row]').first().waitFor()
|
||||
const rendered = await page.locator('[data-catalog-row]').count()
|
||||
check('一次只画 200 行', rendered === 200, String(rendered))
|
||||
const more = page.locator('[data-catalog-more]')
|
||||
const all = state.tables[S1].length
|
||||
check('……「显示更多」写明已显示几张、一共几张', (await more.getAttribute('data-catalog-more')) === `200/${all}`
|
||||
&& (await more.innerText()).includes(`已显示 200 / ${all} 张`), await more.innerText().catch(() => ''))
|
||||
await more.click()
|
||||
await until(async () => (await page.locator('[data-catalog-row]').count()) === all)
|
||||
check('……点了再画下一批,画完就不再给按钮', await page.locator('[data-catalog-row]').count() === all
|
||||
&& await page.locator('[data-catalog-more]').count() === 0, String(await page.locator('[data-catalog-row]').count()))
|
||||
const reason = async (name) => page.locator(`[data-catalog-row="${name}"] [data-row-missing]`)
|
||||
.evaluate((el) => [el.getAttribute('data-row-missing'), el.innerText]).catch(() => ['', ''])
|
||||
const [r1, t1] = await reason('gone_promos')
|
||||
check('真删除的写「表已不在」', r1 === 'removed' && t1.includes('表已不在'), t1)
|
||||
const [r2, t2] = await reason('cold_ledger')
|
||||
check('探查范围外的写「在探查范围外」,不说表已不在', r2 === 'out_of_scope' && t2.includes('在探查范围外') && !t2.includes('已不在'), t2)
|
||||
const [r3, t3] = await reason('locked_audit')
|
||||
check('读不到结构的写「读不到结构」', r3 === 'reflect_failed' && t3.includes('读不到结构'), t3)
|
||||
const [r4, t4] = await reason('legacy_coupons')
|
||||
check('老服务端不给原因的照旧写「表结构中已没有这张表」', r4 === '' && t4.includes('表结构中已没有这张表'), t4)
|
||||
const bar = page.locator('[data-catalog-schema-partial]')
|
||||
const barText = await bar.innerText().catch(() => '')
|
||||
check('顶部说明写探查范围:共几张、范围内几张、范围外的怎么找', barText.includes('探查范围内') && barText.includes('其余 2,600 张在探查范围外')
|
||||
&& barText.includes('加入范围'), barText.replace(/\s+/g, ' '))
|
||||
|
||||
// 表详情:在范围外的那张说清怎么加进来
|
||||
await page.locator('[data-catalog-row="cold_ledger"] [data-catalog-open-row]').click()
|
||||
const notice = page.locator('[data-catalog-missing]')
|
||||
await notice.waitFor({ timeout: 4000 }).catch(() => {})
|
||||
check('表详情里按原因说:范围外的给加入范围的去处', (await notice.getAttribute('data-catalog-missing')) === 'out_of_scope'
|
||||
&& (await notice.innerText()).includes('探查范围'), await notice.innerText().catch(() => ''))
|
||||
|
||||
// 搜索范围外的表并加入范围
|
||||
const search = page.locator('[data-catalog-search]')
|
||||
await search.fill('complaint')
|
||||
const outsideRow = page.locator('[data-catalog-outside-row="complaint_ticket"]')
|
||||
check('搜索词同时在对象索引里找:范围外的命中列在清单下面', await outsideRow.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||||
check('……写明在探查范围外、带注释', (await page.locator('[data-catalog-outside]').innerText()).includes('探查范围外的表')
|
||||
&& (await outsideRow.innerText()).includes('客户投诉工单'), await page.locator('[data-catalog-outside]').innerText().catch(() => ''))
|
||||
await shot(page, 'catalog-outside-search')
|
||||
await outsideRow.locator('[data-catalog-add-scope]').click()
|
||||
await until(async () => (await page.locator('[data-catalog-row="complaint_ticket"]').count()) === 1)
|
||||
const addReq = lastBody(sent, /^POST \/datasources\/check-cat-scenic\/catalog\/scope\/add$/)
|
||||
check('……「加入范围」发的是这一张', JSON.stringify(addReq) === JSON.stringify({ tables: ['complaint_ticket'] }), JSON.stringify(addReq))
|
||||
check('……加完清单重取,这张表在清单里了', await page.locator('[data-catalog-row="complaint_ticket"]').count() === 1)
|
||||
check('……范围外的命中里不再有它', await page.locator('[data-catalog-outside-row="complaint_ticket"]').count() === 0)
|
||||
await cleanCopy(page, '探查范围')
|
||||
check('只有「加入范围」一种写请求', writes(sent, /./).every((s) => /scope\/add$/.test(s.key)),
|
||||
writes(sent, /./).map((s) => s.key).join(' | '))
|
||||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 窄屏', async () => {
|
||||
const state = freshState()
|
||||
const { page, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state), viewport: { width: 390, height: 844 } })
|
||||
|
||||
+151
-3
@@ -174,6 +174,18 @@ const until = async (fn, ms = 8000, step = 200) => {
|
||||
return v
|
||||
}
|
||||
const delayed = (ms, data, status = 200) => async (route) => { await new Promise((r) => setTimeout(r, ms)); return json(data, status)(route) }
|
||||
/**
|
||||
* 后台探查(next-round 03 A1)的假任务:POST /introspect 回 202 和排队中的任务,GET 任务回做完了的样子。界面开始探查后
|
||||
* 每秒取一次进度,结束了换上任务里带回来的数据源
|
||||
*/
|
||||
const introspectJob = (sourceId, total = 3, extra = {}) => ({
|
||||
id: `job-${sourceId}`, source_id: sourceId, status: 'queued', trigger: 'manual', schema: '', created_by: null,
|
||||
created_at: ago(0), started_at: null, finished_at: null, total, done: 0,
|
||||
counts: { pending: total, running: 0, ok: 0, failed: 0, cancelled: 0 }, current: null, failures: [], error: null,
|
||||
scope: {}, waiting_for: null, ...extra,
|
||||
})
|
||||
const introspectDone = (job) => ({ ...job, status: 'succeeded', done: job.total, finished_at: ago(0),
|
||||
counts: { ...job.counts, pending: 0, running: 0, ok: job.total } })
|
||||
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` }) }
|
||||
const text = (page) => page.locator('main').innerText()
|
||||
const dialog = (page) => page.locator('[role="dialog"]').last()
|
||||
@@ -1165,11 +1177,15 @@ await section('数据 · 数据库', async () => {
|
||||
const hit = cur.options.schema === 'mes'
|
||||
cur = { ...cur, cached_schema: cur.options.schema ?? '', table_count: hit ? 3 : 0, schema_error: hit ? '' : empty.schema_error,
|
||||
schema_synced_at: hit ? ago(0) : cur.schema_synced_at }
|
||||
return json(cur)(route)
|
||||
return json({ job: introspectJob(cur.id), source: cur }, 202)(route)
|
||||
}],
|
||||
[/^POST \/datasources\/check-manage-drift\/introspect$/, (route) => {
|
||||
drift = { ...drift, cached_schema: drift.options.schema, schema_synced_at: ago(0) }
|
||||
return json(drift)(route)
|
||||
return json({ job: introspectJob(drift.id), source: drift }, 202)(route)
|
||||
}],
|
||||
[/^GET \/datasources\/check-manage-(empty|drift)\/introspect-jobs\/[^/]+$/, (route, { key }) => {
|
||||
const row = key.includes('check-manage-empty') ? cur : drift
|
||||
return json({ job: introspectDone(introspectJob(row.id)), source: row })(route)
|
||||
}],
|
||||
// 后端的规矩:连接配置(地址、账号、库)改了,上次测连接的结果作废
|
||||
[/^PATCH \/datasources\/check-manage-empty$/, (route, { body }) => {
|
||||
@@ -1184,7 +1200,12 @@ await section('数据 · 数据库', async () => {
|
||||
[/^POST \/datasources\/[^/]+\/introspect/, (route, { url }) => {
|
||||
const id = url.pathname.split('/')[3]
|
||||
const row = sources.find((s) => s.id === id)
|
||||
return json({ ...row, schema_synced_at: new Date().toISOString() })(route)
|
||||
return json({ job: introspectJob(id, row?.table_count ?? 1), source: { ...row, schema_synced_at: new Date().toISOString() } }, 202)(route)
|
||||
}],
|
||||
[/^GET \/datasources\/[^/]+\/introspect-jobs\/[^/]+$/, (route, { url }) => {
|
||||
const id = url.pathname.split('/')[3]
|
||||
const row = sources.find((s) => s.id === id)
|
||||
return json({ job: introspectDone(introspectJob(id, row?.table_count ?? 1)), source: { ...row, schema_synced_at: new Date().toISOString() } })(route)
|
||||
}],
|
||||
],
|
||||
})
|
||||
@@ -1431,6 +1452,133 @@ await section('数据 · 数据库', async () => {
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据 · 后台探查与探查范围', async () => {
|
||||
// 探查是后台任务(next-round 03 A1):卡片上看得到进度、能停止、停了写明结果;几千张表的库按「探查范围」读取,规则命中
|
||||
// 超过上限时服务端拒绝并说出命中数,界面给「调整探查范围」;范围面板边改边数命中(服务端算,不连库),说明一律在范围内的表。
|
||||
// 全部是假的:一个 3,000 张表的假库,任务怎么往前走由检查推
|
||||
const base = {
|
||||
id: 'check-manage-big', name: 'zz_bigdb', kind: 'mysql', host: '10.0.0.8', port: 3306, database: 'erp', username: 'reader',
|
||||
options: {}, readonly: true, description: '检查脚本的大库', enabled: true, password_masked: '', has_password: false,
|
||||
table_count: 400, schema_synced_at: ago(86400_000), cached_schema: '', schema_error: '', available_schemas: [],
|
||||
tools: ['db_query__zz_bigdb'], last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null,
|
||||
origin: 'manual', current_snapshot: null, introspect_job: null,
|
||||
schema_scope: { in_scope: 400, reflected: 400, failed: 0, out_of_scope: 2600, cap: 500, index_total: 3000, pinned: 3, explicit: 0,
|
||||
rule: 397, on_demand: 0, index_method: 'dictionary', index_capped: false },
|
||||
}
|
||||
let big = base
|
||||
let rules = { mode: 'all', include: [], exclude: [], tables: [], removed: [], views: true, max_tables: 500 }
|
||||
let indexed = false
|
||||
let job = null
|
||||
const hitsOf = (r) => (r.mode === 'rules' && r.include.includes('ord_*') ? 400 : 3000)
|
||||
const previewOf = (r) => {
|
||||
const hits = hitsOf(r)
|
||||
return { in_scope: hits, hits, cap: r.max_tables, out_of_scope: 3000 - hits, index_total: 3000, pinned: 3, explicit: 0,
|
||||
rule: hits - 3, on_demand: 0, over: hits > r.max_tables }
|
||||
}
|
||||
const scopeOut = (r = rules) => ({
|
||||
scope: rules, defaults: { max_tables: 500, max_limit: 2000, index_limit: 50000, rules_max: 100 },
|
||||
preview: indexed ? previewOf(r) : null,
|
||||
index: { ready: indexed, schema: '', method: indexed ? 'dictionary' : null, capped: false, at: indexed ? ago(0) : null, total: indexed ? 3000 : null },
|
||||
pinned_sample: ['ord_dim_store', 'fin_ledger', 'mbr_card'], on_demand: [],
|
||||
})
|
||||
const { page, sent, natives, errors, close } = await open('/data/databases', {
|
||||
handlers: [
|
||||
[/^GET \/datasources$/, (route) => json([...sources, big])(route)],
|
||||
[/^GET \/datasources\/check-manage-big\/introspect-scope$/, (route) => json(scopeOut())(route)],
|
||||
[/^POST \/datasources\/check-manage-big\/introspect-scope\/preview$/, (route, { body }) => json(scopeOut(body.scope))(route)],
|
||||
[/^PUT \/datasources\/check-manage-big\/introspect-scope$/, (route, { body }) => { rules = body.scope; return json(scopeOut())(route) }],
|
||||
[/^POST \/datasources\/check-manage-big\/introspect$/, (route, { url }) => {
|
||||
if (url.searchParams.get('dry_run') === 'true') {
|
||||
indexed = true
|
||||
return json({ dry_run: true, schema: '', table_count: 3000, tables: [], truncated: true, total: 3000, schema_error: '',
|
||||
available_schemas: [], stored: true, scope: previewOf(rules), index_method: 'dictionary', index_capped: false })(route)
|
||||
}
|
||||
const hits = hitsOf(rules)
|
||||
if (hits > rules.max_tables) {
|
||||
return json({ detail: `按探查范围的规则命中 ${hits.toLocaleString('en-US')} 张表(其中有数据目录或被已发布模板用到、一律在范围内的 3 张),超过上限 500 张,没有开始探查。请在「探查范围」里收窄规则(加包含、排除的通配),或者把上限调高(最多 2,000 张)`,
|
||||
code: 'scope_too_large', hits, cap: rules.max_tables, pinned: 3 }, 409)(route)
|
||||
}
|
||||
job = introspectJob(big.id, hits, { status: 'running', started_at: ago(0), counts: { pending: hits, running: 0, ok: 0, failed: 0, cancelled: 0 } })
|
||||
return json({ job, source: big }, 202)(route)
|
||||
}],
|
||||
[/^GET \/datasources\/check-manage-big\/introspect-jobs\/[^/]+$/, (route) => {
|
||||
if (job && job.status === 'running') {
|
||||
const done = Math.min(job.done + 50, job.total - 25)
|
||||
job = { ...job, done, current: `ord_line_${done}`, counts: { ...job.counts, ok: done, pending: job.total - done } }
|
||||
}
|
||||
return json({ job, source: big })(route)
|
||||
}],
|
||||
[/^POST \/datasources\/check-manage-big\/introspect-jobs\/[^/]+\/cancel$/, (route) => {
|
||||
job = { ...job, status: 'cancelled', finished_at: ago(0), done: job.total,
|
||||
counts: { ...job.counts, pending: 0, cancelled: job.total - job.counts.ok } }
|
||||
big = { ...big, introspect_job: { id: job.id, status: 'cancelled', total: job.total, done: job.total, failed: 0,
|
||||
cancelled: job.counts.cancelled, waiting_for: null, finished_at: ago(0), error: null } }
|
||||
return json({ job, source: big })(route)
|
||||
}],
|
||||
],
|
||||
})
|
||||
const card = page.locator(`[data-source="${base.name}"]`)
|
||||
await card.waitFor()
|
||||
check('卡片上写探查范围:范围内几张 / 对象一共几张', (await card.locator('[data-introspect-scope]').getAttribute('data-introspect-scope')) === '400/3000'
|
||||
&& (await card.locator('[data-introspect-scope]').innerText()).includes('探查范围 400 / 3,000'))
|
||||
|
||||
// 缺省范围(全部对象,上限 500)碰到 3,000 张:拒绝并说出命中数,给「调整探查范围」
|
||||
await card.locator('[data-introspect-start]').click()
|
||||
const toastBox = page.locator('[role="status"], [role="alert"]').filter({ hasText: '命中 3,000 张' }).first()
|
||||
check('规则命中超过上限:照实说命中了多少张、上限多少', await toastBox.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||||
const adjust = page.getByRole('button', { name: '调整探查范围' })
|
||||
check('……给「调整探查范围」', await adjust.count() >= 1)
|
||||
check('……没有开始任务:卡片上没有进度', await card.locator('[data-introspect-job]').count() === 0)
|
||||
await adjust.first().click()
|
||||
const dlg = page.locator('[data-scope-dialog]')
|
||||
check('点了打开探查范围面板', await dlg.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||||
|
||||
// 还没有对象索引:先刷新(一条字典查询,只看不存)
|
||||
check('……还没有对象索引时说清、给「刷新对象索引」', (await dlg.innerText()).includes('还没有对象索引'))
|
||||
await dlg.locator('[data-scope-refresh]').first().click()
|
||||
await until(() => dlg.locator('[data-scope-hits]').getAttribute('data-scope-hits').then((v) => v === '3000/500'))
|
||||
const dry = sent.filter((x) => x.key === 'POST /datasources/check-manage-big/introspect').at(-1)
|
||||
check('……刷新对象索引发的是 dry_run(不反射、不改表结构)', !!dry?.url.includes('dry_run=true'), dry?.url ?? '')
|
||||
const hitsText = await dlg.locator('[data-scope-hits]').innerText()
|
||||
check('……刷新后数出命中:超过上限的写明,会被拒绝', hitsText.includes('超过上限') && hitsText.includes('3,000'), hitsText.replace(/\s+/g, ' '))
|
||||
check('……说明一律在范围内的表(有目录、被已发布模板用到)', (await dlg.locator('[data-scope-pinned]').innerText()).includes('规则排不掉'))
|
||||
check('……超上限时「保存并探查」点不了', await page.locator('[data-scope-save-run]').isDisabled())
|
||||
|
||||
// 边改边数:改成按规则 + 包含 ord_*,停手后问服务端
|
||||
await dlg.locator('[data-scope-mode="rules"]').check()
|
||||
await dlg.locator('[data-scope-input="include"]').fill('ord_*\nord_*')
|
||||
await until(() => dlg.locator('[data-scope-hits]').getAttribute('data-scope-hits').then((v) => v === '400/500'))
|
||||
const pv = sent.filter((x) => x.key === 'POST /datasources/check-manage-big/introspect-scope/preview').at(-1)
|
||||
check('改规则时边改边问命中数(服务端算):按规则、包含去重后的 ord_*', pv?.body?.scope?.mode === 'rules'
|
||||
&& JSON.stringify(pv?.body?.scope?.include) === '["ord_*"]', JSON.stringify(pv?.body ?? {}))
|
||||
check('……命中回到上限以内', (await dlg.locator('[data-scope-hits]').innerText()).includes('命中 400 张,上限 500 张'))
|
||||
await shot(page, 'introspect-scope')
|
||||
await page.locator('[data-scope-save-run]').click()
|
||||
await until(() => card.locator('[data-introspect-job]').count().then((n) => n === 1))
|
||||
const put = sent.filter((x) => x.key === 'PUT /datasources/check-manage-big/introspect-scope').at(-1)
|
||||
check('「保存并探查」:先保存规则', put?.body?.scope?.mode === 'rules' && put?.body?.scope?.max_tables === 500, JSON.stringify(put?.body ?? {}))
|
||||
check('……再开始探查:卡片上有进度条', await card.locator('[data-introspect-job] [role="progressbar"]').count() === 1)
|
||||
await until(async () => ((await card.locator('[data-introspect-job]').getAttribute('data-introspect-progress')) ?? '').startsWith('100/'), 6000)
|
||||
const progress = await card.locator('[data-introspect-job]').getAttribute('data-introspect-progress')
|
||||
check('……每秒取一次进度,往前走', /^(1|2|3)\d\d\/400$/.test(progress ?? ''), progress ?? '')
|
||||
check('……写正在读哪张', (await card.locator('[data-introspect-job]').innerText()).includes('正在读取'))
|
||||
check('……进行中「探查结构」点不了,旁边有「停止」', await card.locator('[data-introspect-start]').isDisabled()
|
||||
&& await card.locator('[data-introspect-stop]').count() === 1)
|
||||
await shot(page, 'introspect-running')
|
||||
|
||||
// 停止:读完的照常更新,卡片常驻一行结果
|
||||
await card.locator('[data-introspect-stop]').click()
|
||||
await until(() => card.locator('[data-introspect-result="cancelled"]').count().then((n) => n === 1))
|
||||
const result = await card.locator('[data-introspect-result]').innerText().catch(() => '')
|
||||
check('停止后卡片上写明:已停止、读完了几张、其余保留上一次的结构', result.includes('上次探查已停止') && result.includes('其余保留上一次的结构'), result)
|
||||
check('……发了停止请求', sent.some((x) => x.key.endsWith('/cancel')))
|
||||
check('……进度条不见了,「停止」也不在了', await card.locator('[data-introspect-job]').count() === 0
|
||||
&& await card.locator('[data-introspect-stop]').count() === 0)
|
||||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据 · 遮罩列(证据面板的 mask_columns)', async () => {
|
||||
// 假库:两张表,phone 已经遮上了。挑列从已探查的结构里读;写请求全部在浏览器里接住
|
||||
const base = {
|
||||
|
||||
Reference in New Issue
Block a user