@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Nl2SqlService methods: storage statistics, log retention, configuration
3
+ * export / import, about info, Ask-page suggestions and onboarding status.
4
+ * Mixed into the service prototype by index.js; `this` is the service.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { readdir, stat } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+ import { DatabaseSync } from 'node:sqlite'
10
+ import { DEFAULT_SETTINGS, RECORD_LIMITS, UserError, newId, pick } from './shared.js'
11
+ import { widgetWidth } from './dashboards.js'
12
+
13
+ const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
14
+
15
+ const CONFIG_FORMAT = 'dsh-nl2sql-config'
16
+ const SECTIONS = ['settings', 'datasources', 'knowledge', 'dashboards', 'alerts', 'reports', 'channels', 'users']
17
+ /** Datasource fields carried by a config export (never secrets). */
18
+ const DS_FIELDS = ['id', 'name', 'type', 'host', 'port', 'database', 'user', 'charset', 'timezone', 'ssl', 'schemas', 'description', 'privacy', 'policy']
19
+ const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
20
+ const REPORT_FIELDS = ['id', 'title', 'scope', 'question', 'instructions', 'period', 'time', 'weekdays', 'timeZone', 'target']
21
+ const CHANNEL_FIELDS = ['id', 'name', 'type', 'domain', 'appId', 'receiveIdType', 'receiveId', 'description', 'isDefault', 'chartMode']
22
+
23
+ async function fileBytes(file) {
24
+ try { return (await stat(file)).size } catch { return 0 }
25
+ }
26
+
27
+ /** "区块链交易表 - 记录具体链…" → "区块链交易" (same cleanup the Ask page used). */
28
+ export function tableLabel(t) {
29
+ return t.comment ? t.comment.split(/[\s\-—–::,,((]/)[0].replace(/表$/, '') || t.name : t.name
30
+ }
31
+
32
+ /**
33
+ * Schema-based example questions. Fact tables (largest first, then other
34
+ * tables) each contribute several templates: daily trend, distribution by an
35
+ * enum column, top-N by a measure, totals. Returns a long, deterministic pool;
36
+ * the Ask page shows a page of it at a time ("换一批").
37
+ */
38
+ export function schemaExamples(schema, locale = 'zh') {
39
+ const en = locale === 'en'
40
+ if (!schema) return en ? ['What are the core business tables and what is each one for?', 'Which 10 tables hold the most rows?'] : ['这个库里有哪些核心业务表?分别是做什么的?', '数据量最大的 10 张表是哪些?']
41
+ const byRows = (a, b) => (b.rows ?? 0) - (a.rows ?? 0)
42
+ const tables = [...schema.tables.filter(tb => tb.role === 'fact').sort(byRows), ...schema.tables.filter(tb => tb.role !== 'fact' && tb.role !== 'log' && tb.role !== 'config').sort(byRows)].filter(tb => !tb.blocked)
43
+ const colLabel = (c) => (en ? c.name : (c.comment || c.name).split(/[\s::,,((]/)[0])
44
+ // Round-robin: first template of every table, then the second, … so a page mixes tables.
45
+ const perTable = tables.slice(0, 12).map(f => {
46
+ const label = en ? f.name.split('.').pop() : tableLabel(f)
47
+ const qs = []
48
+ const time = f.columns.find(c => c.roles.includes('time') && !/update|modif/i.test(c.name)) ?? f.columns.find(c => c.roles.includes('time'))
49
+ const enumCol = f.columns.find(c => c.roles.includes('enum') && c.values?.length && !c.roles.includes('sensitive'))
50
+ const enum2 = f.columns.filter(c => c.roles.includes('enum') && c.values?.length && !c.roles.includes('sensitive'))[1]
51
+ const measure = f.columns.find(c => c.roles.includes('measure') && !c.roles.includes('flag') && !c.roles.includes('id') && !/_no$|uid$|_id$/i.test(c.name))
52
+ if (time) qs.push(en ? `${label} trend per day over the last 30 days` : `最近 30 天每天的${label}数量趋势`)
53
+ if (enumCol) qs.push(en ? `${label} by ${enumCol.name}` : `${label}按${colLabel(enumCol)}的分布`)
54
+ if (measure && enumCol) qs.push(en ? `Total ${measure.name} of ${label} by ${enumCol.name}` : `各${colLabel(enumCol)}的${label}${colLabel(measure)}合计`)
55
+ if (time && enumCol) qs.push(en ? `How did ${label} by ${enumCol.name} change week over week?` : `${label}按${colLabel(enumCol)}的周环比变化`)
56
+ if (measure) qs.push(en ? `Top 10 ${label} by ${measure.name}` : `${colLabel(measure)}最大的 10 条${label}`)
57
+ if (enum2) qs.push(en ? `${label} by ${enumCol.name} and ${enum2.name}` : `${label}按${colLabel(enumCol)}和${colLabel(enum2)}的交叉分布`)
58
+ if (time) qs.push(en ? `How many ${label} were added yesterday compared with the day before?` : `昨天新增了多少${label}?和前一天相比如何?`)
59
+ return qs
60
+ })
61
+ const out = []
62
+ for (let i = 0; perTable.some(q => q[i]); i++) for (const q of perTable) if (q[i]) out.push(q[i])
63
+ out.push(en ? 'What are the core business tables and how are they related?' : '这个库里有哪些核心业务表?它们之间是什么关系?')
64
+ out.push(en ? 'Which 10 tables hold the most rows?' : '数据量最大的 10 张表是哪些?')
65
+ return out
66
+ }
67
+
68
+ /** Example questions from the metric library (two templates per metric). */
69
+ export function metricExamples(metrics, locale = 'zh') {
70
+ const en = locale === 'en'
71
+ const first = []
72
+ const second = []
73
+ for (const m of metrics) {
74
+ if (m.timeColumn) first.push(en ? `Daily ${m.name} over the last 30 days` : `最近 30 天每天的${m.name}`)
75
+ else first.push(en ? `What is the ${m.name}?` : `${m.name}是多少`)
76
+ if (m.dimensions?.length) second.push(en ? `${m.name} by ${m.dimensions[0]}` : `${m.name}按${m.dimensions[0]}分布`)
77
+ else if (m.timeColumn) second.push(en ? `${m.name} this month vs last month` : `本月和上月的${m.name}对比`)
78
+ }
79
+ return [...first, ...second]
80
+ }
81
+
82
+ const ASK_PAGE = 6
83
+
84
+ export const methods = {
85
+ // ------------------------------------------------------------- storage
86
+
87
+ async storageStats() {
88
+ const db = join(this.dir, 'results.sqlite')
89
+ const bytes = (await fileBytes(db)) + (await fileBytes(`${db}-wal`)) + (await fileBytes(`${db}-shm`))
90
+ const s = this.cacheStats()
91
+ let uploads = 0
92
+ try { for (const n of await readdir(this.uploadDir())) uploads += await fileBytes(join(this.uploadDir(), n)) } catch {}
93
+ return {
94
+ resultsDb: { bytes, results: s.results, cacheEntries: s.cacheEntries, resultBytes: s.bytes },
95
+ records: this.records.counts(),
96
+ retention: this.records.limits(),
97
+ stateBytes: await fileBytes(this.file),
98
+ uploadsBytes: uploads,
99
+ dataDir: this.dir,
100
+ }
101
+ },
102
+
103
+ async purgeRecords({ kind } = {}) {
104
+ if (!Object.hasOwn(RECORD_LIMITS, kind)) throw new UserError('nl2sql/invalid', `未知的日志类型:${kind}(可选:${Object.keys(RECORD_LIMITS).join(' / ')})`)
105
+ const n = this.records.count(kind)
106
+ this.records.clear(kind)
107
+ this.auditAdmin('records.purge', `${kind}(${n} 条)`)
108
+ await this.save()
109
+ return { kind, purged: n, records: this.records.counts() }
110
+ },
111
+
112
+ aboutInfo() {
113
+ let sqliteVersion = null
114
+ try {
115
+ const db = new DatabaseSync(':memory:')
116
+ sqliteVersion = db.prepare('SELECT sqlite_version() AS v').get().v
117
+ db.close()
118
+ } catch {}
119
+ return {
120
+ name: PKG.name ?? 'nl2sql',
121
+ version: PKG.version ?? null,
122
+ dataDir: this.dir,
123
+ nodeVersion: process.versions.node,
124
+ electronVersion: process.versions.electron ?? null,
125
+ sqliteVersion,
126
+ testsCommand: 'npm test',
127
+ }
128
+ },
129
+
130
+ // ------------------------------------------------------- Ask & onboarding
131
+
132
+ /**
133
+ * Ask-page suggestions. `examples` is one page of a deterministic pool that
134
+ * mixes the metric library, liked (👍) questions, verified examples and
135
+ * schema templates; `offset` pages through it ("换一批", wrapping around).
136
+ * `recent` lists the latest distinct agent questions (paged by `recentLimit`).
137
+ */
138
+ async askSuggestions({ datasourceId, locale = 'zh', offset = 0, pageSize = ASK_PAGE, recentLimit = 8 } = {}) {
139
+ const lang = locale === 'en' ? 'en' : 'zh'
140
+ const ds = this.requireDatasource(datasourceId ?? this.state.current)
141
+ const k = this.knowledgeFor(ds.id)
142
+ const pool = []
143
+ const add = (q) => { const s = String(q ?? '').trim(); if (s && !pool.includes(s)) pool.push(s) }
144
+ const metricQs = metricExamples(k.metrics, lang)
145
+ const liked = this.records.list('feedback', { ref: ds.id, limit: 200, filter: f => f.rating === 'up' && Boolean(f.question?.trim()) }).map(f => f.question.trim())
146
+ const verified = [...k.examples].reverse().map(e => e.question)
147
+ let schema
148
+ if (ds.schemaAnalyzedAt || this.schemas.has(ds.id)) { try { schema = await this.visibleSchema(ds) } catch {} }
149
+ const schemaQs = schemaExamples(schema, lang)
150
+ // Interleave sources so every page is a mix: metric, liked, verified, schema, schema …
151
+ const sources = [metricQs, liked, lang === 'zh' ? verified : [], schemaQs]
152
+ const weights = [1, 1, 1, 2]
153
+ for (let i = 0; sources.some(src => src.length); i++) {
154
+ sources.forEach((src, j) => { for (let n = 0; n < weights[j] && src.length; n++) add(src.shift()) })
155
+ }
156
+ const size = Math.max(1, Math.min(12, Math.floor(Number(pageSize) || ASK_PAGE)))
157
+ const total = pool.length
158
+ const start = total ? ((Math.floor(Number(offset) || 0) % total) + total) % total : 0
159
+ const examples = []
160
+ for (let i = 0; i < Math.min(size, total); i++) examples.push(pool[(start + i) % total])
161
+ const recent = []
162
+ const seen = new Set()
163
+ const cap = Math.max(1, Math.min(50, Math.floor(Number(recentLimit) || 8)))
164
+ this.records.list('history', { ref: ds.id, limit: cap + 1, filter: h => {
165
+ if (h.source !== 'agent' || !h.title) return false
166
+ if (seen.has(h.title)) return false
167
+ seen.add(h.title)
168
+ return true
169
+ } }).forEach(h => recent.push({ id: h.id, at: h.at, question: h.title, sessionId: h.sessionId ?? null, resultId: h.resultId ?? null, sql: h.sql, status: h.status ?? 'ok' }))
170
+ return {
171
+ datasourceId: ds.id, locale: lang, tableCount: schema ? schema.tables.length : null,
172
+ examples, total, offset: start, nextOffset: total ? (start + size) % total : 0, pageSize: size,
173
+ recent: recent.slice(0, cap), hasMoreRecent: recent.length > cap,
174
+ }
175
+ },
176
+
177
+ onboardingStatus() {
178
+ const k = Object.values(this.state.knowledge ?? {})
179
+ return {
180
+ hasDatasource: this.state.datasources.length > 0,
181
+ analyzed: this.state.datasources.some(d => d.schemaAnalyzedAt || this.schemas.has(d.id)),
182
+ hasKnowledge: k.some(x => (x.glossary?.length ?? 0) + (x.metrics?.length ?? 0) + (x.examples?.length ?? 0) > 0),
183
+ askedQuestion: this.records.list('history', { limit: 1, filter: h => h.source === 'agent' }).length > 0,
184
+ hasChannel: this.state.channels.length > 0,
185
+ dismissed: Boolean(this.state.onboardingDismissed),
186
+ }
187
+ },
188
+
189
+ async dismissOnboarding({ dismissed = true } = {}) {
190
+ this.state.onboardingDismissed = Boolean(dismissed)
191
+ await this.save()
192
+ return this.onboardingStatus()
193
+ },
194
+
195
+ // ------------------------------------------------------ config export
196
+
197
+ /** Whole configuration as one JSON document, without secrets or runtime state. */
198
+ exportConfig() {
199
+ const dsName = (id) => this.findDatasource(id)?.name ?? null
200
+ const chName = (id) => this.state.channels.find(c => c.id === id)?.name ?? null
201
+ const settings = structuredClone(this.state.settings)
202
+ if (Array.isArray(settings.larkDefaultDatasourceIds)) settings.larkDefaultDatasources = settings.larkDefaultDatasourceIds.map(dsName).filter(Boolean)
203
+ delete settings.larkDefaultDatasourceIds
204
+ if (settings.schemaNotifyChannel) settings.schemaNotifyChannelName = chName(settings.schemaNotifyChannel)
205
+ delete settings.schemaNotifyChannel
206
+ return {
207
+ format: CONFIG_FORMAT,
208
+ version: 1,
209
+ exportedAt: new Date().toISOString(),
210
+ plugin: { name: PKG.name, version: PKG.version },
211
+ settings,
212
+ datasources: this.state.datasources.map(ds => ({
213
+ ...structuredClone(pick(ds, DS_FIELDS)),
214
+ type: ds.type ?? 'mysql',
215
+ hasPassword: Boolean(ds.hasPassword),
216
+ hasApiKey: Boolean(ds.hasApiKey),
217
+ ssh: ds.ssh?.enabled ? { enabled: true, host: ds.ssh.host, port: ds.ssh.port, user: ds.ssh.user, auth: ds.ssh.auth ?? 'password', hasSecret: Boolean(ds.ssh.hasSecret) } : { enabled: false },
218
+ ...(ds.type === 'sqlite' ? { localFile: true } : {}),
219
+ })),
220
+ knowledge: this.exportKnowledge({}).datasources,
221
+ dashboards: this.state.dashboards.map(d => ({
222
+ id: d.id, name: d.name, description: d.description ?? '', filters: structuredClone(d.filters ?? []),
223
+ widgets: d.widgets.map(w => ({ ...structuredClone(pick(w, ['id', 'title', 'sql', 'chart', 'width'])), datasourceName: dsName(w.datasourceId) })),
224
+ })),
225
+ alerts: (this.state.alerts ?? []).map(a => {
226
+ const out = structuredClone(a)
227
+ for (const k of ALERT_RUNTIME) delete out[k]
228
+ delete out.datasourceId; delete out.channelId
229
+ return { ...out, datasourceName: dsName(a.datasourceId), channelName: chName(a.channelId) }
230
+ }),
231
+ reports: this.state.reports.map(r => ({
232
+ ...structuredClone(pick(r, REPORT_FIELDS)),
233
+ datasourceName: dsName(r.datasourceId),
234
+ channelName: chName(r.channelId),
235
+ dashboardName: this.state.dashboards.find(d => d.id === r.dashboardId)?.name ?? null,
236
+ })),
237
+ channels: this.state.channels.map(c => ({ ...pick(c, CHANNEL_FIELDS), hasSecret: Boolean(c.hasSecret), hasSign: Boolean(c.hasSign) })),
238
+ users: (this.state.users ?? []).map(u => {
239
+ const out = structuredClone(u)
240
+ out.datasources = Array.isArray(u.datasourceIds) ? u.datasourceIds.map(dsName).filter(Boolean) : null
241
+ out.defaultDatasource = dsName(u.defaultDatasourceId)
242
+ out.rowRules = (u.rowRules ?? []).map(r => ({ ...r, datasource: r.datasourceId ? dsName(r.datasourceId) : null, datasourceId: undefined }))
243
+ delete out.datasourceIds; delete out.defaultDatasourceId
244
+ return out
245
+ }),
246
+ }
247
+ },
248
+
249
+ // ------------------------------------------------------ config import
250
+
251
+ /**
252
+ * Import a document produced by exportConfig.
253
+ * mode 'merge' (default): items are matched by name (users by identity)
254
+ * and updated; others are added. Ids that clash get new ids.
255
+ * mode 'replace': dashboards, alerts, reports, channels, users and
256
+ * knowledge sections present in the file replace the current ones;
257
+ * data sources are matched by name and never deleted.
258
+ * References between sections resolve by data source / channel / dashboard
259
+ * name. Secrets are never in the file: imported data sources and channels
260
+ * that had one are flagged needsSecret. dryRun reports counts only.
261
+ */
262
+ async importConfig({ data, mode = 'merge', dryRun = false, sections } = {}) {
263
+ let doc = data
264
+ if (typeof doc === 'string') { try { doc = JSON.parse(doc) } catch { throw new UserError('nl2sql/invalid', '不是有效的 JSON') } }
265
+ if (doc?.format !== CONFIG_FORMAT) throw new UserError('nl2sql/invalid', `不是有效的配置导出文件(format 应为 ${CONFIG_FORMAT})`)
266
+ if (Number(doc.version) !== 1) throw new UserError('nl2sql/invalid', `不支持的配置版本:${doc.version}`)
267
+ if (mode !== 'merge' && mode !== 'replace') throw new UserError('nl2sql/invalid', 'mode 必须是 merge 或 replace')
268
+ for (const s of SECTIONS) if (doc[s] !== undefined && s !== 'settings' && !Array.isArray(doc[s])) throw new UserError('nl2sql/invalid', `${s} 必须是数组`)
269
+ const want = new Set(Array.isArray(sections) && sections.length ? sections.filter(s => SECTIONS.includes(s)) : SECTIONS)
270
+ const apply = !dryRun
271
+ const report = {}
272
+ const sec = (name) => (report[name] ??= { add: 0, update: 0, skipped: 0, warnings: [] })
273
+ const warn = (name, text) => { const r = sec(name); if (r.warnings.length < 50) r.warnings.push(text) }
274
+
275
+ // ---- datasources: name → target id (existing or created)
276
+ const dsByName = new Map(this.state.datasources.map(d => [d.name, d.id]))
277
+ const dsMap = new Map() // source name → target id (or a planned placeholder in dry runs)
278
+ if (want.has('datasources')) {
279
+ const r = sec('datasources')
280
+ r.needsSecret = []
281
+ for (const src of doc.datasources ?? []) {
282
+ const name = String(src?.name ?? '').trim()
283
+ if (!name) { r.skipped++; warn('datasources', '缺少名称的数据源已跳过'); continue }
284
+ const needsSecret = Boolean(src.hasPassword || src.hasApiKey || src.ssh?.hasSecret)
285
+ const existing = this.findDatasource(dsByName.get(name))
286
+ try {
287
+ const input = { ...pick(src, DS_FIELDS.filter(k => k !== 'id')), ssh: src.ssh?.enabled ? { ...src.ssh } : { enabled: false } }
288
+ // Local file sources have no connection fields (normalizeDatasourceInput fills them).
289
+ if (src.type === 'sqlite') { delete input.host; delete input.port; delete input.user; delete input.ssh }
290
+ if (existing) {
291
+ const ds = this.normalizeDatasourceInput(input, { ...existing }, { checkName: false })
292
+ if (apply) {
293
+ const i = this.state.datasources.findIndex(d => d.id === existing.id)
294
+ this.state.datasources[i] = ds
295
+ await this.closeDriver(ds.id)
296
+ this.schemas.delete(ds.id)
297
+ }
298
+ dsMap.set(name, existing.id)
299
+ r.update++
300
+ } else {
301
+ const clash = src.id && this.state.datasources.some(d => d.id === src.id)
302
+ const base = this.normalizeDatasourceInput(input, undefined, { checkName: false })
303
+ if (src.id && !clash && /^ds-[\w-]+$/.test(src.id)) {
304
+ base.id = src.id
305
+ if (base.type === 'sqlite') base.file = join(this.dir, 'files', `${base.id}.sqlite`)
306
+ }
307
+ if (needsSecret) base.needsSecret = true
308
+ base.hasPassword = false; base.hasApiKey = false
309
+ if (base.ssh?.enabled) base.ssh.hasSecret = false
310
+ if (apply) {
311
+ this.state.datasources.push(base)
312
+ if (!this.state.current) this.state.current = base.id
313
+ }
314
+ dsMap.set(name, base.id)
315
+ dsByName.set(name, base.id)
316
+ if (needsSecret) r.needsSecret.push(name)
317
+ if (base.type === 'sqlite') warn('datasources', `「${name}」是本地文件数据源,文件数据不在配置中,需要重新上传`)
318
+ r.add++
319
+ }
320
+ } catch (error) {
321
+ r.skipped++
322
+ warn('datasources', `「${name}」:${error.message}`)
323
+ }
324
+ }
325
+ }
326
+ const resolveDs = (name) => {
327
+ if (!name) return undefined
328
+ return dsMap.get(name) ?? dsByName.get(name)
329
+ }
330
+
331
+ // ---- channels: name → id
332
+ const chMap = new Map(this.state.channels.map(c => [c.name, c.id]))
333
+ if (want.has('channels')) {
334
+ const r = sec('channels')
335
+ r.needsSecret = []
336
+ const next = mode === 'replace' ? [] : [...this.state.channels]
337
+ if (mode === 'replace') chMap.clear()
338
+ for (const src of doc.channels ?? []) {
339
+ const name = String(src?.name ?? '').trim().slice(0, 64)
340
+ if (!name || !['lark_webhook', 'lark_app'].includes(src.type)) { r.skipped++; warn('channels', `渠道「${name || '?'}」无效,已跳过`); continue }
341
+ const current = mode === 'replace' ? this.state.channels.find(c => c.name === name) : next.find(c => c.name === name)
342
+ const fields = pick(src, CHANNEL_FIELDS.filter(k => k !== 'id' && k !== 'isDefault'))
343
+ if (current) {
344
+ const c = { ...current, ...fields, name, updatedAt: Date.now() }
345
+ const i = next.findIndex(x => x.id === current.id)
346
+ if (i >= 0) next[i] = c; else next.push(c)
347
+ chMap.set(name, c.id)
348
+ r.update++
349
+ } else {
350
+ const id = src.id && /^ch-[\w-]+$/.test(src.id) && !next.some(c => c.id === src.id) && !this.state.channels.some(c => c.id === src.id) ? src.id : newId('ch')
351
+ const c = { id, ...fields, name, hasSecret: false, hasSign: false, needsSecret: true, createdAt: Date.now(), updatedAt: Date.now(), isDefault: false }
352
+ next.push(c)
353
+ chMap.set(name, id)
354
+ r.add++
355
+ r.needsSecret.push(name)
356
+ }
357
+ }
358
+ {
359
+ const wantDefault = (doc.channels ?? []).find(c => c?.isDefault)?.name
360
+ if (wantDefault && next.some(c => c.name === wantDefault) && !next.some(c => c.isDefault)) for (const c of next) c.isDefault = c.name === wantDefault
361
+ if (next.length && !next.some(c => c.isDefault)) next[0].isDefault = true
362
+ }
363
+ if (apply) this.state.channels = next
364
+ }
365
+ const resolveCh = (name) => (name ? chMap.get(name) ?? null : null)
366
+
367
+ // ---- knowledge
368
+ if (want.has('knowledge')) {
369
+ const r = sec('knowledge')
370
+ r.items = 0
371
+ for (const block of doc.knowledge ?? []) {
372
+ const target = resolveDs(block?.name)
373
+ if (!target) { r.skipped++; warn('knowledge', `找不到数据源「${block?.name}」,其业务知识已跳过`); continue }
374
+ const items = (block.glossary?.length ?? 0) + (block.metrics?.length ?? 0) + Object.keys(block.notes ?? {}).length + (block.examples?.length ?? 0)
375
+ if (!apply || !this.findDatasource(target)) {
376
+ r.items += items
377
+ if (this.findDatasource(target) && this.knowledgeFor(target).glossary.length + this.knowledgeFor(target).metrics.length + this.knowledgeFor(target).examples.length) r.update++; else r.add++
378
+ continue
379
+ }
380
+ const res = await this.importKnowledge({ data: { format: 'dsh-nl2sql-knowledge', version: 1, datasources: [block] }, targetDatasourceId: target, mode, dryRun: false })
381
+ const c = res.report[0] ?? {}
382
+ r.items += (c.glossary ?? 0) + (c.metrics ?? 0) + (c.notes ?? 0) + (c.examples ?? 0)
383
+ if (c.invalid) warn('knowledge', `「${block.name}」有 ${c.invalid} 条无效条目被跳过`)
384
+ r.update++
385
+ }
386
+ }
387
+
388
+ // ---- dashboards
389
+ const dbMap = new Map(this.state.dashboards.map(d => [d.name, d.id]))
390
+ if (want.has('dashboards')) {
391
+ const r = sec('dashboards')
392
+ r.widgets = 0
393
+ const next = mode === 'replace' ? [] : [...this.state.dashboards]
394
+ for (const src of doc.dashboards ?? []) {
395
+ const name = String(src?.name ?? '').trim().slice(0, 40)
396
+ if (!name) { r.skipped++; continue }
397
+ const widgets = []
398
+ for (const w of src.widgets ?? []) {
399
+ const dsId = resolveDs(w?.datasourceName)
400
+ if (!dsId || typeof w.sql !== 'string' || !w.sql.trim()) { warn('dashboards', `看板「${name}」的组件「${w?.title ?? '?'}」引用的数据源「${w?.datasourceName ?? '?'}」不存在,已跳过`); continue }
401
+ widgets.push({ id: newId('w'), title: String(w.title || 'Untitled').slice(0, 120), sql: w.sql, datasourceId: dsId, chart: w.chart ?? { type: 'table' }, width: widgetWidth(w.width), createdAt: Date.now() })
402
+ }
403
+ r.widgets += widgets.length
404
+ const existing = next.find(d => d.name === name)
405
+ if (existing) {
406
+ const d = { ...existing, description: String(src.description ?? existing.description ?? '').slice(0, 300), widgets, filters: Array.isArray(src.filters) ? src.filters : existing.filters ?? [], updatedAt: Date.now() }
407
+ next[next.indexOf(existing)] = d
408
+ r.update++
409
+ } else {
410
+ const prev = mode === 'replace' ? this.state.dashboards.find(d => d.name === name) : undefined
411
+ const id = prev?.id ?? (src.id && /^db-[\w-]+$/.test(src.id) && !next.some(d => d.id === src.id) && !this.state.dashboards.some(d => d.id === src.id) ? src.id : newId('db'))
412
+ next.push({ id, name, description: String(src.description ?? '').slice(0, 300), widgets, filters: Array.isArray(src.filters) ? src.filters : [], createdAt: Date.now(), updatedAt: Date.now() })
413
+ r.add++
414
+ }
415
+ dbMap.set(name, next.find(d => d.name === name).id)
416
+ }
417
+ if (apply) {
418
+ if (!next.length) next.push({ id: 'db-default', name: '默认看板', description: '', widgets: [], filters: [], createdAt: Date.now() })
419
+ this.state.dashboards = next
420
+ if (!next.some(d => d.id === this.state.currentDashboard)) this.state.currentDashboard = next[0].id
421
+ for (const rp of this.state.reports) if (rp.dashboardId && !next.some(d => d.id === rp.dashboardId)) delete rp.dashboardId
422
+ this.syncLegacyDashboard()
423
+ }
424
+ }
425
+
426
+ // ---- alerts
427
+ if (want.has('alerts')) {
428
+ const r = sec('alerts')
429
+ const next = mode === 'replace' ? [] : [...(this.state.alerts ?? [])]
430
+ for (const src of doc.alerts ?? []) {
431
+ const name = String(src?.name ?? '').trim().slice(0, 60)
432
+ const dsId = resolveDs(src?.datasourceName)
433
+ if (!name || !dsId) { r.skipped++; warn('alerts', `告警「${name || '?'}」引用的数据源「${src?.datasourceName ?? '?'}」不存在,已跳过`); continue }
434
+ const def = structuredClone(src)
435
+ for (const k of [...ALERT_RUNTIME, 'datasourceName', 'channelName', 'id']) delete def[k]
436
+ const existing = next.find(a => a.name === name)
437
+ const a = { ...def, name, datasourceId: dsId, channelId: resolveCh(src.channelName), state: 'ok', updatedAt: Date.now() }
438
+ if (src.channelName && !a.channelId) warn('alerts', `告警「${name}」的推送渠道「${src.channelName}」不存在,已清空`)
439
+ if (existing) { next[next.indexOf(existing)] = { ...a, id: existing.id, createdAt: existing.createdAt }; r.update++ }
440
+ else {
441
+ const id = src.id && /^al-[\w-]+$/.test(src.id) && !next.some(x => x.id === src.id) ? src.id : newId('al')
442
+ next.push({ ...a, id, createdAt: Date.now() })
443
+ r.add++
444
+ }
445
+ }
446
+ if (apply) this.state.alerts = next
447
+ }
448
+
449
+ // ---- reports (schedules are created only when the schedule service exists)
450
+ if (want.has('reports')) {
451
+ const r = sec('reports')
452
+ let paused = 0
453
+ r.paused = false
454
+ const schedule = this.ctx.get('schedule')
455
+ if (apply && mode === 'replace') for (const rp of [...this.state.reports]) await this.deleteReport(rp.id)
456
+ for (const src of doc.reports ?? []) {
457
+ const title = String(src?.title ?? '').trim()
458
+ const dsId = resolveDs(src?.datasourceName)
459
+ if (!title || !dsId) { r.skipped++; warn('reports', `报告「${title || '?'}」引用的数据源「${src?.datasourceName ?? '?'}」不存在,已跳过`); continue }
460
+ const existing = this.state.reports.find(x => x.title === title)
461
+ const input = { ...pick(src, REPORT_FIELDS.filter(k => k !== 'id')), title, datasourceId: dsId, channelId: resolveCh(src.channelName) ?? undefined, dashboardId: src.dashboardName ? dbMap.get(src.dashboardName) : undefined }
462
+ if (existing && mode === 'merge') r.update++; else r.add++
463
+ if (!apply) { if (!schedule) paused++; continue }
464
+ if (schedule && this.findDatasource(dsId)) {
465
+ try {
466
+ await this.saveReport({ ...input, id: existing && mode === 'merge' ? existing.id : undefined })
467
+ continue
468
+ } catch (error) {
469
+ warn('reports', `报告「${title}」创建定时任务失败:${error.message},已按暂停导入`)
470
+ }
471
+ }
472
+ // No schedule service: keep the definition, paused, until the report is saved again.
473
+ const rp = {
474
+ id: existing && mode === 'merge' ? existing.id : newId('rp'),
475
+ title: title.slice(0, 80), datasourceId: dsId,
476
+ scope: input.scope === 'dashboard' ? 'dashboard' : 'question', question: String(input.question ?? '').slice(0, 2000),
477
+ instructions: String(input.instructions ?? '').slice(0, 1000), period: input.period === 'weekly' ? 'weekly' : 'daily',
478
+ time: /^\d{2}:\d{2}(:\d{2})?$/.test(String(input.time)) ? (String(input.time).length === 5 ? `${input.time}:00` : String(input.time)) : '09:00:00',
479
+ weekdays: Array.isArray(input.weekdays) ? input.weekdays.map(Number).filter(n => n >= 1 && n <= 7) : [1],
480
+ timeZone: String(input.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone), target: input.target === 'new' ? 'new' : 'new_once',
481
+ channelId: input.channelId, dashboardId: input.dashboardId, paused: true, createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
482
+ }
483
+ this.state.reports = [...this.state.reports.filter(x => x.id !== rp.id), rp]
484
+ paused++
485
+ }
486
+ r.paused = paused > 0
487
+ if (paused) warn('reports', schedule ? `${paused} 个报告未能创建定时任务,已按暂停导入` : `当前 Harness 没有定时任务服务,${paused} 个报告已按暂停导入;启用定时任务后重新保存即可`)
488
+ }
489
+
490
+ // ---- users
491
+ if (want.has('users')) {
492
+ const r = sec('users')
493
+ const next = mode === 'replace' ? [] : [...(this.state.users ?? [])]
494
+ for (const src of doc.users ?? []) {
495
+ const name = String(src?.name ?? '').trim().slice(0, 60)
496
+ if (!name || !['person', 'chat'].includes(src.kind ?? 'person') || !['admin', 'analyst', 'viewer'].includes(src.role ?? 'analyst')) { r.skipped++; continue }
497
+ const ids = src.datasources === null || src.datasources === undefined ? null : (src.datasources ?? []).map(resolveDs).filter(Boolean)
498
+ if (Array.isArray(src.datasources) && ids.length < src.datasources.length) warn('users', `成员「${name}」的部分数据源不存在,已忽略`)
499
+ const u = {
500
+ ...pick(src, ['kind', 'role', 'larkOpenId', 'email', 'larkChatId', 'disabled', 'note']),
501
+ kind: src.kind ?? 'person', role: src.role ?? 'analyst', name,
502
+ datasourceIds: ids, defaultDatasourceId: resolveDs(src.defaultDatasource) ?? ids?.[0] ?? null,
503
+ rowRules: (src.rowRules ?? []).map(x => ({ datasourceId: x.datasource ? resolveDs(x.datasource) ?? null : null, column: x.column, values: x.values ?? [] })).filter(x => x.column && x.values.length),
504
+ updatedAt: Date.now(),
505
+ }
506
+ const existing = next.find(x => (u.larkOpenId && x.larkOpenId === u.larkOpenId) || (u.email && x.email === u.email) || (u.larkChatId && x.larkChatId === u.larkChatId)) ?? next.find(x => x.name === name)
507
+ if (existing) { next[next.indexOf(existing)] = { ...u, id: existing.id, createdAt: existing.createdAt }; r.update++ }
508
+ else {
509
+ const id = src.id && /^u-[\w-]+$/.test(src.id) && !next.some(x => x.id === src.id) ? src.id : newId('u')
510
+ next.push({ ...u, id, createdAt: Date.now() })
511
+ r.add++
512
+ }
513
+ }
514
+ if (apply) this.state.users = next
515
+ }
516
+
517
+ // ---- settings (validated through saveSettings)
518
+ if (want.has('settings') && doc.settings && typeof doc.settings === 'object') {
519
+ const r = sec('settings')
520
+ const input = pick(doc.settings, Object.keys(DEFAULT_SETTINGS).concat(['schemaCheckDaily']))
521
+ if (Array.isArray(doc.settings.larkDefaultDatasources)) input.larkDefaultDatasourceIds = doc.settings.larkDefaultDatasources.map(resolveDs).filter(Boolean)
522
+ if (doc.settings.schemaNotifyChannelName) input.schemaNotifyChannel = resolveCh(doc.settings.schemaNotifyChannelName) ?? ''
523
+ if (apply) {
524
+ const before = structuredClone(this.state.settings)
525
+ try { await this.saveSettings(input) } catch (error) { this.state.settings = before; r.skipped++; warn('settings', error.message) }
526
+ if (!r.skipped) r.update = Object.keys(input).length
527
+ } else {
528
+ r.update = Object.keys(input).length
529
+ }
530
+ }
531
+
532
+ if (apply) {
533
+ this.auditAdmin('config.import', `${mode}: ${Object.entries(report).map(([k, v]) => `${k} +${v.add}/~${v.update}`).join(', ')}`)
534
+ await this.save()
535
+ if (want.has('alerts')) this.scheduleAlerts()
536
+ }
537
+ return { dryRun: Boolean(dryRun), mode, report }
538
+ },
539
+ }