@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1
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 +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nl2SqlService methods: alerts.
|
|
3
|
+
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
+
*/
|
|
5
|
+
import { evaluateAlert } from '../analysis.js'
|
|
6
|
+
import { admitSql } from '../sql-guard.js'
|
|
7
|
+
import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
|
|
8
|
+
|
|
9
|
+
export const methods = {
|
|
10
|
+
// ---------------------------------------------------------------- alerts
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Alert: a metric (from the library) or a SQL query returning a time series
|
|
14
|
+
* (first column time, value column), evaluated on a schedule; triggers push
|
|
15
|
+
* to a channel.
|
|
16
|
+
*/
|
|
17
|
+
listAlerts() {
|
|
18
|
+
return (this.state.alerts ?? []).map(a => ({ ...a, datasourceName: this.findDatasource(a.datasourceId)?.name ?? a.datasourceId, channelName: this.state.channels.find(c => c.id === a.channelId)?.name ?? null }))
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/** Validate an alert definition (shared by save and preview). */
|
|
22
|
+
async normalizeAlert(input, forceId) {
|
|
23
|
+
const existing = input.id ? (this.state.alerts ?? []).find(a => a.id === input.id) : undefined
|
|
24
|
+
if (input.id && !existing) throw new UserError('nl2sql/invalid', '告警不存在')
|
|
25
|
+
const ds = this.requireDatasource(input.datasourceId ?? existing?.datasourceId)
|
|
26
|
+
const a = existing ? { ...existing } : { id: forceId ?? newId('al'), createdAt: Date.now(), state: 'ok' }
|
|
27
|
+
a.name = requireText(input.name ?? a.name, 'name', 60)
|
|
28
|
+
a.datasourceId = ds.id
|
|
29
|
+
a.source = oneOf(input.source ?? a.source ?? 'metric', ['metric', 'sql'], 'source')
|
|
30
|
+
if (a.source === 'metric') {
|
|
31
|
+
const m = this.findMetric(ds, input.metric ?? a.metric)
|
|
32
|
+
if (!m) throw new UserError('nl2sql/unknown-metric', '指标库中没有这个指标')
|
|
33
|
+
a.metric = m.name
|
|
34
|
+
a.grain = oneOf(input.grain ?? a.grain ?? 'day', ['hour', 'day', 'week', 'month'], 'grain')
|
|
35
|
+
a.filters = Array.isArray(input.filters) ? input.filters : (a.filters ?? [])
|
|
36
|
+
delete a.sql
|
|
37
|
+
} else {
|
|
38
|
+
a.sql = requireText(input.sql ?? a.sql, 'sql', 20000)
|
|
39
|
+
admitSql(a.sql, { dialect: ds.type ?? 'mysql' })
|
|
40
|
+
delete a.metric
|
|
41
|
+
}
|
|
42
|
+
const cond = input.condition ?? a.condition ?? { type: 'threshold', op: '>', value: 0 }
|
|
43
|
+
a.condition = { ...cond, type: oneOf(cond.type, ['threshold', 'change', 'anomaly', 'missing'], 'condition.type') }
|
|
44
|
+
a.every = oneOf(input.every ?? a.every ?? 'hour', ['15m', 'hour', 'day'], 'every')
|
|
45
|
+
a.at = /^\d{2}:\d{2}$/.test(String(input.at ?? a.at ?? '')) ? (input.at ?? a.at) : '09:00'
|
|
46
|
+
a.channelId = input.channelId !== undefined ? (this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : (a.channelId ?? null)
|
|
47
|
+
a.mention = input.mention !== undefined ? Boolean(input.mention) : Boolean(a.mention)
|
|
48
|
+
a.cooldownMin = clampInt(input.cooldownMin ?? a.cooldownMin ?? 60, 0, 10080, 'cooldownMin')
|
|
49
|
+
a.enabled = input.enabled !== undefined ? Boolean(input.enabled) : (a.enabled ?? true)
|
|
50
|
+
a.updatedAt = Date.now()
|
|
51
|
+
return a
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async saveAlert(input) {
|
|
55
|
+
const a = await this.normalizeAlert(input)
|
|
56
|
+
this.state.alerts = upsert(this.state.alerts ?? [], a)
|
|
57
|
+
await this.save()
|
|
58
|
+
this.scheduleAlerts()
|
|
59
|
+
return a
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async deleteAlert(id) {
|
|
63
|
+
this.state.alerts = (this.state.alerts ?? []).filter(a => a.id !== id)
|
|
64
|
+
await this.save()
|
|
65
|
+
return { deleted: id }
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Evaluate one alert now; pushes when it triggers (respecting cooldown) unless dryRun. */
|
|
69
|
+
async checkAlert(id, opts = {}) {
|
|
70
|
+
if (opts.dryRun) return this.checkAlertInner(id, opts)
|
|
71
|
+
return this.withJobLock(`alert:${id}`, () => this.checkAlertInner(id, opts))
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
|
|
75
|
+
async previewAlert(input) {
|
|
76
|
+
const tmpId = `preview-${newId('al')}`
|
|
77
|
+
const saved = this.state.alerts
|
|
78
|
+
try {
|
|
79
|
+
const draft = await this.normalizeAlert({ ...input, id: undefined }, tmpId)
|
|
80
|
+
this.state.alerts = [...(saved ?? []), draft]
|
|
81
|
+
return await this.checkAlertInner(tmpId, { dryRun: true })
|
|
82
|
+
} finally {
|
|
83
|
+
this.state.alerts = saved
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async checkAlertInner(id, { dryRun = false, force = false } = {}) {
|
|
88
|
+
const a = (this.state.alerts ?? []).find(x => x.id === id)
|
|
89
|
+
if (!a) throw new UserError('nl2sql/invalid', '告警不存在')
|
|
90
|
+
const ds = this.requireDatasource(a.datasourceId)
|
|
91
|
+
let result
|
|
92
|
+
if (a.source === 'metric') {
|
|
93
|
+
const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
|
|
94
|
+
result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
|
|
95
|
+
} else {
|
|
96
|
+
result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
|
|
97
|
+
}
|
|
98
|
+
// Series: first column = time/category, last numeric column = value.
|
|
99
|
+
const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
|
|
100
|
+
const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
|
|
101
|
+
if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
|
|
102
|
+
const ev = evaluateAlert(a.condition, series, { now: Date.now() })
|
|
103
|
+
a.lastCheckedAt = Date.now()
|
|
104
|
+
a.lastValue = ev.value ?? null
|
|
105
|
+
a.lastResultId = result.resultId
|
|
106
|
+
const cooled = !a.lastFiredAt || Date.now() - a.lastFiredAt >= a.cooldownMin * 60000
|
|
107
|
+
let pushed = false
|
|
108
|
+
if (ev.triggered) {
|
|
109
|
+
a.state = 'firing'
|
|
110
|
+
if (!dryRun && (cooled || force)) {
|
|
111
|
+
a.lastFiredAt = Date.now()
|
|
112
|
+
const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
|
|
113
|
+
this.records.put('alertEvent', event)
|
|
114
|
+
if (a.channelId) {
|
|
115
|
+
try {
|
|
116
|
+
await this.sendToChannel({ channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' })
|
|
117
|
+
pushed = true
|
|
118
|
+
event.pushed = true
|
|
119
|
+
} catch (error) {
|
|
120
|
+
event.pushError = error.message
|
|
121
|
+
}
|
|
122
|
+
this.records.put('alertEvent', event)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else if (a.state === 'firing') {
|
|
126
|
+
a.state = 'ok'
|
|
127
|
+
if (!dryRun && a.channelId && a.notifyRecovery !== false) {
|
|
128
|
+
try { await this.sendToChannel({ channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }) } catch {}
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
a.state = 'ok'
|
|
132
|
+
}
|
|
133
|
+
await this.save()
|
|
134
|
+
return { ...ev, pushed, resultId: result.resultId, state: a.state }
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
listAlertEvents(alertId) {
|
|
138
|
+
return this.records.list('alertEvent', { ref: alertId, limit: 200 })
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* In-process scheduler (alerts + daily schema check). The Harness schedule
|
|
143
|
+
* service only delivers prompts to agent sessions, so deterministic checks run
|
|
144
|
+
* here. Reliability:
|
|
145
|
+
* - a first tick 20 s after start catches up on anything missed while
|
|
146
|
+
* Harness was closed (each job keeps its own last-run time, so a missed
|
|
147
|
+
* daily slot runs once, not once per missed day);
|
|
148
|
+
* - per-job in-flight locks (manual "check now" and the tick never overlap);
|
|
149
|
+
* - jobs run concurrently with a timeout, so one slow query does not block others;
|
|
150
|
+
* - every tick is recorded and shown on the page.
|
|
151
|
+
*/
|
|
152
|
+
scheduleAlerts() {
|
|
153
|
+
if (this.alertTimer || this.disposed) return
|
|
154
|
+
const tick = () => { this.tickAlerts().catch(error => { this.state.scheduler = { ...(this.state.scheduler ?? {}), lastError: error.message } }) }
|
|
155
|
+
this.alertTimer = setInterval(tick, 60000)
|
|
156
|
+
this.alertTimer.unref?.()
|
|
157
|
+
this.alertKick = setTimeout(tick, this.config.schedulerDelayMs ?? 20000)
|
|
158
|
+
this.alertKick.unref?.()
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Next due time (ms) of an alert after its last check. */
|
|
162
|
+
alertDueAt(a, now = Date.now()) {
|
|
163
|
+
const last = a.lastCheckedAt ?? 0
|
|
164
|
+
if (a.every === '15m') return last + 15 * 60000
|
|
165
|
+
if (a.every === 'hour') return last + 60 * 60000
|
|
166
|
+
// daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
|
|
167
|
+
const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
|
|
168
|
+
const slot = new Date(now)
|
|
169
|
+
slot.setHours(hh, mm, 0, 0)
|
|
170
|
+
if (slot.getTime() > now) slot.setDate(slot.getDate() - 1)
|
|
171
|
+
return last < slot.getTime() ? slot.getTime() : (() => { const n = new Date(slot); n.setDate(n.getDate() + 1); return n.getTime() })()
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async withJobLock(key, fn) {
|
|
175
|
+
this.jobLocks ??= new Set()
|
|
176
|
+
if (this.jobLocks.has(key)) throw new UserError('nl2sql/busy', '该任务正在执行,请稍后再试')
|
|
177
|
+
this.jobLocks.add(key)
|
|
178
|
+
try { return await fn() } finally { this.jobLocks.delete(key) }
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async tickAlerts(now = new Date()) {
|
|
182
|
+
if (this.alertBusy || this.disposed) return
|
|
183
|
+
this.alertBusy = true
|
|
184
|
+
const started = Date.now()
|
|
185
|
+
const ran = []
|
|
186
|
+
try {
|
|
187
|
+
const due = (this.state.alerts ?? []).filter(a => a.enabled && this.alertDueAt(a, now.getTime()) <= now.getTime())
|
|
188
|
+
const timeoutMs = this.config.alertTimeoutMs ?? 120000
|
|
189
|
+
await Promise.all(due.map(async (a) => {
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([
|
|
192
|
+
this.checkAlert(a.id),
|
|
193
|
+
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
|
|
194
|
+
])
|
|
195
|
+
a.lastError = undefined
|
|
196
|
+
ran.push({ id: a.id, ok: true })
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code === 'nl2sql/busy') return
|
|
199
|
+
a.lastError = error.message
|
|
200
|
+
a.lastCheckedAt = Date.now()
|
|
201
|
+
ran.push({ id: a.id, ok: false, error: error.message })
|
|
202
|
+
}
|
|
203
|
+
}))
|
|
204
|
+
// Daily schema watch (catches up once after downtime).
|
|
205
|
+
const at = this.state.settings.schemaCheckDaily
|
|
206
|
+
if (at) {
|
|
207
|
+
const [hh, mm] = at.split(':').map(Number)
|
|
208
|
+
const slot = new Date(now)
|
|
209
|
+
slot.setHours(hh, mm, 0, 0)
|
|
210
|
+
if (slot > now) slot.setDate(slot.getDate() - 1)
|
|
211
|
+
if ((this.state.lastSchemaCheck ?? 0) < slot.getTime()) {
|
|
212
|
+
this.state.lastSchemaCheck = Date.now()
|
|
213
|
+
try { await this.withJobLock('schema-check', () => this.checkAllSchemas()); ran.push({ id: 'schema', ok: true }) } catch (error) { ran.push({ id: 'schema', ok: false, error: error.message }) }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
this.state.scheduler = { lastTickAt: started, lastTickMs: Date.now() - started, lastRan: ran.slice(0, 50), lastError: undefined }
|
|
217
|
+
await this.save()
|
|
218
|
+
} finally {
|
|
219
|
+
this.alertBusy = false
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
schedulerStatus() {
|
|
224
|
+
const now = Date.now()
|
|
225
|
+
return {
|
|
226
|
+
...(this.state.scheduler ?? {}),
|
|
227
|
+
running: Boolean(this.alertTimer),
|
|
228
|
+
nextAlerts: (this.state.alerts ?? []).filter(a => a.enabled).map(a => ({ id: a.id, name: a.name, dueAt: this.alertDueAt(a, now) })).sort((x, y) => x.dueAt - y.dueAt).slice(0, 20),
|
|
229
|
+
schemaCheckDaily: this.state.settings.schemaCheckDaily || null,
|
|
230
|
+
lastSchemaCheck: this.state.lastSchemaCheck ?? null,
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}
|