@hedeqiang/dsh-nl2sql 0.12.1 → 0.12.2
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 +22 -1
- package/analysis.js +4 -1
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/filters.js +87 -33
- package/index.js +4 -2
- package/lib/client.js +443 -148
- package/package.json +1 -1
- package/service/alerts.js +40 -20
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/query.js +36 -8
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
package/lib/client.js
CHANGED
|
@@ -3,6 +3,8 @@ window.__ModuleLoader__.load({
|
|
|
3
3
|
factory(require) {
|
|
4
4
|
const React = require('react')
|
|
5
5
|
const ReactDOM = require('react-dom')
|
|
6
|
+
// createRoot for the global feedback layer; older loaders only share react-dom.
|
|
7
|
+
const ReactDOMClient = (() => { try { return require('react-dom/client') } catch { return null } })() ?? ReactDOM
|
|
6
8
|
const h = React.createElement
|
|
7
9
|
const { useState, useEffect, useMemo, useRef, useCallback, useSyncExternalStore } = React
|
|
8
10
|
|
|
@@ -1030,6 +1032,18 @@ window.__ModuleLoader__.load({
|
|
|
1030
1032
|
'tool.running': '正在查询…',
|
|
1031
1033
|
'tool.failed': '查询失败',
|
|
1032
1034
|
'error.generic': '操作失败:{message}',
|
|
1035
|
+
'dash.copyName': '{name} 副本',
|
|
1036
|
+
'set.leaveTitle': '放弃未保存的设置?',
|
|
1037
|
+
'set.leaveMsg': '设置中有未保存的修改,离开后这些修改会丢失。',
|
|
1038
|
+
'set.leaveOk': '放弃并离开',
|
|
1039
|
+
'fb.deleteConfirm': '删除这条反馈?',
|
|
1040
|
+
'hist.source.telegrambot': 'Telegram 机器人',
|
|
1041
|
+
'hist.source.wecombot': '企业微信机器人',
|
|
1042
|
+
'hist.source.dingtalkbot': '钉钉机器人',
|
|
1043
|
+
'hist.source.unknown': '未知来源',
|
|
1044
|
+
'hist.source.genericBot': '{platform} 机器人',
|
|
1045
|
+
'filter.valuesPh': '值,用逗号分隔',
|
|
1046
|
+
'policy.tablesLabel': '可访问的表',
|
|
1033
1047
|
}
|
|
1034
1048
|
const en = {
|
|
1035
1049
|
'mb.pick.people': "Pick someone who talked to a bot",
|
|
@@ -1426,7 +1440,7 @@ window.__ModuleLoader__.load({
|
|
|
1426
1440
|
'im.src.ui': 'Page', 'im.src.agent': 'Agent', 'im.src.dashboard': 'Dashboard', 'im.src.test': 'Test', 'im.resultsN': '{count} results', 'im.imagesN': '{count} images',
|
|
1427
1441
|
'im.share': 'Send to IM', 'im.shareAll': 'Send all to IM', 'im.shareDashboard': 'Send dashboard', 'im.dashboardTitle': 'Dashboard', 'im.sendTitle': "Send to IM channel",
|
|
1428
1442
|
'im.noChannel': "No channels yet; add one under Admin → Channels.", 'im.channel': 'Send to', 'im.msgTitle': 'Title', 'im.text': 'Message (Markdown, optional)', 'im.textPh': 'e.g. GMV +12% day over day, mostly from the east region',
|
|
1429
|
-
'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.',
|
|
1443
|
+
'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.',
|
|
1430
1444
|
'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
|
|
1431
1445
|
'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
|
|
1432
1446
|
'ds.apiKey': 'API key (optional, preferred over user/password)', 'ds.apiKeyHint': 'Base64 encoded id:api_key', 'ds.schemas': 'Schemas (comma separated; empty = all user schemas)', 'ds.https': 'Use HTTPS',
|
|
@@ -1496,6 +1510,12 @@ window.__ModuleLoader__.load({
|
|
|
1496
1510
|
'settings.limits': 'Query limits', 'settings.maxRows': 'Max rows per query', 'settings.timeoutMs': 'Query timeout (ms)', 'settings.save': 'Save settings', 'settings.saved': 'Saved',
|
|
1497
1511
|
'composer.none': 'No data source', 'composer.title': 'Data source queried by this session',
|
|
1498
1512
|
'tool.query': 'Data query', 'tool.chart': 'Chart', 'tool.running': 'Querying…', 'tool.failed': 'Query failed', 'error.generic': 'Failed: {message}',
|
|
1513
|
+
'dash.copyName': '{name} copy',
|
|
1514
|
+
'set.leaveTitle': 'Discard unsaved settings?', 'set.leaveMsg': 'You have unsaved setting changes; they will be lost if you leave.', 'set.leaveOk': 'Discard and leave',
|
|
1515
|
+
'fb.deleteConfirm': 'Delete this feedback?',
|
|
1516
|
+
'hist.source.telegrambot': 'Telegram bot', 'hist.source.wecombot': 'WeCom bot', 'hist.source.dingtalkbot': 'DingTalk bot', 'hist.source.unknown': 'Unknown source', 'hist.source.genericBot': '{platform} bot',
|
|
1517
|
+
'filter.valuesPh': 'Values, comma-separated',
|
|
1518
|
+
'policy.tablesLabel': 'Accessible tables',
|
|
1499
1519
|
}
|
|
1500
1520
|
|
|
1501
1521
|
// --------------------------------------------------------------- RPC
|
|
@@ -2036,7 +2056,8 @@ window.__ModuleLoader__.load({
|
|
|
2036
2056
|
.n2s-ptile[aria-checked=true]{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 6%,transparent);box-shadow:0 0 0 1px var(--n2s-accent)}
|
|
2037
2057
|
.n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
|
|
2038
2058
|
.n2s-ptile-name{font-size:13.5px;font-weight:600}
|
|
2039
|
-
.n2s-kindcards{display:grid;gap:8px;margin-
|
|
2059
|
+
.n2s-kindcards{display:grid;gap:8px;margin:8px 0 0;padding:0;list-style:none}
|
|
2060
|
+
.n2s-kindcard-li{display:grid}
|
|
2040
2061
|
.n2s-kindcard{display:grid;gap:4px;padding:12px 14px;border-radius:var(--n2s-radius);border:.5px solid var(--n2s-line-2);background:var(--dsw-alias-bg-base);color:inherit;font:inherit;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}
|
|
2041
2062
|
.n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
|
|
2042
2063
|
.n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
|
|
@@ -2162,6 +2183,16 @@ window.__ModuleLoader__.load({
|
|
|
2162
2183
|
const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
|
|
2163
2184
|
/** Label + colon in the UI language: 「标签:」 / "Label: ". */
|
|
2164
2185
|
const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
|
|
2186
|
+
/** List separator in the UI language. */
|
|
2187
|
+
const listSep = () => (uiLang === 'zh' ? '、' : ', ')
|
|
2188
|
+
/** Clauses joined in the UI language: 「a;b」 / "a; b". */
|
|
2189
|
+
const joinClauses = (items) => joinList(items, uiLang === 'zh' ? ';' : '; ')
|
|
2190
|
+
/** Parenthetical in the UI language: 「(x)」 / " (x)". */
|
|
2191
|
+
const paren = (s) => (s === undefined || s === null || s === '' ? '' : uiLang === 'zh' ? `(${s})` : ` (${s})`)
|
|
2192
|
+
/** Quoted name in the UI language: 「x」 / "x". */
|
|
2193
|
+
const quoted = (s) => (uiLang === 'zh' ? `「${s}」` : `"${s}"`)
|
|
2194
|
+
/** BCP 47 tag for Intl formatting in the UI language. */
|
|
2195
|
+
const uiLocale = () => (uiLang === 'zh' ? 'zh-CN' : 'en-US')
|
|
2165
2196
|
const setUiLang = (snapshot) => {
|
|
2166
2197
|
const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
|
|
2167
2198
|
uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
|
|
@@ -2176,18 +2207,26 @@ window.__ModuleLoader__.load({
|
|
|
2176
2207
|
}
|
|
2177
2208
|
|
|
2178
2209
|
function formatCompact(v) {
|
|
2210
|
+
if (v === null || v === undefined || v === '') return '—'
|
|
2179
2211
|
const n = Number(v)
|
|
2180
|
-
if (!Number.isFinite(n)) return String(v
|
|
2212
|
+
if (!Number.isFinite(n)) return String(v)
|
|
2181
2213
|
const abs = Math.abs(n)
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2214
|
+
// [divisor, suffix, threshold, digits(abs)] from the largest unit down.
|
|
2215
|
+
const scales = uiLang === 'zh'
|
|
2216
|
+
? [[1e8, '亿', 1e8, a => (a >= 1e10 ? 0 : 2)], [1e4, '万', 1e5, a => (a >= 1e7 ? 0 : 1)]]
|
|
2217
|
+
: [[1e9, 'B', 1e9, a => (a >= 1e11 ? 0 : 2)], [1e6, 'M', 1e6, a => (a >= 1e8 ? 0 : 1)], [1e3, 'K', 1e5, () => 0]]
|
|
2218
|
+
for (let i = 0; i < scales.length; i++) {
|
|
2219
|
+
const [div, suffix, threshold, digits] = scales[i]
|
|
2220
|
+
if (abs < threshold) continue
|
|
2221
|
+
const s = (n / div).toFixed(digits(abs))
|
|
2222
|
+
// Rounding can reach the next unit (999 999 → "1000K"): use the larger unit instead.
|
|
2223
|
+
if (i > 0 && Math.abs(Number(s)) >= scales[i - 1][0] / div) {
|
|
2224
|
+
const [div2, suffix2, , digits2] = scales[i - 1]
|
|
2225
|
+
return `${(n / div2).toFixed(digits2(scales[i - 1][0]))}${suffix2}`
|
|
2226
|
+
}
|
|
2227
|
+
return `${s}${suffix}`
|
|
2187
2228
|
}
|
|
2188
|
-
|
|
2189
|
-
if (abs >= 1e5) return `${(n / 1e4).toFixed(abs >= 1e7 ? 0 : 1)}万`
|
|
2190
|
-
return n.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
2229
|
+
return n.toLocaleString(uiLocale(), { maximumFractionDigits: 2 })
|
|
2191
2230
|
}
|
|
2192
2231
|
|
|
2193
2232
|
function toCsv(result) {
|
|
@@ -2261,23 +2300,72 @@ window.__ModuleLoader__.load({
|
|
|
2261
2300
|
* Async data with request ordering: results of an older request (or of
|
|
2262
2301
|
* previous deps) never overwrite newer ones, and `loading` is true from the
|
|
2263
2302
|
* first render after deps change.
|
|
2303
|
+
*
|
|
2304
|
+
* `value` belongs to the current deps: while new deps load it is undefined,
|
|
2305
|
+
* so e.g. a previous data source's data is never shown or acted on.
|
|
2306
|
+
* `reload()` with unchanged deps keeps `value` until the new one arrives.
|
|
2307
|
+
* `scope` (optional) names the deps that identify *whose* data this is; when
|
|
2308
|
+
* only other deps change (a page offset, a filter) the previous value stays
|
|
2309
|
+
* on screen while the next one loads, as long as the scope is unchanged.
|
|
2264
2310
|
*/
|
|
2265
|
-
function useAsync(fn, deps) {
|
|
2311
|
+
function useAsync(fn, deps, scope) {
|
|
2266
2312
|
const depsKey = JSON.stringify(deps)
|
|
2267
|
-
const
|
|
2313
|
+
const scopeKey = scope === undefined ? depsKey : JSON.stringify(scope)
|
|
2314
|
+
const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null, scope: null })
|
|
2268
2315
|
const seq = useRef(0)
|
|
2269
2316
|
const reload = useCallback(() => {
|
|
2270
2317
|
const id = ++seq.current
|
|
2271
2318
|
setState(s => ({ ...s, loading: true, error: undefined }))
|
|
2272
2319
|
Promise.resolve().then(fn).then(
|
|
2273
|
-
value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey }) },
|
|
2274
|
-
error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey }) },
|
|
2320
|
+
value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey, scope: scopeKey }) },
|
|
2321
|
+
error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey, scope: scopeKey }) },
|
|
2275
2322
|
)
|
|
2276
2323
|
return () => { if (seq.current === id) seq.current++ }
|
|
2277
2324
|
}, deps)
|
|
2278
2325
|
useEffect(reload, [reload])
|
|
2279
2326
|
const stale = state.key !== depsKey
|
|
2280
|
-
|
|
2327
|
+
const foreign = state.scope !== scopeKey
|
|
2328
|
+
return { loading: state.loading || stale, value: foreign ? undefined : state.value, error: stale ? undefined : state.error, stale, reload }
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* Guard against double submits: `run(fn)` ignores calls while one is in
|
|
2333
|
+
* flight (checked synchronously through a ref, so two clicks in the same
|
|
2334
|
+
* tick still run once) and `busy` re-renders the disabled state.
|
|
2335
|
+
*/
|
|
2336
|
+
function useBusy() {
|
|
2337
|
+
const ref = useRef(false)
|
|
2338
|
+
const [busy, setBusy] = useState(false)
|
|
2339
|
+
const alive = useRef(true)
|
|
2340
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
|
2341
|
+
const run = useCallback(async (fn) => {
|
|
2342
|
+
if (ref.current) return undefined
|
|
2343
|
+
ref.current = true
|
|
2344
|
+
setBusy(true)
|
|
2345
|
+
try { return await fn() } finally { ref.current = false; if (alive.current) setBusy(false) }
|
|
2346
|
+
}, [])
|
|
2347
|
+
return [busy, run]
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
2351
|
+
|
|
2352
|
+
/** Keep Tab / Shift+Tab focus inside the element while `active`. */
|
|
2353
|
+
function useFocusTrap(ref, active = true) {
|
|
2354
|
+
useEffect(() => {
|
|
2355
|
+
const el = ref.current
|
|
2356
|
+
if (!active || !el) return
|
|
2357
|
+
const onKey = (e) => {
|
|
2358
|
+
if (e.key !== 'Tab') return
|
|
2359
|
+
const items = [...el.querySelectorAll(FOCUSABLE)].filter(x => !x.closest('[hidden]') && x.getAttribute('aria-hidden') !== 'true')
|
|
2360
|
+
if (!items.length) { e.preventDefault(); return }
|
|
2361
|
+
const first = items[0]
|
|
2362
|
+
const last = items[items.length - 1]
|
|
2363
|
+
const cur = document.activeElement
|
|
2364
|
+
if (e.shiftKey && (cur === first || !el.contains(cur))) { e.preventDefault(); last.focus() } else if (!e.shiftKey && (cur === last || !el.contains(cur))) { e.preventDefault(); first.focus() }
|
|
2365
|
+
}
|
|
2366
|
+
el.addEventListener('keydown', onKey)
|
|
2367
|
+
return () => el.removeEventListener('keydown', onKey)
|
|
2368
|
+
}, [ref, active, ref.current])
|
|
2281
2369
|
}
|
|
2282
2370
|
|
|
2283
2371
|
// ------------------------------------------------------------ feedback
|
|
@@ -2314,6 +2402,7 @@ window.__ModuleLoader__.load({
|
|
|
2314
2402
|
const [value, setValue] = useState('')
|
|
2315
2403
|
const [err, setErr] = useState(null)
|
|
2316
2404
|
const box = useRef(null)
|
|
2405
|
+
useFocusTrap(box, Boolean(d))
|
|
2317
2406
|
useEffect(() => {
|
|
2318
2407
|
if (!d) return
|
|
2319
2408
|
setValue(d.value ?? '')
|
|
@@ -2335,7 +2424,7 @@ window.__ModuleLoader__.load({
|
|
|
2335
2424
|
return h(React.Fragment, null,
|
|
2336
2425
|
h('div', { className: 'n2s-toasts', role: 'status', 'aria-live': 'polite' }, st.toasts.map(x => h('div', { key: x.id, className: `n2s-toast ${x.kind}` }, x.kind === 'ok' ? Icon.check(14) : x.kind === 'err' ? Icon.close(13) : null, h('span', null, x.text)))),
|
|
2337
2426
|
d && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) close(d.kind === 'prompt' ? null : false) } },
|
|
2338
|
-
h('div', { ref: box, className: 'n2s-modal', role: 'alertdialog', 'aria-modal': true, 'aria-labelledby': 'n2s-dlg-title', style: { width: 'min(440px, 100%)' }, onKeyDown: (e) => { if (e.key === 'Escape') close(d.kind === 'prompt' ? null : false)
|
|
2427
|
+
h('div', { ref: box, className: 'n2s-modal', role: 'alertdialog', 'aria-modal': true, 'aria-labelledby': 'n2s-dlg-title', style: { width: 'min(440px, 100%)' }, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(d.kind === 'prompt' ? null : false) } if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') ok() } },
|
|
2339
2428
|
h('div', { id: 'n2s-dlg-title', className: 'n2s-modal-title', style: { marginBottom: 8 } }, d.title),
|
|
2340
2429
|
d.message && h('div', { className: 'n2s-muted', style: { fontSize: 13.5, marginBottom: 12, whiteSpace: 'pre-wrap' } }, d.message),
|
|
2341
2430
|
d.kind === 'prompt' && h('input', { className: 'n2s-input', value, placeholder: d.placeholder, 'aria-label': d.title, onChange: e => { setValue(e.target.value); setErr(null) } }),
|
|
@@ -2345,6 +2434,22 @@ window.__ModuleLoader__.load({
|
|
|
2345
2434
|
h('button', { type: 'button', className: `n2s-btn ${d.danger ? 'danger-solid' : 'primary'}`, onClick: ok }, d.confirmLabel ?? t('fb2.ok'))))), document.body))
|
|
2346
2435
|
}
|
|
2347
2436
|
|
|
2437
|
+
|
|
2438
|
+
/** Mount the FeedbackLayer once into its own root under document.body; returns the disposer. */
|
|
2439
|
+
function mountFeedbackLayer(ctx) {
|
|
2440
|
+
const el = document.createElement('div')
|
|
2441
|
+
el.className = 'n2s-feedback-root'
|
|
2442
|
+
el.dataset.plugin = PKG
|
|
2443
|
+
document.body.appendChild(el)
|
|
2444
|
+
const root = ReactDOMClient.createRoot(el)
|
|
2445
|
+
function GlobalFeedback() {
|
|
2446
|
+
const t = useMemo(() => ctx.locale.bind(NS), [])
|
|
2447
|
+
setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
|
|
2448
|
+
return h(FeedbackLayer, { t })
|
|
2449
|
+
}
|
|
2450
|
+
root.render(h(GlobalFeedback))
|
|
2451
|
+
return () => { const d = feedbackStore.get().dialog; feedbackStore.set({ ...feedbackStore.get(), dialog: null }); d?.resolve(d.kind === 'prompt' ? null : false); setTimeout(() => { root.unmount(); el.remove() }, 0) }
|
|
2452
|
+
}
|
|
2348
2453
|
// ----------------------------------------------------------- charts
|
|
2349
2454
|
|
|
2350
2455
|
let echartsPromise
|
|
@@ -2551,8 +2656,11 @@ window.__ModuleLoader__.load({
|
|
|
2551
2656
|
const [sending, setSending] = useState(false)
|
|
2552
2657
|
const [rating, setRating] = useState(null)
|
|
2553
2658
|
const [drill, setDrill] = useState(null)
|
|
2659
|
+
const ratingBusy = useRef(false)
|
|
2554
2660
|
useEffect(() => { setType(result.chart?.type ?? 'table') }, [result.resultId])
|
|
2555
2661
|
const rate = async (value, comment) => {
|
|
2662
|
+
if (ratingBusy.current) return
|
|
2663
|
+
ratingBusy.current = true
|
|
2556
2664
|
try {
|
|
2557
2665
|
await call('submitFeedback', { resultId: result.resultId, rating: value, comment, question, sql: result.baseSql ?? result.sql, datasourceId: result.datasourceId, title: result.title, sessionId: onFeedback?.sessionId })
|
|
2558
2666
|
setRated(value)
|
|
@@ -2560,11 +2668,15 @@ window.__ModuleLoader__.load({
|
|
|
2560
2668
|
if (value === 'down') onFeedback?.down?.(comment)
|
|
2561
2669
|
} catch (e) {
|
|
2562
2670
|
notifyError(e)
|
|
2671
|
+
} finally {
|
|
2672
|
+
ratingBusy.current = false
|
|
2563
2673
|
}
|
|
2564
2674
|
}
|
|
2565
2675
|
const chart = useMemo(() => (type === result.chart?.type ? result.chart : chartFor(type, result, result.suggestedChart ?? result.chart)) ?? { type: 'table' }, [type, result])
|
|
2566
2676
|
const available = useMemo(() => CHART_CHOICES.filter(c => c === 'table' || chartFor(c, result, result.chart)), [result])
|
|
2567
|
-
const
|
|
2677
|
+
const [pinning, runPin] = useBusy()
|
|
2678
|
+
const pin = () => runPin(async () => {
|
|
2679
|
+
if (pinned) return
|
|
2568
2680
|
try {
|
|
2569
2681
|
await call('pinWidget', { title: result.title || result.sql.slice(0, 60), sql: result.sql, datasourceId: result.datasourceId, chart })
|
|
2570
2682
|
setPinned(true)
|
|
@@ -2572,8 +2684,9 @@ window.__ModuleLoader__.load({
|
|
|
2572
2684
|
} catch (e) {
|
|
2573
2685
|
notifyError(e)
|
|
2574
2686
|
}
|
|
2575
|
-
}
|
|
2576
|
-
const
|
|
2687
|
+
})
|
|
2688
|
+
const [savingExample, runSaveExample] = useBusy()
|
|
2689
|
+
const saveExample = () => runSaveExample(async () => {
|
|
2577
2690
|
const q = await promptDialog({ title: t('kb.examplePrompt'), value: question || result.title || '' })
|
|
2578
2691
|
if (!q) return
|
|
2579
2692
|
try {
|
|
@@ -2582,7 +2695,7 @@ window.__ModuleLoader__.load({
|
|
|
2582
2695
|
} catch (e) {
|
|
2583
2696
|
notifyError(e)
|
|
2584
2697
|
}
|
|
2585
|
-
}
|
|
2698
|
+
})
|
|
2586
2699
|
const rerun = async (confirmScan) => {
|
|
2587
2700
|
setRunning(true); setEditError(null)
|
|
2588
2701
|
try {
|
|
@@ -2626,7 +2739,7 @@ window.__ModuleLoader__.load({
|
|
|
2626
2739
|
isChart && result.chart?.x && result.rows.length >= 2 && { label: t('drill.why'), icon: Icon.spark(14), onClick: () => setDrill({ mode: 'why' }) },
|
|
2627
2740
|
isChart && chartInstance && { label: t('result.png'), icon: Icon.download(14), onClick: exportPng },
|
|
2628
2741
|
!hidden && { divider: true },
|
|
2629
|
-
!hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample, onClick: saveExample },
|
|
2742
|
+
!hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample || savingExample, onClick: saveExample },
|
|
2630
2743
|
]
|
|
2631
2744
|
const chartPicker = result.rows.length > 0 && h(Dropdown, {
|
|
2632
2745
|
label: t('result.chartType'),
|
|
@@ -2653,7 +2766,7 @@ window.__ModuleLoader__.load({
|
|
|
2653
2766
|
embedded && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'result.rows', { count: result.rowCount }), ' · ', fmt(t, 'result.elapsed', { ms: result.elapsedMs })),
|
|
2654
2767
|
!hidden && h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-pressed': rated === 'up', title: t('fb.up'), 'aria-label': t('fb.up'), onClick: () => rate('up') }, Icon.thumbUp()),
|
|
2655
2768
|
!hidden && h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-pressed': rated === 'down' || rating === 'down', title: t('fb.down'), 'aria-label': t('fb.down'), onClick: () => setRating(rating === 'down' ? null : 'down') }, Icon.thumbDown()),
|
|
2656
|
-
!hidden && h('button', { type: 'button', className: 'n2s-iconbtn', disabled: pinned, 'aria-pressed': pinned, title: pinned ? t('result.pinned') : t('result.pin'), 'aria-label': t('result.pin'), onClick: pin }, Icon.pin()),
|
|
2769
|
+
!hidden && h('button', { type: 'button', className: 'n2s-iconbtn', disabled: pinned || pinning, 'aria-busy': pinning || undefined, 'aria-pressed': pinned, title: pinned ? t('result.pinned') : t('result.pin'), 'aria-label': t('result.pin'), onClick: pin }, Icon.pin()),
|
|
2657
2770
|
h('button', { type: 'button', className: 'n2s-iconbtn', title: t('im.share'), 'aria-label': t('im.share'), onClick: () => setSending(true) }, Icon.send()),
|
|
2658
2771
|
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: moreItems })),
|
|
2659
2772
|
rating === 'down' && h(FeedbackForm, { t, onCancel: () => setRating(null), onSubmit: (comment) => rate('down', comment) }),
|
|
@@ -2838,6 +2951,18 @@ window.__ModuleLoader__.load({
|
|
|
2838
2951
|
const [state, setState] = useState(null)
|
|
2839
2952
|
const [drag, setDrag] = useState(false)
|
|
2840
2953
|
const inputRef = useRef(null)
|
|
2954
|
+
const box = useRef(null)
|
|
2955
|
+
useFocusTrap(box)
|
|
2956
|
+
const busyRef = useRef(false)
|
|
2957
|
+
busyRef.current = Boolean(state?.busy)
|
|
2958
|
+
useEffect(() => {
|
|
2959
|
+
const prev = document.activeElement
|
|
2960
|
+
setTimeout(() => box.current?.querySelector('[role=button], button')?.focus(), 0)
|
|
2961
|
+
// Escape works wherever focus is (not only inside the dialog), but not under a confirm dialog or mid-upload.
|
|
2962
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) onClose() }
|
|
2963
|
+
document.addEventListener('keydown', onKey)
|
|
2964
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
2965
|
+
}, [])
|
|
2841
2966
|
const pick = async (f) => {
|
|
2842
2967
|
if (!f) return
|
|
2843
2968
|
setFile(f); setSheets(null); setState({ busy: true }); setProgress(0)
|
|
@@ -2850,6 +2975,8 @@ window.__ModuleLoader__.load({
|
|
|
2850
2975
|
} catch (e) { setState({ error: e.message }) }
|
|
2851
2976
|
}
|
|
2852
2977
|
const run = async () => {
|
|
2978
|
+
if (busyRef.current) return
|
|
2979
|
+
busyRef.current = true
|
|
2853
2980
|
setState({ busy: true })
|
|
2854
2981
|
try {
|
|
2855
2982
|
const res = await call('importUpload', { datasourceId: target || undefined, newName: target ? undefined : newName, fileName: file.name, uploadId, mode, sheets: sheets.filter(x => x.include).map(x => ({ sheet: x.sheet, table: x.table, mode, columns: x.columns })) })
|
|
@@ -2860,7 +2987,7 @@ window.__ModuleLoader__.load({
|
|
|
2860
2987
|
}
|
|
2861
2988
|
const typeOpts = ['TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME']
|
|
2862
2989
|
return ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose() } },
|
|
2863
|
-
h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' }
|
|
2990
|
+
h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' } },
|
|
2864
2991
|
h('div', { className: 'n2s-row', style: { marginBottom: 12 } }, h('span', { className: 'n2s-modal-title' }, t('up.title')), h('span', { className: 'n2s-grow' }), h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
|
|
2865
2992
|
!sheets && h('div', {
|
|
2866
2993
|
className: `n2s-drop ${drag ? 'over' : ''}`, role: 'button', tabIndex: 0,
|
|
@@ -2890,19 +3017,19 @@ window.__ModuleLoader__.load({
|
|
|
2890
3017
|
h('tr', null, sh.columns.map((c, ci) => h('th', { key: ci, style: { top: 30, padding: '2px 8px' } }, h('select', { className: 'n2s-select', style: { height: 24, fontSize: 11, padding: '0 4px' }, value: c.type, 'aria-label': `${c.name} type`, onChange: e => setSheets(x => x.map((y, i) => i === si ? { ...y, columns: y.columns.map((cc, j) => j === ci ? { ...cc, type: e.target.value } : cc) } : y)) }, typeOpts.map(o => h('option', { key: o, value: o }, t(`up.type.${o}`)))))))),
|
|
2891
3018
|
h('tbody', null, sh.preview.slice(0, 8).map((r, ri) => h('tr', { key: ri }, sh.columns.map((c, ci) => h('td', { key: ci, className: c.type === 'INTEGER' || c.type === 'REAL' ? 'num' : undefined }, r[ci] === null || r[ci] === undefined ? '' : String(r[ci]))))))))))),
|
|
2892
3019
|
state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
|
|
2893
|
-
state?.done && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, fmt(t, 'up.done', { name: state.done.datasourceName }), ' ', state.done.results.map(r => r.ok ? fmt(t, 'up.resultOk', { table: r.table, rows: r.rows }) : `${r.sheet}
|
|
3020
|
+
state?.done && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, fmt(t, 'up.done', { name: state.done.datasourceName }), ' ', joinClauses(state.done.results.map(r => r.ok ? fmt(t, 'up.resultOk', { table: r.table, rows: r.rows }) : `${colon(r.sheet)}${r.error}`))),
|
|
2894
3021
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
2895
3022
|
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('up.privacy')),
|
|
2896
3023
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, state?.done ? t('ds.close') : t('ds.cancel')),
|
|
2897
3024
|
sheets && !state?.done && h('button', { type: 'button', className: 'n2s-btn primary', disabled: state?.busy || !sheets.some(x => x.include) || (!target && !newName.trim()), onClick: run }, state?.busy ? h('span', { className: 'n2s-spin' }) : null, t('up.import'))))), document.body)
|
|
2898
3025
|
}
|
|
2899
3026
|
function FileTables({ t, ds }) {
|
|
2900
|
-
const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
|
|
3027
|
+
const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
|
|
2901
3028
|
const tables = list.value ?? []
|
|
2902
3029
|
if (!tables.length) return null
|
|
2903
3030
|
return h('div', { className: 'n2s-chips', style: { marginTop: 6 } }, tables.map(tb => h('span', { key: tb.name, className: 'n2s-badge', title: `${tb.source ?? ''} · ${new Date(tb.updatedAt ?? tb.createdAt).toLocaleString()}` },
|
|
2904
3031
|
`${tb.name} · ${formatCompact(tb.rows)}`,
|
|
2905
|
-
h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 16, height: 16 }, 'aria-label': `${t('ds.delete')} ${tb.name}`, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'up.dropConfirm', { name: tb.name }), danger: true })) { await call('dropFileTable', { datasourceId: ds.id, table: tb.name })
|
|
3032
|
+
h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 16, height: 16 }, 'aria-label': `${t('ds.delete')} ${tb.name}`, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'up.dropConfirm', { name: tb.name }), danger: true })) { try { await call('dropFileTable', { datasourceId: ds.id, table: tb.name }) } catch (e) { notifyError(e) } list.reload(); refreshDatasources() } } }, Icon.close(10)))))
|
|
2906
3033
|
}
|
|
2907
3034
|
|
|
2908
3035
|
function SourcesTab({ t, datasources }) {
|
|
@@ -2949,7 +3076,7 @@ window.__ModuleLoader__.load({
|
|
|
2949
3076
|
h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
|
|
2950
3077
|
h('button', { type: 'button', className: 'n2s-btn small', disabled: tr?.busy, onClick: () => test(ds) }, tr?.busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.test')),
|
|
2951
3078
|
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
2952
|
-
!ds.current && { label: t('ds.setCurrent'), icon: Icon.check(14), onClick: async () => { await call('setCurrent', { id: ds.id })
|
|
3079
|
+
!ds.current && { label: t('ds.setCurrent'), icon: Icon.check(14), onClick: async () => { try { await call('setCurrent', { id: ds.id }) } catch (e) { notifyError(e) } refreshDatasources() } },
|
|
2953
3080
|
ds.type === 'sqlite' && { label: t('up.addFile'), icon: Icon.up(14), onClick: () => setUpload({ target: ds.id }) },
|
|
2954
3081
|
ds.type !== 'sqlite' && { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(ds) },
|
|
2955
3082
|
{ label: t('policy.button'), icon: svg(14, [h('rect', { key: 1, x: 3.5, y: 7, width: 9, height: 6.5, rx: 1.4 }), h('path', { key: 2, d: 'M5.5 7V5a2.5 2.5 0 0 1 5 0v2' })]), onClick: () => setPolicyFor(ds) },
|
|
@@ -2998,11 +3125,12 @@ window.__ModuleLoader__.load({
|
|
|
2998
3125
|
}
|
|
2999
3126
|
const quoteTable = (name) => ds.type === 'mysql' ? `\`${name.replace(/`/g, '``')}\`` : ds.type === 'postgres' ? name.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${name.replace(/"/g, '""')}"`
|
|
3000
3127
|
const runOverview = async (tb, pin) => {
|
|
3128
|
+
const forId = ds.id
|
|
3001
3129
|
setPreview({ busy: true })
|
|
3002
3130
|
try {
|
|
3003
|
-
const res = await call('tableOverview', { datasourceId:
|
|
3004
|
-
setPreview({ overview: res.results, pinned: pin })
|
|
3005
|
-
} catch (e) { setPreview({ error: e.message }) }
|
|
3131
|
+
const res = await call('tableOverview', { datasourceId: forId, table: tb.name, pin })
|
|
3132
|
+
if (currentDs.current === forId) setPreview({ overview: res.results, pinned: pin })
|
|
3133
|
+
} catch (e) { if (currentDs.current === forId) setPreview({ error: e.message }) }
|
|
3006
3134
|
}
|
|
3007
3135
|
// Default to the largest fact table so the detail pane is never blank.
|
|
3008
3136
|
const defaultTable = schema ? ([...schema.tables].sort((x, y) => (y.role === 'fact') - (x.role === 'fact') || (y.rows ?? 0) - (x.rows ?? 0))[0]?.name ?? null) : null
|
|
@@ -3076,7 +3204,7 @@ window.__ModuleLoader__.load({
|
|
|
3076
3204
|
* Hidden once every required step is done or the user dismisses it.
|
|
3077
3205
|
*/
|
|
3078
3206
|
function Onboarding({ t, ds, onNavigate, onAsk, examples }) {
|
|
3079
|
-
const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt])
|
|
3207
|
+
const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt], [])
|
|
3080
3208
|
const [busy, setBusy] = useState(false)
|
|
3081
3209
|
const v = st.value
|
|
3082
3210
|
if (!v || v.dismissed) return null
|
|
@@ -3115,12 +3243,21 @@ window.__ModuleLoader__.load({
|
|
|
3115
3243
|
const [exOffset, setExOffset] = useState(0)
|
|
3116
3244
|
const [recentLimit, setRecentLimit] = useState(8)
|
|
3117
3245
|
const [hiddenRecent, setHiddenRecent] = useState(() => new Set())
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3246
|
+
// Data source switched: reset paging and per-source results during render, so the
|
|
3247
|
+
// first request for the new source already uses offset 0 (no extra fetch).
|
|
3248
|
+
const [stateFor, setStateFor] = useState(ds?.id)
|
|
3249
|
+
if (stateFor !== ds?.id) {
|
|
3250
|
+
setStateFor(ds?.id)
|
|
3251
|
+
setExOffset(0); setRecentLimit(8); setHiddenRecent(new Set())
|
|
3252
|
+
setResult(null); setPlan(null); setError(null)
|
|
3253
|
+
}
|
|
3254
|
+
const pageOffset = stateFor === ds?.id ? exOffset : 0
|
|
3255
|
+
const pageRecent = stateFor === ds?.id ? recentLimit : 8
|
|
3256
|
+
// Scoped by data source + language: within one source the previous page stays on
|
|
3257
|
+
// screen while the next loads (no flicker on 换一批); another source's never does.
|
|
3258
|
+
const sugg = useAsync(() => (ds ? call('askSuggestions', { datasourceId: ds.id, locale: uiLang, offset: pageOffset, pageSize: 6, recentLimit: pageRecent }).catch(() => null) : null), [ds?.id, ds?.schemaAnalyzedAt, uiLang, pageOffset, pageRecent], [ds?.id, uiLang])
|
|
3259
|
+
useEffect(() => { if (sugg.value && !sugg.stale) setHiddenRecent(new Set()) }, [sugg.value])
|
|
3260
|
+
const data = sugg.value
|
|
3124
3261
|
const examples = data?.examples?.length ? data.examples : [t('ask.ex.tables'), t('ask.ex.biggest')]
|
|
3125
3262
|
const canShuffle = (data?.total ?? 0) > (data?.pageSize ?? 6)
|
|
3126
3263
|
const recent = (data?.recent ?? []).filter(r => !hiddenRecent.has(r.question))
|
|
@@ -3129,25 +3266,30 @@ window.__ModuleLoader__.load({
|
|
|
3129
3266
|
setHiddenRecent(x => new Set(x).add(r.question))
|
|
3130
3267
|
try { const res = await call('deleteHistory', { title: r.question, datasourceId: ds.id }); notify(fmt(t, 'ask.deletedN', { n: res.deleted }), 'ok') } catch (e) { notifyError(e); setHiddenRecent(x => { const n = new Set(x); n.delete(r.question); return n }) }
|
|
3131
3268
|
}
|
|
3269
|
+
const currentDs = useRef(ds?.id)
|
|
3270
|
+
currentDs.current = ds?.id
|
|
3132
3271
|
const run = async (confirmScan = false) => {
|
|
3272
|
+
const forId = ds.id
|
|
3133
3273
|
setRunning(true); setError(null)
|
|
3134
3274
|
try {
|
|
3135
|
-
|
|
3275
|
+
const r = await call('runQuery', { sql, datasourceId: forId, title: '', confirmScan })
|
|
3276
|
+
if (currentDs.current === forId) setResult(r)
|
|
3136
3277
|
} catch (e) {
|
|
3137
|
-
if (e.code === 'nl2sql/scan-guard' && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
|
|
3138
|
-
setError(e.message)
|
|
3278
|
+
if (e.code === 'nl2sql/scan-guard' && currentDs.current === forId && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
|
|
3279
|
+
if (currentDs.current === forId) setError(e.message)
|
|
3139
3280
|
}
|
|
3140
3281
|
setRunning(false)
|
|
3141
3282
|
}
|
|
3142
3283
|
const explain = async () => {
|
|
3284
|
+
const forId = ds.id
|
|
3143
3285
|
setRunning(true); setError(null)
|
|
3144
|
-
try {
|
|
3286
|
+
try { const p = await call('explain', { sql, datasourceId: forId }); if (currentDs.current === forId) setPlan(p) } catch (e) { if (currentDs.current === forId) setError(e.message) }
|
|
3145
3287
|
setRunning(false)
|
|
3146
3288
|
}
|
|
3147
3289
|
const submit = () => { if (question.trim()) { onAsk(question.trim()); setQuestion('') } }
|
|
3148
3290
|
const [uploading, setUploading] = useState(false)
|
|
3149
3291
|
const [sqlOpen, setSqlOpen] = useState(false)
|
|
3150
|
-
const tableCount = ds?.tableCount ??
|
|
3292
|
+
const tableCount = ds?.tableCount ?? data?.tableCount
|
|
3151
3293
|
return h('div', null,
|
|
3152
3294
|
h(Onboarding, { t, ds, onNavigate, onAsk, examples }),
|
|
3153
3295
|
h('div', { className: 'n2s-hero' },
|
|
@@ -3196,15 +3338,16 @@ window.__ModuleLoader__.load({
|
|
|
3196
3338
|
result && h('div', { className: 'n2s-card', style: { marginTop: 12 } }, h(ResultView, { result, t, hideSql: true })))))
|
|
3197
3339
|
}
|
|
3198
3340
|
|
|
3199
|
-
/** "3 分钟前" style relative time. */
|
|
3341
|
+
/** "3 分钟前" style relative time in the UI language; future or older than a week → the date. */
|
|
3200
3342
|
function relTime(t, at) {
|
|
3201
3343
|
if (!at) return ''
|
|
3202
3344
|
const d = (Date.now() - at) / 1000
|
|
3345
|
+
if (d < -60) return new Date(at).toLocaleDateString(uiLocale())
|
|
3203
3346
|
if (d < 60) return t('time.justNow')
|
|
3204
3347
|
if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
|
|
3205
3348
|
if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
|
|
3206
3349
|
if (d < 86400 * 7) return fmt(t, 'time.dayAgo', { n: Math.floor(d / 86400) })
|
|
3207
|
-
return new Date(at).toLocaleDateString()
|
|
3350
|
+
return new Date(at).toLocaleDateString(uiLocale())
|
|
3208
3351
|
}
|
|
3209
3352
|
|
|
3210
3353
|
function PlanTable({ plan }) {
|
|
@@ -3290,6 +3433,7 @@ window.__ModuleLoader__.load({
|
|
|
3290
3433
|
}
|
|
3291
3434
|
function KnowledgeEditor({ kind, item, t, fields, schema, noteKeys, onCancel, onSave }) {
|
|
3292
3435
|
const [form, setForm] = useState(() => ({ ...item, ...(Array.isArray(item?.synonyms) ? { synonyms: item.synonyms.join(',') } : {}) }))
|
|
3436
|
+
const [busy, runBusy] = useBusy()
|
|
3293
3437
|
return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
|
|
3294
3438
|
h('div', { className: 'n2s-form' }, fields.map(([key, label, type]) => h('label', { key, className: `n2s-field ${type === 'textarea' || type?.startsWith('code') ? 'full' : ''}` },
|
|
3295
3439
|
h('span', null, t(label)),
|
|
@@ -3300,19 +3444,19 @@ window.__ModuleLoader__.load({
|
|
|
3300
3444
|
h('datalist', { id: 'n2s-notekeys' }, noteKeys.slice(0, 2000).map(k2 => h('option', { key: k2, value: k2 }))),
|
|
3301
3445
|
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
3302
3446
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
3303
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => onSave(form) }, t('ds.save'))))
|
|
3447
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => runBusy(() => onSave(form)) }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
3304
3448
|
}
|
|
3305
3449
|
|
|
3306
3450
|
function KnowledgeTab({ t, ds }) {
|
|
3307
3451
|
const kb = useAsync(() => (ds ? call('getKnowledge', { datasourceId: ds.id }) : null), [ds?.id])
|
|
3308
|
-
const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt])
|
|
3452
|
+
const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt], [ds?.id])
|
|
3309
3453
|
const [section, setSection] = useState('glossary')
|
|
3310
3454
|
const [editing, setEditing] = useState(null)
|
|
3311
3455
|
const [error, setError] = useState(null)
|
|
3312
3456
|
const [drafts, setDrafts] = useState(null)
|
|
3313
3457
|
const noteKeys = useMemo(() => (schema.value?.tables ?? []).flatMap(tb => [tb.name, ...tb.columns.map(c => `${tb.name}.${c.name}`)]), [schema.value])
|
|
3314
3458
|
const allDatasources = useStore(s2 => s2.datasources)
|
|
3315
|
-
const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value])
|
|
3459
|
+
const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value], [ds?.id])
|
|
3316
3460
|
if (!ds) return null
|
|
3317
3461
|
const k = kb.value ?? { glossary: [], metrics: [], notes: {}, examples: [] }
|
|
3318
3462
|
const save = async (kind, item) => {
|
|
@@ -3321,7 +3465,7 @@ window.__ModuleLoader__.load({
|
|
|
3321
3465
|
}
|
|
3322
3466
|
const remove = async (kind, id) => {
|
|
3323
3467
|
if (!await confirmDialog({ title: t('kb.deleteConfirm'), danger: true })) return
|
|
3324
|
-
await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id })
|
|
3468
|
+
try { await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }) } catch (e) { setError(e.message) }
|
|
3325
3469
|
kb.reload()
|
|
3326
3470
|
}
|
|
3327
3471
|
const FIELDS = {
|
|
@@ -3346,7 +3490,7 @@ window.__ModuleLoader__.load({
|
|
|
3346
3490
|
if (section === 'glossary') list = k.glossary.length ? k.glossary.map(g => item(g.id, h(React.Fragment, null, g.term, g.synonyms?.length ? h('span', { className: 'n2s-muted n2s-small' }, ` ${g.synonyms.join(' / ')}`) : null),
|
|
3347
3491
|
h('div', null, h('div', null, g.definition), g.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 6 } }, g.sql)), 'glossary', g)) : empty(t('kb.glossaryEmpty'))
|
|
3348
3492
|
if (section === 'metric') list = k.metrics.length ? k.metrics.map(m => item(m.id, h(React.Fragment, null, m.name, m.unit ? h('span', { className: 'n2s-badge', style: { marginLeft: 6 } }, m.unit) : null),
|
|
3349
|
-
h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${m.dimensions
|
|
3493
|
+
h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${joinList(m.dimensions)}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${joinList(m.synonyms)}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
|
|
3350
3494
|
if (section === 'note') {
|
|
3351
3495
|
const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
|
|
3352
3496
|
list = h(React.Fragment, null,
|
|
@@ -3371,7 +3515,7 @@ window.__ModuleLoader__.load({
|
|
|
3371
3515
|
}
|
|
3372
3516
|
function HistoryTab({ t, datasources, onOpenSession }) {
|
|
3373
3517
|
const [filters, setFilters] = useState({ status: '', source: '', datasourceId: '', search: '' })
|
|
3374
|
-
const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search])
|
|
3518
|
+
const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search], [])
|
|
3375
3519
|
const [open, setOpen] = useState(null)
|
|
3376
3520
|
const [rerun, setRerun] = useState(null)
|
|
3377
3521
|
const [removed, setRemoved] = useState(() => new Set())
|
|
@@ -3397,13 +3541,7 @@ window.__ModuleLoader__.load({
|
|
|
3397
3541
|
}, [rows])
|
|
3398
3542
|
const [search, setSearch] = useState('')
|
|
3399
3543
|
useEffect(() => { const id = setTimeout(() => setFilters(f => (f.search === search ? f : { ...f, search })), 250); return () => clearTimeout(id) }, [search])
|
|
3400
|
-
const rel = (at) =>
|
|
3401
|
-
const d = (Date.now() - at) / 1000
|
|
3402
|
-
if (d < 60) return t('time.justNow')
|
|
3403
|
-
if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
|
|
3404
|
-
if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
|
|
3405
|
-
return new Date(at).toLocaleDateString()
|
|
3406
|
-
}
|
|
3544
|
+
const rel = (at) => relTime(t, at)
|
|
3407
3545
|
const statusDot = (st) => h('span', { className: `n2s-statusdot ${st === 'error' ? 'err' : st === 'blocked' ? 'warn' : 'ok'}` }, t(`hist.status.${st ?? 'ok'}`))
|
|
3408
3546
|
const oneLine = (sql) => sql.replace(/\s+/g, ' ').trim()
|
|
3409
3547
|
return h('div', null,
|
|
@@ -3418,7 +3556,7 @@ window.__ModuleLoader__.load({
|
|
|
3418
3556
|
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
3419
3557
|
{ label: t('hist.export'), icon: Icon.download(14), disabled: rows.length === 0, onClick: exportCsv },
|
|
3420
3558
|
{ divider: true },
|
|
3421
|
-
{ label: t('hist.clear'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: t('hist.clearConfirm'), danger: true })) { await call('clearHistory')
|
|
3559
|
+
{ label: t('hist.clear'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: t('hist.clearConfirm'), danger: true })) { try { await call('clearHistory') } catch (e) { notifyError(e) } list.reload() } } },
|
|
3422
3560
|
] })),
|
|
3423
3561
|
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
3424
3562
|
list.loading && !list.value && h('div', { className: 'n2s-skel', style: { height: 240 } }),
|
|
@@ -3432,7 +3570,7 @@ window.__ModuleLoader__.load({
|
|
|
3432
3570
|
h('span', { className: 'n2s-hist-what' },
|
|
3433
3571
|
h('span', { className: `n2s-hist-title ${r.title ? '' : 'none'}` }, r.title || r.error?.slice(0, 80) || t('hist.untitled')),
|
|
3434
3572
|
h('span', { className: 'n2s-hist-sql', title: oneLine(r.sql).slice(0, 400) }, oneLine(r.sql).slice(0, 300))),
|
|
3435
|
-
h('span', { className: 'n2s-hist-when' }, h('span', { title: new Date(r.at).toLocaleString() }, rel(r.at)), h('span', { className: 'src' }, `${t
|
|
3573
|
+
h('span', { className: 'n2s-hist-when' }, h('span', { title: new Date(r.at).toLocaleString(uiLocale()) }, rel(r.at)), h('span', { className: 'src' }, `${sourceLabel(t, r.source)} · ${r.datasourceName ?? ''}`)),
|
|
3436
3574
|
h('span', { className: 'n2s-hist-num' }, r.rows !== undefined ? fmt(t, 'result.rows', { count: r.rows }) : '—', h('br'), r.elapsedMs !== undefined ? `${r.elapsedMs} ms` : ''),
|
|
3437
3575
|
h('span', { className: 'n2s-hist-chev' }, Icon.chev(12))),
|
|
3438
3576
|
h('button', { type: 'button', className: 'n2s-iconbtn n2s-rowdel', title: t('hist.deleteOne'), 'aria-label': `${t('hist.deleteOne')}: ${r.title || t('hist.untitled')}`, onClick: () => removeRow(r) }, Icon.trash(14))),
|
|
@@ -3442,7 +3580,7 @@ window.__ModuleLoader__.load({
|
|
|
3442
3580
|
h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
|
|
3443
3581
|
h('button', { type: 'button', className: 'n2s-iconbtn', style: { position: 'absolute', top: 6, right: 6 }, title: t('result.copy'), 'aria-label': t('result.copy'), onClick: () => navigator.clipboard?.writeText(r.sql) }, Icon.copy(14))),
|
|
3444
3582
|
h('div', { className: 'n2s-row' },
|
|
3445
|
-
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString()),
|
|
3583
|
+
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString(uiLocale())),
|
|
3446
3584
|
r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
|
|
3447
3585
|
h('button', {
|
|
3448
3586
|
type: 'button', className: 'n2s-btn small',
|
|
@@ -3455,6 +3593,15 @@ window.__ModuleLoader__.load({
|
|
|
3455
3593
|
rerun?.id === r.id && (rerun.busy ? h('div', { className: 'n2s-skel', style: { height: 200 } }) : rerun.error ? h('div', { className: 'n2s-notice err' }, rerun.error) : h('div', { className: 'n2s-card' }, h(ResultView, { result: rerun.result, t, hideSql: true })))))
|
|
3456
3594
|
})))
|
|
3457
3595
|
}
|
|
3596
|
+
|
|
3597
|
+
const HIST_SOURCES = ['agent', 'ui', 'dashboard', 'alert', 'eval', 'larkbot', 'telegrambot', 'wecombot', 'dingtalkbot']
|
|
3598
|
+
/** Label of a history `source`; unknown sources (a new bot platform…) fall back to a generic label. */
|
|
3599
|
+
function sourceLabel(t, source) {
|
|
3600
|
+
if (!source) return t('hist.source.unknown')
|
|
3601
|
+
if (HIST_SOURCES.includes(source)) return t(`hist.source.${source}`)
|
|
3602
|
+
const bot = /^(.+)bot$/.exec(source)
|
|
3603
|
+
return bot ? fmt(t, 'hist.source.genericBot', { platform: bot[1] }) : source
|
|
3604
|
+
}
|
|
3458
3605
|
function OverviewGrid({ t, results, pinned }) {
|
|
3459
3606
|
const [sending, setSending] = useState(false)
|
|
3460
3607
|
const ok = results.filter(r => !r.error && r.resultId)
|
|
@@ -3532,7 +3679,7 @@ window.__ModuleLoader__.load({
|
|
|
3532
3679
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, svg(16, [h('circle', { key: 1, cx: 8, cy: 8, r: 5.5 }), h('path', { key: 2, d: 'M8 5v3.2l2 1.3' })])),
|
|
3533
3680
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
3534
3681
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, r.title), r.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), r.channelName)),
|
|
3535
|
-
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(
|
|
3682
|
+
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(listSep()) + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
|
|
3536
3683
|
h('div', { className: 'n2s-list2-side' },
|
|
3537
3684
|
statusDot(r.status),
|
|
3538
3685
|
h('span', { className: 'n2s-faint n2s-small' }, r.scheduledAt && r.status === 'active' ? `${t('rep.next')} ${new Date(r.scheduledAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}` : '')),
|
|
@@ -3548,25 +3695,32 @@ window.__ModuleLoader__.load({
|
|
|
3548
3695
|
}
|
|
3549
3696
|
function FeedbackForm({ t, onCancel, onSubmit }) {
|
|
3550
3697
|
const [comment, setComment] = useState('')
|
|
3698
|
+
const [busy, runBusy] = useBusy()
|
|
3551
3699
|
const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
|
|
3552
3700
|
return h('div', { className: 'n2s-card', style: { padding: 10 } },
|
|
3553
3701
|
h('div', { className: 'n2s-small', style: { marginBottom: 6 } }, t('fb.whatWrong')),
|
|
3554
|
-
h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ?
|
|
3702
|
+
h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ? joinClauses([c, t(r)]) : t(r))) }, t(r)))),
|
|
3555
3703
|
h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
|
|
3556
3704
|
h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
|
|
3557
3705
|
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: onCancel }, t('ds.cancel')),
|
|
3558
|
-
h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => onSubmit(comment.trim()) }, t('fb.submit'))))
|
|
3706
|
+
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy, onClick: () => runBusy(() => onSubmit(comment.trim())) }, busy ? h('span', { className: 'n2s-spin' }) : null, t('fb.submit'))))
|
|
3559
3707
|
}
|
|
3560
3708
|
|
|
3561
3709
|
function FeedbackSection({ t, ds }) {
|
|
3562
3710
|
const [status, setStatus] = useState('open')
|
|
3563
|
-
const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status])
|
|
3564
|
-
const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value])
|
|
3711
|
+
const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status], [ds?.id])
|
|
3712
|
+
const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value], [ds?.id])
|
|
3565
3713
|
const [fixing, setFixing] = useState(null)
|
|
3566
3714
|
const [error, setError] = useState(null)
|
|
3567
|
-
const
|
|
3715
|
+
const [busy, runBusy] = useBusy()
|
|
3716
|
+
const resolve = (payload) => runBusy(async () => {
|
|
3568
3717
|
setError(null)
|
|
3569
3718
|
try { await call('resolveFeedback', payload); setFixing(null); list.reload() } catch (e) { setError(e.message) }
|
|
3719
|
+
})
|
|
3720
|
+
const remove = async (f) => {
|
|
3721
|
+
if (!await confirmDialog({ title: t('fb.deleteConfirm'), message: f.question || f.title || '', confirmLabel: t('ds.delete'), danger: true })) return
|
|
3722
|
+
try { await call('deleteFeedback', { id: f.id }) } catch (e) { notifyError(e) }
|
|
3723
|
+
list.reload()
|
|
3570
3724
|
}
|
|
3571
3725
|
const st = stats.value
|
|
3572
3726
|
return h('div', null,
|
|
@@ -3588,13 +3742,13 @@ window.__ModuleLoader__.load({
|
|
|
3588
3742
|
h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
|
|
3589
3743
|
f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
|
|
3590
3744
|
h('pre', { className: 'n2s-code', style: { marginTop: 6 } }, f.correctedSql || f.sql),
|
|
3591
|
-
f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${t('fb.resolution')}
|
|
3745
|
+
f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${colon(t('fb.resolution'))}${f.resolution}`)),
|
|
3592
3746
|
h('div', { style: { display: 'grid', gap: 4 } },
|
|
3593
3747
|
f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
|
|
3594
3748
|
f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => setFixing({ id: f.id, sql: f.sql, question: f.question || f.title, resolution: '' }) }, t('fb.fix')),
|
|
3595
3749
|
f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
|
|
3596
3750
|
f.rating === 'up' && !f.savedAsExample && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'resolved', saveExample: true }) }, t('kb.saveExample')),
|
|
3597
|
-
h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick:
|
|
3751
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick: () => remove(f) }, t('ds.delete')))),
|
|
3598
3752
|
fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
|
|
3599
3753
|
h('label', { className: 'n2s-field' }, h('span', null, t('kb.question')), h('input', { className: 'n2s-input', value: fixing.question, onChange: e => setFixing(x => ({ ...x, question: e.target.value })) })),
|
|
3600
3754
|
h('label', { className: 'n2s-field' }, h('span', null, t('fb.correctedSql')), h('textarea', { className: 'n2s-textarea n2s-mono', style: { minHeight: 120 }, spellCheck: false, value: fixing.sql, onChange: e => setFixing(x => ({ ...x, sql: e.target.value })) })),
|
|
@@ -3640,10 +3794,16 @@ window.__ModuleLoader__.load({
|
|
|
3640
3794
|
area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
|
|
3641
3795
|
h('div', null,
|
|
3642
3796
|
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 6 } }, tables.length ? fmt(t, 'policy.preview', { allowed: allowed.length, total: tables.length }) : t('schema.notAnalyzed')),
|
|
3643
|
-
h('div', { className: 'n2s-list', style: { maxHeight: 320 } }, tables.map(tb => {
|
|
3797
|
+
h('div', { className: 'n2s-list', role: 'group', 'aria-label': t('policy.tablesLabel'), style: { maxHeight: 320 } }, tables.map(tb => {
|
|
3644
3798
|
const ok = allowed.includes(tb)
|
|
3645
|
-
|
|
3646
|
-
|
|
3799
|
+
const editable = form.mode !== 'all'
|
|
3800
|
+
return h('div', {
|
|
3801
|
+
key: tb.name, className: 'n2s-item', style: { cursor: editable ? 'pointer' : 'default', opacity: ok ? 1 : 0.5 },
|
|
3802
|
+
role: 'checkbox', 'aria-checked': ok, 'aria-disabled': editable ? undefined : true, tabIndex: editable ? 0 : -1, 'aria-label': tb.name,
|
|
3803
|
+
onClick: () => editable && toggleTable(tb.name),
|
|
3804
|
+
onKeyDown: (e) => { if (editable && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); toggleTable(tb.name) } },
|
|
3805
|
+
},
|
|
3806
|
+
h('span', { 'aria-hidden': true }, ok ? '✓' : '✕'), h('span', { className: 'name n2s-mono' }, tb.name), roleBadge(t, tb.role))
|
|
3647
3807
|
})),
|
|
3648
3808
|
form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
|
|
3649
3809
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
@@ -3662,8 +3822,16 @@ window.__ModuleLoader__.load({
|
|
|
3662
3822
|
const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
|
|
3663
3823
|
onChange([...(next ? [next] : []), ...ins])
|
|
3664
3824
|
}
|
|
3665
|
-
|
|
3825
|
+
// keep: an emptied free-text field keeps its tag (with no values) instead of disappearing.
|
|
3826
|
+
const setIn = (column, values, keep) => {
|
|
3827
|
+
const others = ins.filter(f => f.column !== column)
|
|
3828
|
+
const at = ins.findIndex(f => f.column === column)
|
|
3829
|
+
const mine = values.length || keep ? [{ kind: 'in', column, values }] : []
|
|
3830
|
+
const next = at < 0 ? [...others, ...mine] : [...others.slice(0, at), ...mine, ...others.slice(at)]
|
|
3831
|
+
onChange([...(time ? [time] : []), ...next])
|
|
3832
|
+
}
|
|
3666
3833
|
const c = cand.value ?? { time: [], dims: [] }
|
|
3834
|
+
const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
|
|
3667
3835
|
return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
|
|
3668
3836
|
h('span', { className: 'n2s-faint', style: { display: 'inline-flex' } }, svg(14, [h('path', { key: 1, d: 'M2.5 3.5h11l-4.2 5v4l-2.6 1.2V8.5L2.5 3.5z' })])),
|
|
3669
3837
|
h('select', { className: 'n2s-select', value: time ? time.range : '', onChange: e => setTime(e.target.value ? { range: e.target.value } : null), 'aria-label': t('filter.range') },
|
|
@@ -3680,7 +3848,7 @@ window.__ModuleLoader__.load({
|
|
|
3680
3848
|
h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
|
|
3681
3849
|
dim?.values?.length
|
|
3682
3850
|
? h('select', { className: 'n2s-select', 'aria-label': dim.label, value: f.values[0] ?? '', onChange: e => setIn(f.column, e.target.value ? [e.target.value] : []) }, dim.values.map(v => h('option', { key: v, value: v }, v)))
|
|
3683
|
-
: h(
|
|
3851
|
+
: h(FilterValuesInput, { key: f.column, t, label: dim?.label ?? f.column, values: f.values, onCommit: (values) => setIn(f.column, values, true) }),
|
|
3684
3852
|
h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 22, height: 22 }, 'aria-label': t('filter.remove'), onClick: () => setIn(f.column, []) }, Icon.close(11)))
|
|
3685
3853
|
}),
|
|
3686
3854
|
c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
|
|
@@ -3689,9 +3857,39 @@ window.__ModuleLoader__.load({
|
|
|
3689
3857
|
items: c.dims.filter(d => !ins.some(f => f.column === d.column)).map(d => ({ label: d.label === d.column ? d.column : `${d.label}`, hint: d.label === d.column ? '' : d.column, onClick: () => setIn(d.column, d.values?.slice(0, 1) ?? []) })),
|
|
3690
3858
|
}),
|
|
3691
3859
|
h('span', { className: 'n2s-grow' }),
|
|
3692
|
-
|
|
3860
|
+
activeN > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: activeN })),
|
|
3693
3861
|
filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
|
|
3694
3862
|
}
|
|
3863
|
+
|
|
3864
|
+
/**
|
|
3865
|
+
* Free-text values of an "in" filter: typed into a local draft (commas allowed,
|
|
3866
|
+
* clearing does not remove the tag) and committed on Enter, blur or after a pause.
|
|
3867
|
+
*/
|
|
3868
|
+
function FilterValuesInput({ t, label, values, onCommit }) {
|
|
3869
|
+
const joined = values.join(', ')
|
|
3870
|
+
const [draft, setDraft] = useState(joined)
|
|
3871
|
+
const committed = useRef(joined)
|
|
3872
|
+
// Adopt outside changes (clear all, another board) when not mid-edit.
|
|
3873
|
+
useEffect(() => { if (joined !== committed.current) { committed.current = joined; setDraft(joined) } }, [joined])
|
|
3874
|
+
const parse = (s) => s.split(/[,,]/).map(x => x.trim()).filter(Boolean)
|
|
3875
|
+
const commit = (s) => {
|
|
3876
|
+
const next = parse(s)
|
|
3877
|
+
if (next.join('\u0000') === parse(committed.current).join('\u0000')) return
|
|
3878
|
+
committed.current = next.join(', ')
|
|
3879
|
+
onCommit(next)
|
|
3880
|
+
}
|
|
3881
|
+
useEffect(() => {
|
|
3882
|
+
if (draft === committed.current) return
|
|
3883
|
+
const id = setTimeout(() => commit(draft), 800)
|
|
3884
|
+
return () => clearTimeout(id)
|
|
3885
|
+
}, [draft])
|
|
3886
|
+
return h('input', {
|
|
3887
|
+
className: 'n2s-input', style: { width: 120, height: 26, border: 0 }, 'aria-label': label, placeholder: t('filter.valuesPh'), value: draft,
|
|
3888
|
+
onChange: e => setDraft(e.target.value),
|
|
3889
|
+
onBlur: () => commit(draft),
|
|
3890
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && !e.nativeEvent?.isComposing) { e.preventDefault(); commit(draft) } },
|
|
3891
|
+
})
|
|
3892
|
+
}
|
|
3695
3893
|
// ------------------------------------------------------------ IM push
|
|
3696
3894
|
|
|
3697
3895
|
function makeReplyAction(ctx) {
|
|
@@ -4073,6 +4271,11 @@ window.__ModuleLoader__.load({
|
|
|
4073
4271
|
}, [])
|
|
4074
4272
|
const [state, setState] = useState(null)
|
|
4075
4273
|
const ref = useRef(null)
|
|
4274
|
+
useFocusTrap(ref)
|
|
4275
|
+
const closeTimer = useRef(null)
|
|
4276
|
+
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
|
4277
|
+
const busyRef = useRef(false)
|
|
4278
|
+
busyRef.current = Boolean(state?.busy)
|
|
4076
4279
|
useEffect(() => {
|
|
4077
4280
|
const def = channels.find(c => c.isDefault) ?? channels[0]
|
|
4078
4281
|
if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
|
|
@@ -4080,11 +4283,14 @@ window.__ModuleLoader__.load({
|
|
|
4080
4283
|
useEffect(() => {
|
|
4081
4284
|
const prev = document.activeElement
|
|
4082
4285
|
ref.current?.querySelector('select, textarea, input, button')?.focus()
|
|
4083
|
-
|
|
4286
|
+
// Escape belongs to a confirm/prompt dialog on top, if any.
|
|
4287
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) onClose() }
|
|
4084
4288
|
document.addEventListener('keydown', onKey)
|
|
4085
4289
|
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
4086
4290
|
}, [])
|
|
4087
4291
|
const send = async () => {
|
|
4292
|
+
if (busyRef.current) return
|
|
4293
|
+
busyRef.current = true
|
|
4088
4294
|
setState({ busy: true })
|
|
4089
4295
|
try {
|
|
4090
4296
|
const res = initial.dashboard
|
|
@@ -4092,7 +4298,7 @@ window.__ModuleLoader__.load({
|
|
|
4092
4298
|
: await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
|
|
4093
4299
|
setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
|
|
4094
4300
|
refreshChannels()
|
|
4095
|
-
setTimeout(onClose, 1200)
|
|
4301
|
+
closeTimer.current = setTimeout(onClose, 1200)
|
|
4096
4302
|
} catch (e) {
|
|
4097
4303
|
setState({ error: e.message })
|
|
4098
4304
|
}
|
|
@@ -4159,6 +4365,7 @@ window.__ModuleLoader__.load({
|
|
|
4159
4365
|
/** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
|
|
4160
4366
|
function Modal({ label, labelledBy, onClose, width, children, className }) {
|
|
4161
4367
|
const box = useRef(null)
|
|
4368
|
+
useFocusTrap(box)
|
|
4162
4369
|
useEffect(() => {
|
|
4163
4370
|
const prev = document.activeElement
|
|
4164
4371
|
setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
|
|
@@ -4184,13 +4391,13 @@ window.__ModuleLoader__.load({
|
|
|
4184
4391
|
h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
|
|
4185
4392
|
p && h(React.Fragment, null,
|
|
4186
4393
|
h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
|
|
4187
|
-
h('
|
|
4188
|
-
(p.kinds ?? []).map(k => h('
|
|
4394
|
+
h('ul', { className: 'n2s-kindcards', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
|
|
4395
|
+
(p.kinds ?? []).map(k => h('li', { key: k.id, className: 'n2s-kindcard-li' }, h('button', { type: 'button', 'data-nav': true, className: 'n2s-kindcard', onClick: () => onPick(p.platform, k.id) },
|
|
4189
4396
|
h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
|
|
4190
4397
|
k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
|
|
4191
4398
|
h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
|
|
4192
4399
|
h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
|
|
4193
|
-
k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
|
|
4400
|
+
k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl')))))))))
|
|
4194
4401
|
}
|
|
4195
4402
|
|
|
4196
4403
|
// ---------------------------------------------------------------- step 2
|
|
@@ -4427,7 +4634,7 @@ window.__ModuleLoader__.load({
|
|
|
4427
4634
|
|
|
4428
4635
|
// ----------------------------------------------------------------- list
|
|
4429
4636
|
|
|
4430
|
-
const shortTime = (ts) => new Date(ts).toLocaleString(
|
|
4637
|
+
const shortTime = (ts) => new Date(ts).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
4431
4638
|
|
|
4432
4639
|
function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
|
|
4433
4640
|
const kind = findKind(catalog, c.type)?.kind
|
|
@@ -4491,7 +4698,7 @@ window.__ModuleLoader__.load({
|
|
|
4491
4698
|
const channels = useChannels()
|
|
4492
4699
|
const bots = useBots()
|
|
4493
4700
|
const { catalog } = useCatalog(t)
|
|
4494
|
-
const deliveries = useAsync(() => call('listDeliveries'), [channels])
|
|
4701
|
+
const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
|
|
4495
4702
|
const [editing, setEditing] = useState(null)
|
|
4496
4703
|
// Pick up host upgrades (catalog, new channel shape) whenever the page opens.
|
|
4497
4704
|
useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
|
|
@@ -4617,7 +4824,12 @@ window.__ModuleLoader__.load({
|
|
|
4617
4824
|
useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
|
|
4618
4825
|
const filtersReady = filters !== null && filtersFor === boardId
|
|
4619
4826
|
const [sendingDash, setSendingDash] = useState(false)
|
|
4620
|
-
|
|
4827
|
+
// Saves are serialized so an older save can never land after a newer one.
|
|
4828
|
+
const saveChain = useRef(Promise.resolve())
|
|
4829
|
+
const changeFilters = (next) => {
|
|
4830
|
+
setFilters(next)
|
|
4831
|
+
saveChain.current = saveChain.current.then(() => call('saveDashboardFilters', { filters: next })).catch((e) => notifyError(e))
|
|
4832
|
+
}
|
|
4621
4833
|
const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
|
|
4622
4834
|
// Per-widget load status → header summary ("updated 10:42 · 2 from cache").
|
|
4623
4835
|
const [loads, setLoads] = useState({})
|
|
@@ -4680,7 +4892,7 @@ window.__ModuleLoader__.load({
|
|
|
4680
4892
|
h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
|
|
4681
4893
|
(mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
|
|
4682
4894
|
['all', 'current'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': scope === v, disabled: v === 'current' && !ds, onClick: () => setScope(v) }, v === 'all' ? t('dash.scopeAll') : fmt(t, 'dash.scopeCurrent', { name: ds?.name ?? '' }))))),
|
|
4683
|
-
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort'))
|
|
4895
|
+
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: joinList(sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort'))) })),
|
|
4684
4896
|
h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
|
|
4685
4897
|
loadingN > 0 ? fmt(t, 'dash.loadingN', { n: loadingN }) : lastAt ? fmt(t, 'dash.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : '',
|
|
4686
4898
|
cachedN > 0 && loadingN === 0 ? h('span', null, ' · ', h('button', { type: 'button', className: 'n2s-link', title: t('dash.cachedHint'), onClick: () => setTick(x => x + 1) }, fmt(t, 'dash.cachedN', { n: cachedN }))) : null,
|
|
@@ -4701,7 +4913,7 @@ window.__ModuleLoader__.load({
|
|
|
4701
4913
|
!list.loading && allWidgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
|
|
4702
4914
|
widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
|
|
4703
4915
|
filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
|
|
4704
|
-
key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
|
|
4916
|
+
key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).filter(f => f.kind !== 'in' || f.values.length > 0).length,
|
|
4705
4917
|
onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
|
|
4706
4918
|
onMove: (delta) => move(w, delta),
|
|
4707
4919
|
onRemove: async () => { if (!await confirmDialog({ title: fmt(t, 'dash.removeConfirm', { title: w.title }), danger: true })) return; try { await call('unpinWidget', { id: w.id }); notify(t('dash.removed'), 'ok') } catch (e) { notifyError(e) } list.reload() },
|
|
@@ -4756,24 +4968,32 @@ window.__ModuleLoader__.load({
|
|
|
4756
4968
|
h('span', { className: 'n2s-wf-bar' }, h('span', { className: `n2s-wf-fill ${it.delta > 0 ? 'up' : it.delta < 0 ? 'down' : ''}`, style: { width: `${Math.max(2, Math.abs(it.delta) / max * 100)}%` } })),
|
|
4757
4969
|
h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
|
|
4758
4970
|
h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
|
|
4759
|
-
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(
|
|
4971
|
+
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(listSep())}`))
|
|
4760
4972
|
}
|
|
4761
4973
|
|
|
4762
4974
|
// ------------------------------------------------------------ alerts
|
|
4763
4975
|
|
|
4764
4976
|
function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
|
|
4765
4977
|
const channels = useChannels()
|
|
4766
|
-
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: channels.find(c => c.isDefault)?.id ?? '', mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
|
|
4978
|
+
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: initial ? '' : (channels.find(c => c.isDefault)?.id ?? ''), mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
|
|
4767
4979
|
const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
|
|
4768
4980
|
const [error, setError] = useState(null)
|
|
4769
4981
|
const [test, setTest] = useState(null)
|
|
4982
|
+
const [busy, runBusy] = useBusy()
|
|
4983
|
+
// Channels may load after the form opens: adopt the default once, unless the user already chose.
|
|
4984
|
+
const channelTouched = useRef(Boolean(initial) || Boolean(form.channelId))
|
|
4985
|
+
useEffect(() => {
|
|
4986
|
+
if (channelTouched.current) return
|
|
4987
|
+
const def = channels.find(c => c.isDefault)
|
|
4988
|
+
if (def) { channelTouched.current = true; setForm(f => ({ ...f, channelId: def.id })) }
|
|
4989
|
+
}, [channels])
|
|
4770
4990
|
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
|
|
4771
4991
|
const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
|
|
4772
4992
|
const metrics = k.value?.metrics ?? []
|
|
4773
|
-
const save = async () => {
|
|
4993
|
+
const save = () => runBusy(async () => {
|
|
4774
4994
|
setError(null)
|
|
4775
4995
|
try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
|
|
4776
|
-
}
|
|
4996
|
+
})
|
|
4777
4997
|
const tryIt = async () => {
|
|
4778
4998
|
if (test?.busy) return
|
|
4779
4999
|
setTest({ busy: true })
|
|
@@ -4802,24 +5022,24 @@ window.__ModuleLoader__.load({
|
|
|
4802
5022
|
h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
|
|
4803
5023
|
h('label', { className: 'n2s-field' }, h('span', null, t('al.every')), h('select', { className: 'n2s-select', value: form.every, onChange: set('every') }, ['15m', 'hour', 'day'].map(v => h('option', { key: v, value: v }, t(`al.every.${v}`))))),
|
|
4804
5024
|
form.every === 'day' ? h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.at, onChange: set('at') })) : h('label', { className: 'n2s-field' }, h('span', null, t('al.cooldown')), h('input', { className: 'n2s-input', type: 'number', value: form.cooldownMin, onChange: set('cooldownMin') })),
|
|
4805
|
-
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
|
|
5025
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => { channelTouched.current = true; setForm(f => ({ ...f, channelId: v })) } })),
|
|
4806
5026
|
h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
|
|
4807
|
-
test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${test.triggered ? t('al.wouldFire') : t('al.wouldNotFire')}
|
|
5027
|
+
test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${colon(test.triggered ? t('al.wouldFire') : t('al.wouldNotFire'))}${test.message}`),
|
|
4808
5028
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
4809
5029
|
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
4810
5030
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
4811
5031
|
h('button', { type: 'button', className: 'n2s-btn', disabled: test?.busy, onClick: tryIt }, t('al.test')),
|
|
4812
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: save }, t('ds.save'))))
|
|
5032
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
4813
5033
|
}
|
|
4814
5034
|
|
|
4815
5035
|
function AlertsTab({ t, ds, datasources }) {
|
|
4816
5036
|
const list = useAsync(() => call('listAlerts'), [])
|
|
4817
|
-
const events = useAsync(() => call('listAlertEvents', {}), [list.value])
|
|
5037
|
+
const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
|
|
4818
5038
|
const [editing, setEditing] = useState(null)
|
|
4819
5039
|
const [notice, setNotice] = useState(null)
|
|
4820
5040
|
const act = async (fn, ok) => { setNotice(null); try { const r = await fn(); if (ok) setNotice({ ok: true, text: typeof ok === 'function' ? ok(r) : ok }) } catch (e) { setNotice({ ok: false, text: e.message }) } list.reload() }
|
|
4821
5041
|
const alerts = list.value ?? []
|
|
4822
|
-
const sched = useAsync(() => call('schedulerStatus'), [list.value])
|
|
5042
|
+
const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
|
|
4823
5043
|
const condText = (a) => a.condition.type === 'threshold' ? `${a.condition.op} ${a.condition.value}` : a.condition.type === 'change' ? `${t(`al.dir.${a.condition.direction ?? 'any'}`)} ≥ ${a.condition.pct}%` : a.condition.type === 'anomaly' ? `Z ≥ ${a.condition.z ?? 3}` : t('al.cond.missing')
|
|
4824
5044
|
return h('div', null,
|
|
4825
5045
|
h(SectionHead, { title: t('tab.alerts'), desc: t('al.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !ds, onClick: () => setEditing({}) }, '+ ', t('al.add'))),
|
|
@@ -4870,7 +5090,7 @@ window.__ModuleLoader__.load({
|
|
|
4870
5090
|
const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
|
|
4871
5091
|
return () => clearInterval(id)
|
|
4872
5092
|
}, [latest?.status, latest?.id])
|
|
4873
|
-
const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done])
|
|
5093
|
+
const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done], [open])
|
|
4874
5094
|
const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
|
|
4875
5095
|
const cases = set.value ?? []
|
|
4876
5096
|
return h('div', null,
|
|
@@ -4895,7 +5115,7 @@ window.__ModuleLoader__.load({
|
|
|
4895
5115
|
c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
|
|
4896
5116
|
h('div', { className: 'n2s-form', style: { marginTop: 6 } }, h('pre', { className: 'n2s-code' }, prettySql(c.expectedSql)), h('pre', { className: 'n2s-code' }, prettySql(c.actualSql))))))),
|
|
4897
5117
|
!open && cases.length > 0 && h('div', { className: 'n2s-list2', style: { marginTop: 12 } }, cases.map(c => h('div', { key: c.id, className: 'n2s-list2-row', style: { padding: '8px 16px' } },
|
|
4898
|
-
h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked })
|
|
5118
|
+
h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { try { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked }) } catch (err) { notifyError(err) } set.reload() } }),
|
|
4899
5119
|
h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
|
|
4900
5120
|
c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
|
|
4901
5121
|
}
|
|
@@ -4903,16 +5123,16 @@ window.__ModuleLoader__.load({
|
|
|
4903
5123
|
// ---------------------------------------------------- schema changes
|
|
4904
5124
|
|
|
4905
5125
|
function SchemaChanges({ t, ds }) {
|
|
4906
|
-
const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
|
|
5126
|
+
const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
|
|
4907
5127
|
const open = (list.value ?? []).filter(c => c.status === 'open')
|
|
4908
5128
|
if (!open.length) return null
|
|
4909
5129
|
const kindLabel = (k) => t(`sc.${k}`)
|
|
4910
5130
|
return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
|
|
4911
5131
|
open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
|
|
4912
5132
|
h('div', { className: 'n2s-row' }, h('strong', null, fmt(t, 'sc.title', { count: c.changes.length, time: new Date(c.at).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })), h('span', { className: 'n2s-grow' }),
|
|
4913
|
-
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id })
|
|
4914
|
-
h('div', { className: 'n2s-small', style: { marginTop: 4 } }, c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${x.detail
|
|
4915
|
-
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}
|
|
5133
|
+
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { try { await call('ackSchemaChange', { id: c.id }) } catch (e) { notifyError(e) } list.reload() } }, t('sc.ack'))),
|
|
5134
|
+
h('div', { className: 'n2s-small', style: { marginTop: 4 } }, joinClauses(c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${paren(x.detail)}`))),
|
|
5135
|
+
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), joinList(c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}${uiLang === 'zh' ? '' : ' '}${quoted(i.name)}`))))))
|
|
4916
5136
|
}
|
|
4917
5137
|
|
|
4918
5138
|
// -------------------------------------------------- knowledge io
|
|
@@ -4920,9 +5140,26 @@ window.__ModuleLoader__.load({
|
|
|
4920
5140
|
function KnowledgeIO({ t, ds, datasources, onImported }) {
|
|
4921
5141
|
const fileRef = useRef(null)
|
|
4922
5142
|
const [state, setState] = useState(null)
|
|
5143
|
+
const box = useRef(null)
|
|
5144
|
+
useFocusTrap(box, Boolean(state))
|
|
5145
|
+
// A dry run belongs to the data source it was computed for.
|
|
5146
|
+
useEffect(() => { setState(null) }, [ds?.id])
|
|
5147
|
+
useEffect(() => {
|
|
5148
|
+
if (!state) return
|
|
5149
|
+
const prev = document.activeElement
|
|
5150
|
+
setTimeout(() => box.current?.querySelector('button.primary, button')?.focus(), 0)
|
|
5151
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) setState(null) }
|
|
5152
|
+
document.addEventListener('keydown', onKey)
|
|
5153
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
5154
|
+
}, [Boolean(state)])
|
|
5155
|
+
const [busy, runBusy] = useBusy()
|
|
5156
|
+
const busyRef = useRef(false)
|
|
5157
|
+
busyRef.current = busy
|
|
4923
5158
|
const doExport = async (all) => {
|
|
4924
|
-
|
|
4925
|
-
|
|
5159
|
+
try {
|
|
5160
|
+
const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
|
|
5161
|
+
download(`knowledge-${all ? 'all' : safeName(ds.name)}-${new Date().toISOString().slice(0, 10)}.json`, new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
|
|
5162
|
+
} catch (e) { notifyError(e) }
|
|
4926
5163
|
}
|
|
4927
5164
|
const onFile = async (e) => {
|
|
4928
5165
|
const f = e.target.files?.[0]
|
|
@@ -4932,13 +5169,14 @@ window.__ModuleLoader__.load({
|
|
|
4932
5169
|
const text = await f.text()
|
|
4933
5170
|
const doc = JSON.parse(text)
|
|
4934
5171
|
const single = doc.datasources?.length === 1
|
|
4935
|
-
const
|
|
4936
|
-
|
|
5172
|
+
const dsId = ds.id
|
|
5173
|
+
const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? dsId : undefined, dryRun: true })
|
|
5174
|
+
setState({ doc, single, dry, dsId, dsName: ds.name })
|
|
4937
5175
|
} catch (err) { setState({ error: err.message }) }
|
|
4938
5176
|
}
|
|
4939
|
-
const apply = async (
|
|
4940
|
-
try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ?
|
|
4941
|
-
}
|
|
5177
|
+
const apply = (mode) => runBusy(async () => {
|
|
5178
|
+
try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ? state.dsId : undefined, mode }); setState({ done: r }); onImported() } catch (err) { setState({ error: err.message }) }
|
|
5179
|
+
})
|
|
4942
5180
|
return h(React.Fragment, null,
|
|
4943
5181
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
|
|
4944
5182
|
h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
|
|
@@ -4948,17 +5186,17 @@ window.__ModuleLoader__.load({
|
|
|
4948
5186
|
{ label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
|
|
4949
5187
|
] }),
|
|
4950
5188
|
state && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setState(null) } },
|
|
4951
|
-
h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true },
|
|
5189
|
+
h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('kb.import') },
|
|
4952
5190
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
|
|
4953
5191
|
state.error && h('div', { className: 'n2s-notice err' }, state.error),
|
|
4954
5192
|
state.dry && h(React.Fragment, null,
|
|
4955
|
-
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name:
|
|
5193
|
+
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name: state.dsName }) : t('kb.importByName')),
|
|
4956
5194
|
h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('kb.src'), t('kb.target'), t('kb.glossary'), t('kb.metrics'), t('kb.notes'), t('kb.examples'), t('kb.invalid')].map((x, i) => h('th', { key: i }, x)))),
|
|
4957
5195
|
h('tbody', null, state.dry.report.map((r, i) => h('tr', { key: i }, h('td', null, r.source), h('td', null, r.target ?? h('span', { style: { color: 'var(--n2s-err)' } }, r.skipped)), h('td', null, r.glossary ?? '—'), h('td', null, r.metrics ?? '—'), h('td', null, r.notes ?? '—'), h('td', null, r.examples ?? '—'), h('td', null, r.invalid ?? '—'))))),
|
|
4958
5196
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
4959
5197
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setState(null) }, t('ds.cancel')),
|
|
4960
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
|
|
4961
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => apply('merge') }, t('kb.merge')))),
|
|
5198
|
+
h('button', { type: 'button', className: 'n2s-btn danger', disabled: busy, onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
|
|
5199
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => apply('merge') }, busy ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge')))),
|
|
4962
5200
|
state.done && h(React.Fragment, null, h('div', { className: 'n2s-notice ok' }, t('kb.imported')), h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setState(null) }, t('ds.close')))))), document.body))
|
|
4963
5201
|
}
|
|
4964
5202
|
|
|
@@ -4981,7 +5219,7 @@ window.__ModuleLoader__.load({
|
|
|
4981
5219
|
if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
|
|
4982
5220
|
if (s.kind === 'metric') return h('div', null,
|
|
4983
5221
|
h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
|
|
4984
|
-
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions
|
|
5222
|
+
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${joinList(it.dimensions)}`].filter(Boolean).join(' · ')))
|
|
4985
5223
|
if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
|
|
4986
5224
|
return h('div', null, h('div', { className: 'n2s-small' }, it.definition), it.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 4 } }, it.sql))
|
|
4987
5225
|
}
|
|
@@ -4999,14 +5237,17 @@ window.__ModuleLoader__.load({
|
|
|
4999
5237
|
const [busy, setBusy] = useState(null)
|
|
5000
5238
|
const items = list.value?.suggestions ?? []
|
|
5001
5239
|
const done = () => { list.reload(); onChanged?.() }
|
|
5240
|
+
const inFlight = useRef(new Set())
|
|
5002
5241
|
const accept = async (s, item) => {
|
|
5242
|
+
if (inFlight.current.has(s.id)) return
|
|
5243
|
+
inFlight.current.add(s.id)
|
|
5003
5244
|
setBusy(s.id)
|
|
5004
5245
|
try {
|
|
5005
5246
|
await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
|
|
5006
5247
|
setEditing(null)
|
|
5007
5248
|
notify(t('sg.accepted'), 'ok')
|
|
5008
5249
|
done()
|
|
5009
|
-
} catch (e) { notifyError(e) } finally { setBusy(null) }
|
|
5250
|
+
} catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
|
|
5010
5251
|
}
|
|
5011
5252
|
const dismiss = async (s) => {
|
|
5012
5253
|
if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
|
|
@@ -5029,8 +5270,8 @@ window.__ModuleLoader__.load({
|
|
|
5029
5270
|
h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
|
|
5030
5271
|
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
|
|
5031
5272
|
h(Dropdown, { label: t('sg.acceptDirect'), trigger: { className: 'n2s-iconbtn', content: Icon.chev(10) }, items: [
|
|
5032
|
-
{ label: t('sg.acceptEdit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
|
|
5033
|
-
{ label: t('sg.acceptDirect'), icon: Icon.check(14), onClick: () => accept(s) },
|
|
5273
|
+
{ label: t('sg.acceptEdit'), icon: Icon.edit(14), disabled: busy === s.id, onClick: () => setEditing(s) },
|
|
5274
|
+
{ label: t('sg.acceptDirect'), icon: Icon.check(14), disabled: busy === s.id, onClick: () => accept(s) },
|
|
5034
5275
|
] }),
|
|
5035
5276
|
h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
|
|
5036
5277
|
editing?.id === s.id && h('div', { style: { marginTop: 10 } },
|
|
@@ -5119,10 +5360,10 @@ window.__ModuleLoader__.load({
|
|
|
5119
5360
|
seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
|
|
5120
5361
|
items.length === 0
|
|
5121
5362
|
? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
|
|
5122
|
-
: h('div', { className: 'n2s-pickchips', role: '
|
|
5363
|
+
: h('div', { className: 'n2s-pickchips', role: 'group', 'aria-label': kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people') }, items.slice(0, 30).map(x => {
|
|
5123
5364
|
const taken = x.memberId && !isChosen(x)
|
|
5124
5365
|
return h('button', {
|
|
5125
|
-
key: `${x.platform}:${x.id}`, type: 'button',
|
|
5366
|
+
key: `${x.platform}:${x.id}`, type: 'button', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
|
|
5126
5367
|
title: [`${platformName(t, x.platform, catalog)} · ${x.id}`, x.email, x.via ? fmt(t, 'mb.pick.via', { name: x.via }) : '', taken ? fmt(t, 'mb.pick.taken', { name: x.memberName }) : '', x.refused ? t('mb.pick.refused') : ''].filter(Boolean).join('\n'),
|
|
5127
5368
|
onClick: () => onPick(x),
|
|
5128
5369
|
},
|
|
@@ -5300,8 +5541,8 @@ window.__ModuleLoader__.load({
|
|
|
5300
5541
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
|
|
5301
5542
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
5302
5543
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, u.name), h('span', { className: 'n2s-badge' }, t(`mb.role.${u.role}`)), u.disabled && h('span', { className: 'n2s-badge err' }, t('mb.disabled'))),
|
|
5303
|
-
h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(
|
|
5304
|
-
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`)
|
|
5544
|
+
h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(listSep()) || t('mb.noDs'))),
|
|
5545
|
+
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${joinClauses(u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`))}`)),
|
|
5305
5546
|
h('div', { className: 'n2s-list2-side' }, h('span', { className: 'n2s-small' }, us ? fmt(t, 'mb.queries', { n: us.queries }) : t('mb.noUsage')), us?.lastAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(us.lastAt).toLocaleDateString())),
|
|
5306
5547
|
h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
|
|
5307
5548
|
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
|
|
@@ -5319,7 +5560,7 @@ window.__ModuleLoader__.load({
|
|
|
5319
5560
|
// ---------------------------------------------------- dashboards bar
|
|
5320
5561
|
|
|
5321
5562
|
function DashboardSwitcher({ t, value, onChange }) {
|
|
5322
|
-
const list = useAsync(() => call('listDashboards'), [value?.id])
|
|
5563
|
+
const list = useAsync(() => call('listDashboards'), [value?.id], [])
|
|
5323
5564
|
const v = list.value
|
|
5324
5565
|
if (!v) return null
|
|
5325
5566
|
const cur = v.dashboards.find(d => d.id === v.current)
|
|
@@ -5328,7 +5569,7 @@ window.__ModuleLoader__.load({
|
|
|
5328
5569
|
v.dashboards.map(d => h('button', { key: d.id, type: 'button', role: 'tab', 'aria-selected': d.id === v.current, onClick: () => act(() => call('selectDashboard', { id: d.id })) }, d.name, h('span', { className: 'count' }, d.widgets))),
|
|
5329
5570
|
h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
|
|
5330
5571
|
{ label: t('dash.new'), icon: Icon.chart(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName') }); if (name) act(() => call('saveDashboard', { name })) } },
|
|
5331
|
-
{ label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value:
|
|
5572
|
+
{ label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value: fmt(t, 'dash.copyName', { name: cur?.name ?? '' }) }); if (name) act(() => call('saveDashboard', { name, copyFrom: v.current })) } },
|
|
5332
5573
|
{ label: t('dash.renameBoard'), icon: Icon.edit(14), onClick: async () => { const name = await promptDialog({ title: t('dash.renameBoard'), value: cur?.name }); if (name) act(() => call('saveDashboard', { id: v.current, name })) } },
|
|
5333
5574
|
{ divider: true },
|
|
5334
5575
|
v.dashboards.length > 1 && { label: t('dash.deleteBoard'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'dash.deleteBoardConfirm', { name: cur?.name }), danger: true })) act(() => call('deleteDashboard', { id: v.current })) } },
|
|
@@ -5340,7 +5581,7 @@ window.__ModuleLoader__.load({
|
|
|
5340
5581
|
const v = st.value
|
|
5341
5582
|
return h('div', { className: 'n2s-row' },
|
|
5342
5583
|
h('span', { className: 'n2s-small n2s-muted' }, v ? fmt(t, 'settings.cacheStatsText', { results: v.results, mb: (v.bytes / 1048576).toFixed(1), cache: v.cacheEntries }) : '…'),
|
|
5343
|
-
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { await call('clearCache')
|
|
5584
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { try { await call('clearCache') } catch (e) { notifyError(e) } st.reload() } }, t('settings.clearCache')))
|
|
5344
5585
|
}
|
|
5345
5586
|
|
|
5346
5587
|
function ChannelSelect({ t, value, onChange }) {
|
|
@@ -5348,8 +5589,25 @@ window.__ModuleLoader__.load({
|
|
|
5348
5589
|
}
|
|
5349
5590
|
|
|
5350
5591
|
// Questions started from the page bind their new session to the chosen
|
|
5351
|
-
// data source as soon as the session appears in the composer.
|
|
5592
|
+
// data source as soon as the session appears in the composer. Only the
|
|
5593
|
+
// session our own startSession created may claim an ask: the one whose id
|
|
5594
|
+
// startSession returned (when the host returns it), otherwise a session the
|
|
5595
|
+
// composer had not seen before the ask was made, within PENDING_ASK_MS.
|
|
5352
5596
|
const pendingAsks = []
|
|
5597
|
+
const seenSessions = new Set()
|
|
5598
|
+
const PENDING_ASK_MS = 60000
|
|
5599
|
+
function addPendingAsk(datasourceId, question) {
|
|
5600
|
+
const pending = { datasourceId, question, at: Date.now(), seen: new Set(seenSessions), sessionId: null }
|
|
5601
|
+
pendingAsks.push(pending)
|
|
5602
|
+
setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, PENDING_ASK_MS)
|
|
5603
|
+
return pending
|
|
5604
|
+
}
|
|
5605
|
+
/** Remove and return the pending ask that belongs to this session, if any. */
|
|
5606
|
+
function claimPendingAsk(sessionId) {
|
|
5607
|
+
const now = Date.now()
|
|
5608
|
+
const i = pendingAsks.findIndex(p => now - p.at < PENDING_ASK_MS && (p.sessionId ? p.sessionId === sessionId : !p.seen.has(sessionId)))
|
|
5609
|
+
return i >= 0 ? pendingAsks.splice(i, 1)[0] : null
|
|
5610
|
+
}
|
|
5353
5611
|
|
|
5354
5612
|
// ------------------------------------------------ composer selector
|
|
5355
5613
|
|
|
@@ -5366,8 +5624,15 @@ window.__ModuleLoader__.load({
|
|
|
5366
5624
|
if (!sessionId) return
|
|
5367
5625
|
let cancelled = false
|
|
5368
5626
|
;(async () => {
|
|
5369
|
-
const
|
|
5370
|
-
|
|
5627
|
+
const fresh = pendingAsks.length > 0 && !seenSessions.has(sessionId)
|
|
5628
|
+
seenSessions.add(sessionId)
|
|
5629
|
+
if (fresh) {
|
|
5630
|
+
// A session already bound to a data source, or running another preset, is not ours.
|
|
5631
|
+
const info = await call('sessionInfo', { sessionId }).catch(() => null)
|
|
5632
|
+
const candidate = info && !info.boundDatasourceId && (info.preset === PRESET || info.preset === null || info.preset === undefined)
|
|
5633
|
+
const pending = candidate ? claimPendingAsk(sessionId) : null
|
|
5634
|
+
if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
|
|
5635
|
+
}
|
|
5371
5636
|
if (!cancelled) await loadSessionInfo(sessionId)
|
|
5372
5637
|
})()
|
|
5373
5638
|
if (!store.get().loaded) refreshDatasources()
|
|
@@ -5519,7 +5784,7 @@ window.__ModuleLoader__.load({
|
|
|
5519
5784
|
}
|
|
5520
5785
|
|
|
5521
5786
|
function AdminArea({ t, ctx, section, sub, onNavigate, ds, datasources }) {
|
|
5522
|
-
const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section])
|
|
5787
|
+
const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section], [])
|
|
5523
5788
|
const [alerts, reports, channels, users] = counts.value ?? []
|
|
5524
5789
|
const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
|
|
5525
5790
|
const firing = (alerts ?? []).filter(a => a.state === 'firing').length
|
|
@@ -5551,32 +5816,49 @@ window.__ModuleLoader__.load({
|
|
|
5551
5816
|
const { datasources, loaded, error } = useStore(s => s)
|
|
5552
5817
|
const [nav, setNavState] = useState(loadNav)
|
|
5553
5818
|
const setNav = useCallback((patch) => setNavState(n => { const next = { ...n, ...patch }; try { localStorage.setItem(NAV_KEY, JSON.stringify(next)) } catch {} return next }), [])
|
|
5819
|
+
// Leaving 管理 → 设置 with unsaved settings asks first (SettingsPage sets fbCtx.settingsDirty).
|
|
5820
|
+
const navRef = useRef(nav)
|
|
5821
|
+
navRef.current = nav
|
|
5822
|
+
const guardedNav = useCallback(async (patch) => {
|
|
5823
|
+
const cur = navRef.current
|
|
5824
|
+
const next = { ...cur, ...patch }
|
|
5825
|
+
const inSettings = cur.tab === 'admin' && cur.section === 'settings'
|
|
5826
|
+
const leaving = inSettings && (next.tab !== 'admin' || next.section !== 'settings')
|
|
5827
|
+
if (leaving && fbCtx.settingsDirty) {
|
|
5828
|
+
const ok = await confirmDialog({ title: t('set.leaveTitle'), message: t('set.leaveMsg'), confirmLabel: t('set.leaveOk'), danger: true })
|
|
5829
|
+
if (!ok) return false
|
|
5830
|
+
fbCtx.settingsDirty = false
|
|
5831
|
+
}
|
|
5832
|
+
setNav(patch)
|
|
5833
|
+
return true
|
|
5834
|
+
}, [])
|
|
5554
5835
|
const [selectedId, setSelectedId] = useState(null)
|
|
5555
5836
|
useEffect(() => { refreshDatasources() }, [])
|
|
5556
5837
|
const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
|
|
5557
5838
|
const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
|
|
5558
5839
|
const ask = useCallback((question) => {
|
|
5559
5840
|
if (!ds) return
|
|
5560
|
-
const pending =
|
|
5561
|
-
pendingAsks.push(pending)
|
|
5562
|
-
setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, 60000)
|
|
5841
|
+
const pending = addPendingAsk(ds.id, question)
|
|
5563
5842
|
try {
|
|
5564
|
-
ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
|
|
5843
|
+
const r = ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
|
|
5844
|
+
// Hosts that return the new session id (directly or as a promise) pin the ask to it.
|
|
5845
|
+
const adopt = (id) => { if (typeof id === 'string' && id) pending.sessionId = id }
|
|
5846
|
+
if (r && typeof r.then === 'function') r.then(adopt, () => {}); else adopt(r)
|
|
5565
5847
|
} catch (e) {
|
|
5848
|
+
const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
|
|
5566
5849
|
notifyError(e)
|
|
5567
5850
|
}
|
|
5568
5851
|
}, [ds?.id])
|
|
5569
5852
|
fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
|
|
5570
|
-
fbCtx.navigate = (patch) =>
|
|
5853
|
+
fbCtx.navigate = (patch) => { guardedNav(patch) }
|
|
5571
5854
|
const tabs = [...PRIMARY_TABS, 'admin']
|
|
5572
5855
|
const onTabKey = (e) => {
|
|
5573
5856
|
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
|
5574
5857
|
const i = tabs.indexOf(nav.tab)
|
|
5575
5858
|
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]
|
|
5576
|
-
|
|
5577
|
-
setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0)
|
|
5859
|
+
guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
|
|
5578
5860
|
}
|
|
5579
|
-
const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () =>
|
|
5861
|
+
const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () => { guardedNav({ tab: k }) } },
|
|
5580
5862
|
k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
|
|
5581
5863
|
const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
|
|
5582
5864
|
const showPicker = datasources.length > 0 && nav.tab !== 'admin'
|
|
@@ -5591,20 +5873,19 @@ window.__ModuleLoader__.load({
|
|
|
5591
5873
|
h('span', { className: 'name' }, ds?.name ?? '—'),
|
|
5592
5874
|
ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
|
|
5593
5875
|
Icon.chev(10),
|
|
5594
|
-
h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); await call('setCurrent', { id })
|
|
5876
|
+
h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); try { await call('setCurrent', { id }) } catch (err) { notifyError(err) } refreshDatasources() } },
|
|
5595
5877
|
datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
|
|
5596
5878
|
h('div', { className: 'n2s-tabs', role: 'tablist', 'aria-label': t('page.title'), onKeyDown: onTabKey }, PRIMARY_TABS.map(tabBtn), h('span', { className: 'n2s-grow' }), tabBtn('admin'))),
|
|
5597
5879
|
h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
|
|
5598
5880
|
error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
|
|
5599
5881
|
!loaded ? h('div', { style: { display: 'grid', gap: 12 } }, h('div', { className: 'n2s-skel', style: { height: 120 } }), h('div', { className: 'n2s-skel', style: { height: 60 } })) :
|
|
5600
|
-
needsSource ? h(EmptySourceHint, { t, onGo: () =>
|
|
5601
|
-
nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate:
|
|
5602
|
-
nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment
|
|
5603
|
-
nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
|
|
5882
|
+
needsSource ? h(EmptySourceHint, { t, onGo: () => guardedNav({ tab: 'admin', section: 'sources' }) }) :
|
|
5883
|
+
nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: guardedNav }) :
|
|
5884
|
+
nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: paren(tb.comment) })) }) :
|
|
5885
|
+
nav.tab === 'knowledge' ? h(KnowledgeTab, { key: `kb:${ds?.id ?? ''}`, t, ds }) :
|
|
5604
5886
|
nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
|
|
5605
5887
|
nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
|
|
5606
|
-
h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate:
|
|
5607
|
-
h(FeedbackLayer, { t })))
|
|
5888
|
+
h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
|
|
5608
5889
|
}
|
|
5609
5890
|
}
|
|
5610
5891
|
|
|
@@ -5706,7 +5987,7 @@ window.__ModuleLoader__.load({
|
|
|
5706
5987
|
form.resultToModel === 'rows' && h(SettingRow, { label: t('settings.modelRows'), hint: t('set.h.modelRows'), changed: changed('modelRows'), htmlFor: 'set-modelRows' }, num('modelRows', { min: 0, max: 500 })),
|
|
5707
5988
|
h(SettingRow, { label: t('set.l.mask'), hint: t('set.h.maskSensitive'), changed: changed('maskSensitive') }, h('label', { className: 'n2s-switch' }, h('input', { type: 'checkbox', role: 'switch', checked: Boolean(form.maskSensitive), onChange: e => set('maskSensitive', e.target.checked) }), h('span', null, form.maskSensitive ? t('set.on') : t('set.off')))),
|
|
5708
5989
|
h('div', { className: 'n2s-setnote' }, overrides.length
|
|
5709
|
-
? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 &&
|
|
5990
|
+
? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 && listSep(), h('button', { type: 'button', className: 'n2s-link', onClick: () => onNavigate({ section: 'sources' }) }, d.name))))
|
|
5710
5991
|
: t('set.noOverrides'))) :
|
|
5711
5992
|
group === 'query' ? h(React.Fragment, null,
|
|
5712
5993
|
h(SettingRow, { label: t('set.l.maxRows'), hint: t('set.h.maxRows'), changed: changed('maxRows'), htmlFor: 'set-maxRows' }, num('maxRows', { min: 1, max: 100000 })),
|
|
@@ -5810,6 +6091,17 @@ window.__ModuleLoader__.load({
|
|
|
5810
6091
|
const retention = form.retention ?? defaults.retention ?? {}
|
|
5811
6092
|
const fileRef = useRef(null)
|
|
5812
6093
|
const [importState, setImportState] = useState(null)
|
|
6094
|
+
const importBox = useRef(null)
|
|
6095
|
+
useFocusTrap(importBox, Boolean(importState))
|
|
6096
|
+
const [importing, runImport] = useBusy()
|
|
6097
|
+
useEffect(() => {
|
|
6098
|
+
if (!importState) return
|
|
6099
|
+
const prev = document.activeElement
|
|
6100
|
+
setTimeout(() => importBox.current?.querySelector('button.primary')?.focus(), 0)
|
|
6101
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) setImportState(null) }
|
|
6102
|
+
document.addEventListener('keydown', onKey)
|
|
6103
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
6104
|
+
}, [Boolean(importState)])
|
|
5813
6105
|
const exportAll = async () => {
|
|
5814
6106
|
try {
|
|
5815
6107
|
const doc = await call('exportConfig')
|
|
@@ -5827,7 +6119,7 @@ window.__ModuleLoader__.load({
|
|
|
5827
6119
|
setImportState({ doc, dry })
|
|
5828
6120
|
} catch (err) { notifyError(err) }
|
|
5829
6121
|
}
|
|
5830
|
-
const doImport = async (
|
|
6122
|
+
const doImport = (mode) => runImport(async () => {
|
|
5831
6123
|
if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
|
|
5832
6124
|
try {
|
|
5833
6125
|
const r = await call('importConfig', { data: importState.doc, mode })
|
|
@@ -5837,7 +6129,7 @@ window.__ModuleLoader__.load({
|
|
|
5837
6129
|
const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
|
|
5838
6130
|
notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
|
|
5839
6131
|
} catch (err) { notifyError(err) }
|
|
5840
|
-
}
|
|
6132
|
+
})
|
|
5841
6133
|
const purge = async (kind) => {
|
|
5842
6134
|
if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
|
|
5843
6135
|
try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
|
|
@@ -5863,17 +6155,17 @@ window.__ModuleLoader__.load({
|
|
|
5863
6155
|
h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
|
|
5864
6156
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
|
|
5865
6157
|
importState && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setImportState(null) } },
|
|
5866
|
-
h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import')
|
|
6158
|
+
h('div', { ref: importBox, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import') },
|
|
5867
6159
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
|
|
5868
6160
|
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 10 } }, fmt(t, 'set.importFrom', { time: importState.doc.exportedAt ? new Date(importState.doc.exportedAt).toLocaleString() : '?' })),
|
|
5869
6161
|
h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('set.section'), t('set.toAdd'), t('set.toUpdate'), t('set.note')].map((x, i) => h('th', { key: i }, x)))),
|
|
5870
6162
|
h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
|
|
5871
6163
|
h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
|
|
5872
|
-
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret
|
|
6164
|
+
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, joinClauses([r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: joinList(r.needsSecret) }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''])))))),
|
|
5873
6165
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
5874
6166
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
|
|
5875
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
|
|
5876
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => doImport('merge') }, t('kb.merge'))))), document.body))
|
|
6167
|
+
h('button', { type: 'button', className: 'n2s-btn danger', disabled: importing, onClick: () => doImport('replace') }, t('kb.replace')),
|
|
6168
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: importing, onClick: () => doImport('merge') }, importing ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge'))))), document.body))
|
|
5877
6169
|
}
|
|
5878
6170
|
|
|
5879
6171
|
function AboutSection({ t }) {
|
|
@@ -5902,6 +6194,9 @@ window.__ModuleLoader__.load({
|
|
|
5902
6194
|
}, 'nl2sql: styles')
|
|
5903
6195
|
ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
|
|
5904
6196
|
const t = ctx.locale.bind(NS)
|
|
6197
|
+
// One feedback layer (toasts + confirm / prompt dialogs) for the page and every
|
|
6198
|
+
// tool view or reply action in conversations, mounted once outside the page.
|
|
6199
|
+
ctx.effect(() => mountFeedbackLayer(ctx), 'nl2sql: feedback layer')
|
|
5905
6200
|
const Page = makePage(ctx)
|
|
5906
6201
|
const PanelIcon = ({ size }) => Icon.db(size ?? 16)
|
|
5907
6202
|
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))
|