@hedeqiang/dsh-nl2sql 0.12.0 → 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 +28 -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 +450 -154
- 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
|
|
@@ -1732,8 +1752,10 @@ window.__ModuleLoader__.load({
|
|
|
1732
1752
|
.n2s-hist{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
|
|
1733
1753
|
.n2s-hist-row{border-bottom:.5px solid var(--n2s-line)}
|
|
1734
1754
|
.n2s-hist-row:last-child{border-bottom:0}
|
|
1735
|
-
.n2s-hist-
|
|
1736
|
-
.n2s-hist-
|
|
1755
|
+
.n2s-hist-head{display:flex;align-items:center}
|
|
1756
|
+
.n2s-hist-head:hover{background:var(--n2s-hover)}
|
|
1757
|
+
.n2s-hist-head .n2s-rowdel{flex:none;margin-right:10px}
|
|
1758
|
+
.n2s-hist-main{display:grid;grid-template-columns:76px minmax(0,1fr) 150px 84px 20px;align-items:center;gap:14px;flex:1;min-width:0;width:100%;padding:11px 6px 11px 16px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
1737
1759
|
.n2s-hist-main:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:-2px}
|
|
1738
1760
|
.n2s-hist-what{display:grid;gap:2px;min-width:0}
|
|
1739
1761
|
.n2s-hist-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -1965,8 +1987,6 @@ window.__ModuleLoader__.load({
|
|
|
1965
1987
|
.n2s-recent-row:hover .n2s-recent-del,.n2s-recent-del:focus-visible,.n2s-hist-row:hover .n2s-rowdel,.n2s-rowdel:focus-visible{opacity:1}
|
|
1966
1988
|
.n2s-recent-del:hover,.n2s-rowdel:hover{color:var(--n2s-err)}
|
|
1967
1989
|
@media (hover:none){.n2s-recent-del,.n2s-rowdel{opacity:1}}
|
|
1968
|
-
.n2s-hist-row.has-del{position:relative}
|
|
1969
|
-
.n2s-rowdel{position:absolute;top:10px;right:40px;z-index:1}
|
|
1970
1990
|
.n2s-morebtn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;height:36px;margin-top:6px;border:.5px dashed var(--n2s-line-2);border-radius:var(--n2s-radius-sm);background:transparent;color:var(--n2s-muted);font:inherit;font-size:13px;cursor:pointer}
|
|
1971
1991
|
.n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
|
|
1972
1992
|
.n2s-btn.danger-text{color:var(--n2s-err)}
|
|
@@ -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,30 +3556,31 @@ 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 } }),
|
|
3425
3563
|
!list.loading && rows.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('hist.empty')), h('div', null, t('hist.emptyHint'))),
|
|
3426
3564
|
rows.length > 0 && h('div', { className: 'n2s-hist', role: 'list' }, rows.map(r => {
|
|
3427
3565
|
const isOpen = open === r.id
|
|
3428
|
-
return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row
|
|
3429
|
-
h('
|
|
3566
|
+
return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row', role: 'listitem' },
|
|
3567
|
+
h('div', { className: 'n2s-hist-head' },
|
|
3430
3568
|
h('button', { type: 'button', className: 'n2s-hist-main', 'aria-expanded': isOpen, onClick: () => setOpen(isOpen ? null : r.id) },
|
|
3431
3569
|
statusDot(r.status),
|
|
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))),
|
|
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))),
|
|
3438
3577
|
isOpen && h('div', { className: 'n2s-hist-detail' },
|
|
3439
3578
|
r.error && h('div', { className: `n2s-notice ${r.status === 'blocked' ? 'warn' : 'err'}` }, r.error),
|
|
3440
3579
|
h('div', { style: { position: 'relative' } },
|
|
3441
3580
|
h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
|
|
3442
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))),
|
|
3443
3582
|
h('div', { className: 'n2s-row' },
|
|
3444
|
-
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())),
|
|
3445
3584
|
r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
|
|
3446
3585
|
h('button', {
|
|
3447
3586
|
type: 'button', className: 'n2s-btn small',
|
|
@@ -3454,6 +3593,15 @@ window.__ModuleLoader__.load({
|
|
|
3454
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 })))))
|
|
3455
3594
|
})))
|
|
3456
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
|
+
}
|
|
3457
3605
|
function OverviewGrid({ t, results, pinned }) {
|
|
3458
3606
|
const [sending, setSending] = useState(false)
|
|
3459
3607
|
const ok = results.filter(r => !r.error && r.resultId)
|
|
@@ -3531,7 +3679,7 @@ window.__ModuleLoader__.load({
|
|
|
3531
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' })])),
|
|
3532
3680
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
3533
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)),
|
|
3534
|
-
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')}`)),
|
|
3535
3683
|
h('div', { className: 'n2s-list2-side' },
|
|
3536
3684
|
statusDot(r.status),
|
|
3537
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' })}` : '')),
|
|
@@ -3547,25 +3695,32 @@ window.__ModuleLoader__.load({
|
|
|
3547
3695
|
}
|
|
3548
3696
|
function FeedbackForm({ t, onCancel, onSubmit }) {
|
|
3549
3697
|
const [comment, setComment] = useState('')
|
|
3698
|
+
const [busy, runBusy] = useBusy()
|
|
3550
3699
|
const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
|
|
3551
3700
|
return h('div', { className: 'n2s-card', style: { padding: 10 } },
|
|
3552
3701
|
h('div', { className: 'n2s-small', style: { marginBottom: 6 } }, t('fb.whatWrong')),
|
|
3553
|
-
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)))),
|
|
3554
3703
|
h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
|
|
3555
3704
|
h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
|
|
3556
3705
|
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: onCancel }, t('ds.cancel')),
|
|
3557
|
-
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'))))
|
|
3558
3707
|
}
|
|
3559
3708
|
|
|
3560
3709
|
function FeedbackSection({ t, ds }) {
|
|
3561
3710
|
const [status, setStatus] = useState('open')
|
|
3562
|
-
const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status])
|
|
3563
|
-
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])
|
|
3564
3713
|
const [fixing, setFixing] = useState(null)
|
|
3565
3714
|
const [error, setError] = useState(null)
|
|
3566
|
-
const
|
|
3715
|
+
const [busy, runBusy] = useBusy()
|
|
3716
|
+
const resolve = (payload) => runBusy(async () => {
|
|
3567
3717
|
setError(null)
|
|
3568
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()
|
|
3569
3724
|
}
|
|
3570
3725
|
const st = stats.value
|
|
3571
3726
|
return h('div', null,
|
|
@@ -3587,13 +3742,13 @@ window.__ModuleLoader__.load({
|
|
|
3587
3742
|
h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
|
|
3588
3743
|
f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
|
|
3589
3744
|
h('pre', { className: 'n2s-code', style: { marginTop: 6 } }, f.correctedSql || f.sql),
|
|
3590
|
-
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}`)),
|
|
3591
3746
|
h('div', { style: { display: 'grid', gap: 4 } },
|
|
3592
3747
|
f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
|
|
3593
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')),
|
|
3594
3749
|
f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
|
|
3595
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')),
|
|
3596
|
-
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')))),
|
|
3597
3752
|
fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
|
|
3598
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 })) })),
|
|
3599
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 })) })),
|
|
@@ -3639,10 +3794,16 @@ window.__ModuleLoader__.load({
|
|
|
3639
3794
|
area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
|
|
3640
3795
|
h('div', null,
|
|
3641
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')),
|
|
3642
|
-
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 => {
|
|
3643
3798
|
const ok = allowed.includes(tb)
|
|
3644
|
-
|
|
3645
|
-
|
|
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))
|
|
3646
3807
|
})),
|
|
3647
3808
|
form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
|
|
3648
3809
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
@@ -3661,8 +3822,16 @@ window.__ModuleLoader__.load({
|
|
|
3661
3822
|
const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
|
|
3662
3823
|
onChange([...(next ? [next] : []), ...ins])
|
|
3663
3824
|
}
|
|
3664
|
-
|
|
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
|
+
}
|
|
3665
3833
|
const c = cand.value ?? { time: [], dims: [] }
|
|
3834
|
+
const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
|
|
3666
3835
|
return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
|
|
3667
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' })])),
|
|
3668
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') },
|
|
@@ -3679,7 +3848,7 @@ window.__ModuleLoader__.load({
|
|
|
3679
3848
|
h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
|
|
3680
3849
|
dim?.values?.length
|
|
3681
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)))
|
|
3682
|
-
: h(
|
|
3851
|
+
: h(FilterValuesInput, { key: f.column, t, label: dim?.label ?? f.column, values: f.values, onCommit: (values) => setIn(f.column, values, true) }),
|
|
3683
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)))
|
|
3684
3853
|
}),
|
|
3685
3854
|
c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
|
|
@@ -3688,9 +3857,39 @@ window.__ModuleLoader__.load({
|
|
|
3688
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) ?? []) })),
|
|
3689
3858
|
}),
|
|
3690
3859
|
h('span', { className: 'n2s-grow' }),
|
|
3691
|
-
|
|
3860
|
+
activeN > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: activeN })),
|
|
3692
3861
|
filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
|
|
3693
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
|
+
}
|
|
3694
3893
|
// ------------------------------------------------------------ IM push
|
|
3695
3894
|
|
|
3696
3895
|
function makeReplyAction(ctx) {
|
|
@@ -4072,6 +4271,11 @@ window.__ModuleLoader__.load({
|
|
|
4072
4271
|
}, [])
|
|
4073
4272
|
const [state, setState] = useState(null)
|
|
4074
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)
|
|
4075
4279
|
useEffect(() => {
|
|
4076
4280
|
const def = channels.find(c => c.isDefault) ?? channels[0]
|
|
4077
4281
|
if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
|
|
@@ -4079,11 +4283,14 @@ window.__ModuleLoader__.load({
|
|
|
4079
4283
|
useEffect(() => {
|
|
4080
4284
|
const prev = document.activeElement
|
|
4081
4285
|
ref.current?.querySelector('select, textarea, input, button')?.focus()
|
|
4082
|
-
|
|
4286
|
+
// Escape belongs to a confirm/prompt dialog on top, if any.
|
|
4287
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) onClose() }
|
|
4083
4288
|
document.addEventListener('keydown', onKey)
|
|
4084
4289
|
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
4085
4290
|
}, [])
|
|
4086
4291
|
const send = async () => {
|
|
4292
|
+
if (busyRef.current) return
|
|
4293
|
+
busyRef.current = true
|
|
4087
4294
|
setState({ busy: true })
|
|
4088
4295
|
try {
|
|
4089
4296
|
const res = initial.dashboard
|
|
@@ -4091,7 +4298,7 @@ window.__ModuleLoader__.load({
|
|
|
4091
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 })
|
|
4092
4299
|
setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
|
|
4093
4300
|
refreshChannels()
|
|
4094
|
-
setTimeout(onClose, 1200)
|
|
4301
|
+
closeTimer.current = setTimeout(onClose, 1200)
|
|
4095
4302
|
} catch (e) {
|
|
4096
4303
|
setState({ error: e.message })
|
|
4097
4304
|
}
|
|
@@ -4158,6 +4365,7 @@ window.__ModuleLoader__.load({
|
|
|
4158
4365
|
/** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
|
|
4159
4366
|
function Modal({ label, labelledBy, onClose, width, children, className }) {
|
|
4160
4367
|
const box = useRef(null)
|
|
4368
|
+
useFocusTrap(box)
|
|
4161
4369
|
useEffect(() => {
|
|
4162
4370
|
const prev = document.activeElement
|
|
4163
4371
|
setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
|
|
@@ -4183,13 +4391,13 @@ window.__ModuleLoader__.load({
|
|
|
4183
4391
|
h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
|
|
4184
4392
|
p && h(React.Fragment, null,
|
|
4185
4393
|
h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
|
|
4186
|
-
h('
|
|
4187
|
-
(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) },
|
|
4188
4396
|
h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
|
|
4189
4397
|
k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
|
|
4190
4398
|
h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
|
|
4191
4399
|
h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
|
|
4192
|
-
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')))))))))
|
|
4193
4401
|
}
|
|
4194
4402
|
|
|
4195
4403
|
// ---------------------------------------------------------------- step 2
|
|
@@ -4426,7 +4634,7 @@ window.__ModuleLoader__.load({
|
|
|
4426
4634
|
|
|
4427
4635
|
// ----------------------------------------------------------------- list
|
|
4428
4636
|
|
|
4429
|
-
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' })
|
|
4430
4638
|
|
|
4431
4639
|
function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
|
|
4432
4640
|
const kind = findKind(catalog, c.type)?.kind
|
|
@@ -4490,7 +4698,7 @@ window.__ModuleLoader__.load({
|
|
|
4490
4698
|
const channels = useChannels()
|
|
4491
4699
|
const bots = useBots()
|
|
4492
4700
|
const { catalog } = useCatalog(t)
|
|
4493
|
-
const deliveries = useAsync(() => call('listDeliveries'), [channels])
|
|
4701
|
+
const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
|
|
4494
4702
|
const [editing, setEditing] = useState(null)
|
|
4495
4703
|
// Pick up host upgrades (catalog, new channel shape) whenever the page opens.
|
|
4496
4704
|
useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
|
|
@@ -4616,7 +4824,12 @@ window.__ModuleLoader__.load({
|
|
|
4616
4824
|
useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
|
|
4617
4825
|
const filtersReady = filters !== null && filtersFor === boardId
|
|
4618
4826
|
const [sendingDash, setSendingDash] = useState(false)
|
|
4619
|
-
|
|
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
|
+
}
|
|
4620
4833
|
const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
|
|
4621
4834
|
// Per-widget load status → header summary ("updated 10:42 · 2 from cache").
|
|
4622
4835
|
const [loads, setLoads] = useState({})
|
|
@@ -4679,7 +4892,7 @@ window.__ModuleLoader__.load({
|
|
|
4679
4892
|
h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
|
|
4680
4893
|
(mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
|
|
4681
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 ?? '' }))))),
|
|
4682
|
-
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'))) })),
|
|
4683
4896
|
h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
|
|
4684
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' }) }) : '',
|
|
4685
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,
|
|
@@ -4700,7 +4913,7 @@ window.__ModuleLoader__.load({
|
|
|
4700
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'))),
|
|
4701
4914
|
widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
|
|
4702
4915
|
filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
|
|
4703
|
-
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,
|
|
4704
4917
|
onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
|
|
4705
4918
|
onMove: (delta) => move(w, delta),
|
|
4706
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() },
|
|
@@ -4755,24 +4968,32 @@ window.__ModuleLoader__.load({
|
|
|
4755
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)}%` } })),
|
|
4756
4969
|
h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
|
|
4757
4970
|
h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
|
|
4758
|
-
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())}`))
|
|
4759
4972
|
}
|
|
4760
4973
|
|
|
4761
4974
|
// ------------------------------------------------------------ alerts
|
|
4762
4975
|
|
|
4763
4976
|
function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
|
|
4764
4977
|
const channels = useChannels()
|
|
4765
|
-
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 ?? {}) } }))
|
|
4766
4979
|
const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
|
|
4767
4980
|
const [error, setError] = useState(null)
|
|
4768
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])
|
|
4769
4990
|
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
|
|
4770
4991
|
const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
|
|
4771
4992
|
const metrics = k.value?.metrics ?? []
|
|
4772
|
-
const save = async () => {
|
|
4993
|
+
const save = () => runBusy(async () => {
|
|
4773
4994
|
setError(null)
|
|
4774
4995
|
try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
|
|
4775
|
-
}
|
|
4996
|
+
})
|
|
4776
4997
|
const tryIt = async () => {
|
|
4777
4998
|
if (test?.busy) return
|
|
4778
4999
|
setTest({ busy: true })
|
|
@@ -4801,24 +5022,24 @@ window.__ModuleLoader__.load({
|
|
|
4801
5022
|
h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
|
|
4802
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}`))))),
|
|
4803
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') })),
|
|
4804
|
-
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 })) } })),
|
|
4805
5026
|
h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
|
|
4806
|
-
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}`),
|
|
4807
5028
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
4808
5029
|
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
4809
5030
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
4810
5031
|
h('button', { type: 'button', className: 'n2s-btn', disabled: test?.busy, onClick: tryIt }, t('al.test')),
|
|
4811
|
-
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'))))
|
|
4812
5033
|
}
|
|
4813
5034
|
|
|
4814
5035
|
function AlertsTab({ t, ds, datasources }) {
|
|
4815
5036
|
const list = useAsync(() => call('listAlerts'), [])
|
|
4816
|
-
const events = useAsync(() => call('listAlertEvents', {}), [list.value])
|
|
5037
|
+
const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
|
|
4817
5038
|
const [editing, setEditing] = useState(null)
|
|
4818
5039
|
const [notice, setNotice] = useState(null)
|
|
4819
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() }
|
|
4820
5041
|
const alerts = list.value ?? []
|
|
4821
|
-
const sched = useAsync(() => call('schedulerStatus'), [list.value])
|
|
5042
|
+
const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
|
|
4822
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')
|
|
4823
5044
|
return h('div', null,
|
|
4824
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'))),
|
|
@@ -4869,7 +5090,7 @@ window.__ModuleLoader__.load({
|
|
|
4869
5090
|
const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
|
|
4870
5091
|
return () => clearInterval(id)
|
|
4871
5092
|
}, [latest?.status, latest?.id])
|
|
4872
|
-
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])
|
|
4873
5094
|
const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
|
|
4874
5095
|
const cases = set.value ?? []
|
|
4875
5096
|
return h('div', null,
|
|
@@ -4894,7 +5115,7 @@ window.__ModuleLoader__.load({
|
|
|
4894
5115
|
c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
|
|
4895
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))))))),
|
|
4896
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' } },
|
|
4897
|
-
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() } }),
|
|
4898
5119
|
h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
|
|
4899
5120
|
c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
|
|
4900
5121
|
}
|
|
@@ -4902,16 +5123,16 @@ window.__ModuleLoader__.load({
|
|
|
4902
5123
|
// ---------------------------------------------------- schema changes
|
|
4903
5124
|
|
|
4904
5125
|
function SchemaChanges({ t, ds }) {
|
|
4905
|
-
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])
|
|
4906
5127
|
const open = (list.value ?? []).filter(c => c.status === 'open')
|
|
4907
5128
|
if (!open.length) return null
|
|
4908
5129
|
const kindLabel = (k) => t(`sc.${k}`)
|
|
4909
5130
|
return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
|
|
4910
5131
|
open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
|
|
4911
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' }),
|
|
4912
|
-
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id })
|
|
4913
|
-
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
|
|
4914
|
-
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)}`))))))
|
|
4915
5136
|
}
|
|
4916
5137
|
|
|
4917
5138
|
// -------------------------------------------------- knowledge io
|
|
@@ -4919,9 +5140,26 @@ window.__ModuleLoader__.load({
|
|
|
4919
5140
|
function KnowledgeIO({ t, ds, datasources, onImported }) {
|
|
4920
5141
|
const fileRef = useRef(null)
|
|
4921
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
|
|
4922
5158
|
const doExport = async (all) => {
|
|
4923
|
-
|
|
4924
|
-
|
|
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) }
|
|
4925
5163
|
}
|
|
4926
5164
|
const onFile = async (e) => {
|
|
4927
5165
|
const f = e.target.files?.[0]
|
|
@@ -4931,13 +5169,14 @@ window.__ModuleLoader__.load({
|
|
|
4931
5169
|
const text = await f.text()
|
|
4932
5170
|
const doc = JSON.parse(text)
|
|
4933
5171
|
const single = doc.datasources?.length === 1
|
|
4934
|
-
const
|
|
4935
|
-
|
|
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 })
|
|
4936
5175
|
} catch (err) { setState({ error: err.message }) }
|
|
4937
5176
|
}
|
|
4938
|
-
const apply = async (
|
|
4939
|
-
try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ?
|
|
4940
|
-
}
|
|
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
|
+
})
|
|
4941
5180
|
return h(React.Fragment, null,
|
|
4942
5181
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
|
|
4943
5182
|
h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
|
|
@@ -4947,17 +5186,17 @@ window.__ModuleLoader__.load({
|
|
|
4947
5186
|
{ label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
|
|
4948
5187
|
] }),
|
|
4949
5188
|
state && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setState(null) } },
|
|
4950
|
-
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') },
|
|
4951
5190
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
|
|
4952
5191
|
state.error && h('div', { className: 'n2s-notice err' }, state.error),
|
|
4953
5192
|
state.dry && h(React.Fragment, null,
|
|
4954
|
-
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')),
|
|
4955
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)))),
|
|
4956
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 ?? '—'))))),
|
|
4957
5196
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
4958
5197
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setState(null) }, t('ds.cancel')),
|
|
4959
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
|
|
4960
|
-
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')))),
|
|
4961
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))
|
|
4962
5201
|
}
|
|
4963
5202
|
|
|
@@ -4980,7 +5219,7 @@ window.__ModuleLoader__.load({
|
|
|
4980
5219
|
if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
|
|
4981
5220
|
if (s.kind === 'metric') return h('div', null,
|
|
4982
5221
|
h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
|
|
4983
|
-
(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(' · ')))
|
|
4984
5223
|
if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
|
|
4985
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))
|
|
4986
5225
|
}
|
|
@@ -4998,14 +5237,17 @@ window.__ModuleLoader__.load({
|
|
|
4998
5237
|
const [busy, setBusy] = useState(null)
|
|
4999
5238
|
const items = list.value?.suggestions ?? []
|
|
5000
5239
|
const done = () => { list.reload(); onChanged?.() }
|
|
5240
|
+
const inFlight = useRef(new Set())
|
|
5001
5241
|
const accept = async (s, item) => {
|
|
5242
|
+
if (inFlight.current.has(s.id)) return
|
|
5243
|
+
inFlight.current.add(s.id)
|
|
5002
5244
|
setBusy(s.id)
|
|
5003
5245
|
try {
|
|
5004
5246
|
await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
|
|
5005
5247
|
setEditing(null)
|
|
5006
5248
|
notify(t('sg.accepted'), 'ok')
|
|
5007
5249
|
done()
|
|
5008
|
-
} catch (e) { notifyError(e) } finally { setBusy(null) }
|
|
5250
|
+
} catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
|
|
5009
5251
|
}
|
|
5010
5252
|
const dismiss = async (s) => {
|
|
5011
5253
|
if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
|
|
@@ -5028,8 +5270,8 @@ window.__ModuleLoader__.load({
|
|
|
5028
5270
|
h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
|
|
5029
5271
|
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
|
|
5030
5272
|
h(Dropdown, { label: t('sg.acceptDirect'), trigger: { className: 'n2s-iconbtn', content: Icon.chev(10) }, items: [
|
|
5031
|
-
{ label: t('sg.acceptEdit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
|
|
5032
|
-
{ 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) },
|
|
5033
5275
|
] }),
|
|
5034
5276
|
h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
|
|
5035
5277
|
editing?.id === s.id && h('div', { style: { marginTop: 10 } },
|
|
@@ -5118,10 +5360,10 @@ window.__ModuleLoader__.load({
|
|
|
5118
5360
|
seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
|
|
5119
5361
|
items.length === 0
|
|
5120
5362
|
? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
|
|
5121
|
-
: 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 => {
|
|
5122
5364
|
const taken = x.memberId && !isChosen(x)
|
|
5123
5365
|
return h('button', {
|
|
5124
|
-
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),
|
|
5125
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'),
|
|
5126
5368
|
onClick: () => onPick(x),
|
|
5127
5369
|
},
|
|
@@ -5299,8 +5541,8 @@ window.__ModuleLoader__.load({
|
|
|
5299
5541
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
|
|
5300
5542
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
5301
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'))),
|
|
5302
|
-
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(
|
|
5303
|
-
(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(', ')}}`))}`)),
|
|
5304
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())),
|
|
5305
5547
|
h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
|
|
5306
5548
|
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
|
|
@@ -5318,7 +5560,7 @@ window.__ModuleLoader__.load({
|
|
|
5318
5560
|
// ---------------------------------------------------- dashboards bar
|
|
5319
5561
|
|
|
5320
5562
|
function DashboardSwitcher({ t, value, onChange }) {
|
|
5321
|
-
const list = useAsync(() => call('listDashboards'), [value?.id])
|
|
5563
|
+
const list = useAsync(() => call('listDashboards'), [value?.id], [])
|
|
5322
5564
|
const v = list.value
|
|
5323
5565
|
if (!v) return null
|
|
5324
5566
|
const cur = v.dashboards.find(d => d.id === v.current)
|
|
@@ -5327,7 +5569,7 @@ window.__ModuleLoader__.load({
|
|
|
5327
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))),
|
|
5328
5570
|
h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
|
|
5329
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 })) } },
|
|
5330
|
-
{ 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 })) } },
|
|
5331
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 })) } },
|
|
5332
5574
|
{ divider: true },
|
|
5333
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 })) } },
|
|
@@ -5339,7 +5581,7 @@ window.__ModuleLoader__.load({
|
|
|
5339
5581
|
const v = st.value
|
|
5340
5582
|
return h('div', { className: 'n2s-row' },
|
|
5341
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 }) : '…'),
|
|
5342
|
-
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')))
|
|
5343
5585
|
}
|
|
5344
5586
|
|
|
5345
5587
|
function ChannelSelect({ t, value, onChange }) {
|
|
@@ -5347,8 +5589,25 @@ window.__ModuleLoader__.load({
|
|
|
5347
5589
|
}
|
|
5348
5590
|
|
|
5349
5591
|
// Questions started from the page bind their new session to the chosen
|
|
5350
|
-
// 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.
|
|
5351
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
|
+
}
|
|
5352
5611
|
|
|
5353
5612
|
// ------------------------------------------------ composer selector
|
|
5354
5613
|
|
|
@@ -5365,8 +5624,15 @@ window.__ModuleLoader__.load({
|
|
|
5365
5624
|
if (!sessionId) return
|
|
5366
5625
|
let cancelled = false
|
|
5367
5626
|
;(async () => {
|
|
5368
|
-
const
|
|
5369
|
-
|
|
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
|
+
}
|
|
5370
5636
|
if (!cancelled) await loadSessionInfo(sessionId)
|
|
5371
5637
|
})()
|
|
5372
5638
|
if (!store.get().loaded) refreshDatasources()
|
|
@@ -5518,7 +5784,7 @@ window.__ModuleLoader__.load({
|
|
|
5518
5784
|
}
|
|
5519
5785
|
|
|
5520
5786
|
function AdminArea({ t, ctx, section, sub, onNavigate, ds, datasources }) {
|
|
5521
|
-
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], [])
|
|
5522
5788
|
const [alerts, reports, channels, users] = counts.value ?? []
|
|
5523
5789
|
const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
|
|
5524
5790
|
const firing = (alerts ?? []).filter(a => a.state === 'firing').length
|
|
@@ -5550,32 +5816,49 @@ window.__ModuleLoader__.load({
|
|
|
5550
5816
|
const { datasources, loaded, error } = useStore(s => s)
|
|
5551
5817
|
const [nav, setNavState] = useState(loadNav)
|
|
5552
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
|
+
}, [])
|
|
5553
5835
|
const [selectedId, setSelectedId] = useState(null)
|
|
5554
5836
|
useEffect(() => { refreshDatasources() }, [])
|
|
5555
5837
|
const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
|
|
5556
5838
|
const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
|
|
5557
5839
|
const ask = useCallback((question) => {
|
|
5558
5840
|
if (!ds) return
|
|
5559
|
-
const pending =
|
|
5560
|
-
pendingAsks.push(pending)
|
|
5561
|
-
setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, 60000)
|
|
5841
|
+
const pending = addPendingAsk(ds.id, question)
|
|
5562
5842
|
try {
|
|
5563
|
-
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)
|
|
5564
5847
|
} catch (e) {
|
|
5848
|
+
const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
|
|
5565
5849
|
notifyError(e)
|
|
5566
5850
|
}
|
|
5567
5851
|
}, [ds?.id])
|
|
5568
5852
|
fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
|
|
5569
|
-
fbCtx.navigate = (patch) =>
|
|
5853
|
+
fbCtx.navigate = (patch) => { guardedNav(patch) }
|
|
5570
5854
|
const tabs = [...PRIMARY_TABS, 'admin']
|
|
5571
5855
|
const onTabKey = (e) => {
|
|
5572
5856
|
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
|
5573
5857
|
const i = tabs.indexOf(nav.tab)
|
|
5574
5858
|
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]
|
|
5575
|
-
|
|
5576
|
-
setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0)
|
|
5859
|
+
guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
|
|
5577
5860
|
}
|
|
5578
|
-
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 }) } },
|
|
5579
5862
|
k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
|
|
5580
5863
|
const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
|
|
5581
5864
|
const showPicker = datasources.length > 0 && nav.tab !== 'admin'
|
|
@@ -5590,20 +5873,19 @@ window.__ModuleLoader__.load({
|
|
|
5590
5873
|
h('span', { className: 'name' }, ds?.name ?? '—'),
|
|
5591
5874
|
ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
|
|
5592
5875
|
Icon.chev(10),
|
|
5593
|
-
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() } },
|
|
5594
5877
|
datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
|
|
5595
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'))),
|
|
5596
5879
|
h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
|
|
5597
5880
|
error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
|
|
5598
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 } })) :
|
|
5599
|
-
needsSource ? h(EmptySourceHint, { t, onGo: () =>
|
|
5600
|
-
nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate:
|
|
5601
|
-
nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment
|
|
5602
|
-
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 }) :
|
|
5603
5886
|
nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
|
|
5604
5887
|
nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
|
|
5605
|
-
h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate:
|
|
5606
|
-
h(FeedbackLayer, { t })))
|
|
5888
|
+
h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
|
|
5607
5889
|
}
|
|
5608
5890
|
}
|
|
5609
5891
|
|
|
@@ -5705,7 +5987,7 @@ window.__ModuleLoader__.load({
|
|
|
5705
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 })),
|
|
5706
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')))),
|
|
5707
5989
|
h('div', { className: 'n2s-setnote' }, overrides.length
|
|
5708
|
-
? 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))))
|
|
5709
5991
|
: t('set.noOverrides'))) :
|
|
5710
5992
|
group === 'query' ? h(React.Fragment, null,
|
|
5711
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 })),
|
|
@@ -5809,6 +6091,17 @@ window.__ModuleLoader__.load({
|
|
|
5809
6091
|
const retention = form.retention ?? defaults.retention ?? {}
|
|
5810
6092
|
const fileRef = useRef(null)
|
|
5811
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)])
|
|
5812
6105
|
const exportAll = async () => {
|
|
5813
6106
|
try {
|
|
5814
6107
|
const doc = await call('exportConfig')
|
|
@@ -5826,7 +6119,7 @@ window.__ModuleLoader__.load({
|
|
|
5826
6119
|
setImportState({ doc, dry })
|
|
5827
6120
|
} catch (err) { notifyError(err) }
|
|
5828
6121
|
}
|
|
5829
|
-
const doImport = async (
|
|
6122
|
+
const doImport = (mode) => runImport(async () => {
|
|
5830
6123
|
if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
|
|
5831
6124
|
try {
|
|
5832
6125
|
const r = await call('importConfig', { data: importState.doc, mode })
|
|
@@ -5836,7 +6129,7 @@ window.__ModuleLoader__.load({
|
|
|
5836
6129
|
const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
|
|
5837
6130
|
notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
|
|
5838
6131
|
} catch (err) { notifyError(err) }
|
|
5839
|
-
}
|
|
6132
|
+
})
|
|
5840
6133
|
const purge = async (kind) => {
|
|
5841
6134
|
if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
|
|
5842
6135
|
try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
|
|
@@ -5862,17 +6155,17 @@ window.__ModuleLoader__.load({
|
|
|
5862
6155
|
h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
|
|
5863
6156
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
|
|
5864
6157
|
importState && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setImportState(null) } },
|
|
5865
|
-
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') },
|
|
5866
6159
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
|
|
5867
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() : '?' })),
|
|
5868
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)))),
|
|
5869
6162
|
h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
|
|
5870
6163
|
h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
|
|
5871
|
-
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 }) : ''])))))),
|
|
5872
6165
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
5873
6166
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
|
|
5874
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
|
|
5875
|
-
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))
|
|
5876
6169
|
}
|
|
5877
6170
|
|
|
5878
6171
|
function AboutSection({ t }) {
|
|
@@ -5901,6 +6194,9 @@ window.__ModuleLoader__.load({
|
|
|
5901
6194
|
}, 'nl2sql: styles')
|
|
5902
6195
|
ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
|
|
5903
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')
|
|
5904
6200
|
const Page = makePage(ctx)
|
|
5905
6201
|
const PanelIcon = ({ size }) => Icon.db(size ?? 16)
|
|
5906
6202
|
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))
|