@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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
package/service/admin.js
ADDED
|
@@ -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
|
+
}
|