@hedeqiang/dsh-nl2sql 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -17,6 +17,15 @@ window.__ModuleLoader__.load({
17
17
  // ------------------------------------------------------------ locale
18
18
 
19
19
  const zh = {
20
+ 'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
21
+ 'mcp.where.claudeCode': '在终端运行(HTTP 直连;下方为 stdio 桥接写法)。', 'mcp.where.cline': '写入 cline_mcp_settings.json(Cline 面板 → MCP Servers → Configure)。', 'mcp.where.windsurf': '写入 Windsurf 的 mcp_config.json(Cascade → MCP → 打开配置文件)。', 'mcp.where.codex': '写入 ~/.codex/config.toml(或项目的 .codex/config.toml)。', 'mcp.where.gemini': '写入 ~/.gemini/settings.json 或项目的 .gemini/settings.json。', 'mcp.where.zed': '写入 Zed 的 settings.json。', 'mcp.where.continue': '保存为工作区 .continue/mcpServers/nl2sql.yaml。',
22
+ 'mcp.test': '测试连接', 'mcp.testOk': '连接正常:发现 {n} 个工具。', 'mcp.testFail': '连接失败:{error}', 'mcp.testFailSteps': '连接测试未通过:',
23
+ 'mcp.expiry': '过期时间(可选)', 'mcp.noExpiry': '不过期', 'mcp.expiryHint': '到期当天结束后令牌失效(401)。', 'mcp.toolsAllow': '允许的工具', 'mcp.allTools': '全部工具', 'mcp.rateLimit': '频率限制(可选)', 'mcp.unlimited': '不限', 'mcp.perMinute': '次 / 分钟,超过返回 429',
24
+ 'mcp.err.tools': '请至少勾选一个工具', 'mcp.edit': '编辑', 'mcp.editTitle': '编辑令牌「{name}」', 'mcp.save': '保存', 'mcp.saved': '已保存',
25
+ 'mcp.rotate': '轮换密钥', 'mcp.rotateConfirm': '轮换令牌「{name}」的密钥?', 'mcp.rotateHint': '旧密钥立即失效,新密钥只显示一次;权限和限制保持不变。', 'mcp.rotated': '已生成新密钥',
26
+ 'mcp.col.limits': '限制', 'mcp.noLimits': '无', 'mcp.expired': '已过期', 'mcp.expiresOn': '{date} 到期', 'mcp.toolsN': '{n} 个工具', 'mcp.rateN': '{n} 次/分',
27
+ 'mcp.callLog': '调用记录', 'mcp.log.token': '令牌', 'mcp.log.allTokens': '全部令牌', 'mcp.log.status': '状态', 'mcp.log.all': '全部', 'mcp.log.filter': '按工具或数据源筛选', 'mcp.log.refresh': '刷新', 'mcp.log.empty': '还没有调用记录', 'mcp.log.time': '时间', 'mcp.log.tool': '工具 / 方法', 'mcp.log.datasource': '数据源', 'mcp.log.ms': '耗时 ms', 'mcp.log.rows': '行数',
28
+ 'hist.status.cancelled': '已取消', 'mcp.log.status.ok': '成功', 'mcp.log.status.error': '失败', 'mcp.log.status.cancelled': '已取消', 'mcp.log.status.rate-limited': '限流',
20
29
  'admin.group.insight': "洞察与自动化",
21
30
  'admin.group.connect': "集成",
22
31
  "fq.title": "数据新鲜度",
@@ -1169,6 +1178,15 @@ window.__ModuleLoader__.load({
1169
1178
  'policy.tablesLabel': '可访问的表',
1170
1179
  }
1171
1180
  const en = {
1181
+ 'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
1182
+ 'mcp.where.claudeCode': 'Run in a terminal (HTTP; the stdio bridge variant is below).', 'mcp.where.cline': 'Add to cline_mcp_settings.json (Cline panel → MCP Servers → Configure).', 'mcp.where.windsurf': 'Add to Windsurf\'s mcp_config.json (Cascade → MCP → open config file).', 'mcp.where.codex': 'Add to ~/.codex/config.toml (or the project\'s .codex/config.toml).', 'mcp.where.gemini': 'Add to ~/.gemini/settings.json or the project\'s .gemini/settings.json.', 'mcp.where.zed': 'Add to Zed\'s settings.json.', 'mcp.where.continue': 'Save as .continue/mcpServers/nl2sql.yaml in the workspace.',
1183
+ 'mcp.test': 'Test connection', 'mcp.testOk': 'Connected: {n} tools discovered.', 'mcp.testFail': 'Connection failed: {error}', 'mcp.testFailSteps': 'Connection test failed:',
1184
+ 'mcp.expiry': 'Expires (optional)', 'mcp.noExpiry': 'Never', 'mcp.expiryHint': 'The token stops working (401) after the end of that day.', 'mcp.toolsAllow': 'Allowed tools', 'mcp.allTools': 'All tools', 'mcp.rateLimit': 'Rate limit (optional)', 'mcp.unlimited': 'unlimited', 'mcp.perMinute': 'requests / minute; above it → 429',
1185
+ 'mcp.err.tools': 'Select at least one tool', 'mcp.edit': 'Edit', 'mcp.editTitle': 'Edit token "{name}"', 'mcp.save': 'Save', 'mcp.saved': 'Saved',
1186
+ 'mcp.rotate': 'Rotate secret', 'mcp.rotateConfirm': 'Rotate the secret of token "{name}"?', 'mcp.rotateHint': 'The old secret stops working at once; the new one is shown once. Scope and limits stay the same.', 'mcp.rotated': 'New secret generated',
1187
+ 'mcp.col.limits': 'Limits', 'mcp.noLimits': 'none', 'mcp.expired': 'Expired', 'mcp.expiresOn': 'expires {date}', 'mcp.toolsN': '{n} tools', 'mcp.rateN': '{n}/min',
1188
+ 'mcp.callLog': 'Call log', 'mcp.log.token': 'Token', 'mcp.log.allTokens': 'All tokens', 'mcp.log.status': 'Status', 'mcp.log.all': 'All', 'mcp.log.filter': 'Filter by tool or data source', 'mcp.log.refresh': 'Refresh', 'mcp.log.empty': 'No calls yet', 'mcp.log.time': 'Time', 'mcp.log.tool': 'Tool / method', 'mcp.log.datasource': 'Data source', 'mcp.log.ms': 'ms', 'mcp.log.rows': 'Rows',
1189
+ 'hist.status.cancelled': 'Cancelled', 'mcp.log.status.ok': 'OK', 'mcp.log.status.error': 'Failed', 'mcp.log.status.cancelled': 'Cancelled', 'mcp.log.status.rate-limited': 'Rate limited',
1172
1190
  'admin.group.insight': "Insight & automation",
1173
1191
  'admin.group.connect': "Integrations",
1174
1192
  "fq.title": "Data freshness",
@@ -2338,6 +2356,17 @@ window.__ModuleLoader__.load({
2338
2356
  .n2s-fq-summary .n2s-fq-score{min-width:48px;height:34px;font-size:17px}
2339
2357
  .n2s-adminnav-group{margin:14px 12px 4px;font-size:11.5px;letter-spacing:.02em;color:var(--n2s-faint)}
2340
2358
  .n2s-adminnav-sep{height:.5px;margin:10px 12px;background:var(--n2s-line)}
2359
+ .n2s-mcp-tabs{flex-wrap:wrap}
2360
+ .n2s-mcp-limits{display:grid;gap:12px;padding-top:4px;border-top:1px dashed var(--n2s-border)}
2361
+ .n2s-mcp-toolgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:4px 12px;margin-top:4px;font-size:12.5px}
2362
+ .n2s-mcp-editor{display:grid;gap:12px;border-color:var(--n2s-accent)}
2363
+ .n2s-mcp-actions{text-align:right;white-space:nowrap}
2364
+ .n2s-mcp-actions .n2s-btn{margin-left:4px}
2365
+ .n2s-mcp-tokens .n2s-badge{margin:0 4px 2px 0}
2366
+ .n2s-mcp-selftest{margin-top:6px}
2367
+ .n2s-mcp-steps{margin:6px 0 0;padding-left:18px;font-size:12.5px}
2368
+ .n2s-mcp-log .n2s-select{max-width:200px}
2369
+ .n2s-mcp-logtable td{vertical-align:middle}
2341
2370
  /* ---- dashboard v2 ---- */
2342
2371
  .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
2343
2372
  .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
@@ -2807,6 +2836,26 @@ window.__ModuleLoader__.load({
2807
2836
  }
2808
2837
  // ----------------------------------------------------------- charts
2809
2838
 
2839
+ // ECharts is loaded in a sandboxed wrapper (lib/client.echarts.js) whose `window` is an empty
2840
+ // object, so it cannot read the screen's devicePixelRatio and would draw at 1x (blurry on
2841
+ // Retina / HiDPI). Every chart passes the real ratio explicitly and re-inits when it changes
2842
+ // (moving between screens, browser zoom).
2843
+ const chartDpr = () => Math.max(1, Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1))
2844
+ function initChart(echarts, el, opts = {}) {
2845
+ return echarts.init(el, null, { renderer: 'canvas', devicePixelRatio: chartDpr(), ...opts })
2846
+ }
2847
+ /** Call `onChange` when the device pixel ratio changes; returns a cleanup. */
2848
+ function watchDpr(onChange) {
2849
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {}
2850
+ let mq
2851
+ const listen = () => {
2852
+ mq = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
2853
+ mq.addEventListener?.('change', handler, { once: true })
2854
+ }
2855
+ const handler = () => { onChange(); listen() }
2856
+ listen()
2857
+ return () => mq?.removeEventListener?.('change', handler)
2858
+ }
2810
2859
  let echartsPromise
2811
2860
  function loadEcharts() {
2812
2861
  echartsPromise ??= require.async('./client.echarts.js')
@@ -2913,17 +2962,27 @@ window.__ModuleLoader__.load({
2913
2962
  useEffect(() => {
2914
2963
  let disposed = false
2915
2964
  let observer
2965
+ let stopDpr
2916
2966
  loadEcharts().then((echarts) => {
2917
2967
  if (disposed || !ref.current) return
2918
- instance.current = echarts.init(ref.current, null, { renderer: 'canvas' })
2919
- instance.current.on('click', (p) => { if (pointRef.current && p.name !== undefined && p.name !== '') pointRef.current(p.name) })
2968
+ const create = () => {
2969
+ const prev = instance.current
2970
+ const option = prev?.getOption?.()
2971
+ prev?.dispose()
2972
+ instance.current = initChart(echarts, ref.current)
2973
+ instance.current.on('click', (p) => { if (pointRef.current && p.name !== undefined && p.name !== '') pointRef.current(p.name) })
2974
+ if (option) instance.current.setOption(option, true)
2975
+ onInstance?.(instance.current)
2976
+ }
2977
+ create()
2978
+ stopDpr = watchDpr(() => { if (!disposed && ref.current) create() })
2920
2979
  observer = new ResizeObserver(() => instance.current?.resize())
2921
2980
  observer.observe(ref.current)
2922
2981
  setReady(true)
2923
- onInstance?.(instance.current)
2924
2982
  }, (e) => setError(e.message))
2925
2983
  return () => {
2926
2984
  disposed = true
2985
+ stopDpr?.()
2927
2986
  observer?.disconnect()
2928
2987
  instance.current?.dispose()
2929
2988
  instance.current = null
@@ -3066,7 +3125,7 @@ window.__ModuleLoader__.load({
3066
3125
  const [flash, setFlash] = useFlash()
3067
3126
  const copySql = () => { navigator.clipboard?.writeText(result.sql); setFlash(t('result.copied')) }
3068
3127
  const exportPng = () => {
3069
- const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
3128
+ const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: Math.max(2, chartDpr()), backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
3070
3129
  if (url) fetch(url).then(r => r.blob()).then(b => download(`${safeName(result.title)}.png`, b))
3071
3130
  }
3072
3131
  const isChart = chart.type !== 'table' && chart.type !== 'kpi'
@@ -3732,7 +3791,7 @@ window.__ModuleLoader__.load({
3732
3791
  let observer
3733
3792
  loadEcharts().then((echarts) => {
3734
3793
  if (disposed || !ref.current) return
3735
- chart = echarts.init(ref.current, null, { renderer: 'canvas' })
3794
+ chart = initChart(echarts, ref.current)
3736
3795
  observer = new ResizeObserver(() => chart?.resize())
3737
3796
  observer.observe(ref.current)
3738
3797
  const el = ref.current
@@ -7198,7 +7257,7 @@ window.__ModuleLoader__.load({
7198
7257
  let observer
7199
7258
  loadEcharts().then((echarts) => {
7200
7259
  if (disposed || !ref.current) return
7201
- inst.current = echarts.init(ref.current, null, { renderer: 'canvas' })
7260
+ inst.current = initChart(echarts, ref.current)
7202
7261
  observer = new ResizeObserver(() => inst.current?.resize())
7203
7262
  observer.observe(ref.current)
7204
7263
  try { inst.current.setOption(proForecastOption(forecast, ref.current, t), true) } catch (e) { setError(e.message) }
@@ -7696,18 +7755,40 @@ window.__ModuleLoader__.load({
7696
7755
 
7697
7756
  extend.adminSections.push({ id: 'bitable', label: (t) => t('pre.bt.nav'), icon: () => Icon.table(15), Component: PreBitableSection })
7698
7757
  // ------------------------------------------------------------ MCP access (admin section)
7699
- // Enable the local MCP server, manage bearer tokens (shown once) and their
7700
- // scopes, and copy config snippets for Claude Desktop / Cursor / VS Code.
7758
+ // Enable the local MCP server, manage bearer tokens (shown once; expiry, tool allowlist,
7759
+ // rate limit, rotation), test the connection, read the per-token call log, and copy setup
7760
+ // snippets for ten MCP clients (formats verified against each client's docs, see docs/mcp.md).
7701
7761
 
7702
7762
  const MCP_PKG = '@hedeqiang/dsh-nl2sql'
7763
+ const MCP_CLIENTS = ['claude', 'claudeCode', 'cursor', 'vscode', 'cline', 'windsurf', 'codex', 'gemini', 'zed', 'continue']
7703
7764
 
7704
7765
  /** Config snippets for a token (placeholder when the token value is no longer known). */
7705
7766
  function mcpSnippets(url, token) {
7706
7767
  const tok = token || '<YOUR_TOKEN>'
7768
+ const auth = { Authorization: `Bearer ${tok}` }
7769
+ const json = (o) => JSON.stringify(o, null, 2)
7770
+ const stdio = { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } }
7707
7771
  return {
7708
- claude: JSON.stringify({ mcpServers: { nl2sql: { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } } } }, null, 2),
7709
- cursor: JSON.stringify({ mcpServers: { nl2sql: { url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7710
- vscode: JSON.stringify({ servers: { nl2sql: { type: 'http', url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7772
+ // https://modelcontextprotocol.io/docs/develop/connect-local-servers
7773
+ claude: json({ mcpServers: { nl2sql: stdio } }),
7774
+ // https://code.claude.com/docs/en/mcp
7775
+ claudeCode: `claude mcp add --transport http nl2sql ${url} \\\n --header "Authorization: Bearer ${tok}"\n\n# stdio variant (bridge)\nclaude mcp add --transport stdio --env NL2SQL_MCP_URL=${url} --env NL2SQL_MCP_TOKEN=${tok} nl2sql -- npx -y -p ${MCP_PKG} nl2sql-mcp`,
7776
+ // https://cursor.com/docs/mcp
7777
+ cursor: json({ mcpServers: { nl2sql: { url, headers: auth } } }),
7778
+ // https://code.visualstudio.com/docs/agents/reference/mcp-configuration
7779
+ vscode: json({ servers: { nl2sql: { type: 'http', url, headers: auth } } }),
7780
+ // https://docs.cline.bot/mcp/configuring-mcp-servers
7781
+ cline: json({ mcpServers: { nl2sql: { type: 'streamableHttp', url, headers: auth, disabled: false } } }),
7782
+ // https://docs.devin.ai/windsurf/plugins/cascade/mcp
7783
+ windsurf: json({ mcpServers: { nl2sql: { serverUrl: url, headers: auth } } }),
7784
+ // https://developers.openai.com/codex/mcp
7785
+ codex: `[mcp_servers.nl2sql]\nurl = "${url}"\nhttp_headers = { "Authorization" = "Bearer ${tok}" }`,
7786
+ // https://geminicli.com/docs/tools/mcp-server/
7787
+ gemini: json({ mcpServers: { nl2sql: { httpUrl: url, headers: auth } } }),
7788
+ // https://zed.dev/docs/ai/mcp
7789
+ zed: json({ context_servers: { nl2sql: { url, headers: auth } } }),
7790
+ // https://docs.continue.dev/customize/deep-dives/mcp
7791
+ continue: `name: nl2sql\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: nl2sql\n type: streamable-http\n url: ${url}\n requestOptions:\n headers:\n Authorization: Bearer ${tok}`,
7711
7792
  }
7712
7793
  }
7713
7794
 
@@ -7724,10 +7805,9 @@ window.__ModuleLoader__.load({
7724
7805
  function McpSnippets({ t, url, token }) {
7725
7806
  const [tab, setTab] = useState('claude')
7726
7807
  const snip = mcpSnippets(url, token)
7727
- const where = { claude: 'mcp.where.claude', cursor: 'mcp.where.cursor', vscode: 'mcp.where.vscode' }
7728
7808
  return h('div', { className: 'n2s-mcp-snippets' },
7729
- h('div', { className: 'n2s-seg', role: 'tablist' }, ['claude', 'cursor', 'vscode'].map(k => h('button', { key: k, type: 'button', role: 'tab', 'aria-selected': tab === k, 'aria-pressed': tab === k, onClick: () => setTab(k) }, t(`mcp.client.${k}`)))),
7730
- h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(where[tab])),
7809
+ h('div', { className: 'n2s-seg n2s-mcp-tabs', role: 'tablist', 'aria-label': t('mcp.setup') }, MCP_CLIENTS.map(k => h('button', { key: k, type: 'button', role: 'tab', 'data-client': k, 'aria-selected': tab === k, 'aria-pressed': tab === k, onClick: () => setTab(k) }, t(`mcp.client.${k}`)))),
7810
+ h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(`mcp.where.${tab}`)),
7731
7811
  h('div', { className: 'n2s-mcp-code' },
7732
7812
  h('pre', { className: 'n2s-code', 'data-snippet': tab }, snip[tab]),
7733
7813
  h('div', { className: 'n2s-mcp-codeact' }, h(McpCopy, { t, text: snip[tab] }))),
@@ -7740,9 +7820,47 @@ window.__ModuleLoader__.load({
7740
7820
  return h('span', { className: 'n2s-badge ok' }, t('mcp.scope.owner'))
7741
7821
  }
7742
7822
 
7743
- function McpTokenForm({ t, datasources, onCreated, onCancel }) {
7823
+ const mcpDateInput = (ms) => {
7824
+ if (!ms) return ''
7825
+ const d = new Date(ms)
7826
+ const p = (n) => String(n).padStart(2, '0')
7827
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
7828
+ }
7829
+
7830
+ /** Expiry, tool allowlist and rate limit fields. value: { expires: 'YYYY-MM-DD'|'', allTools, tools: [], rateLimit: '' }. */
7831
+ function McpLimitsFields({ t, value, onChange, toolNames }) {
7832
+ const set = (k, v) => onChange({ ...value, [k]: v })
7833
+ return h('div', { className: 'n2s-mcp-limits' },
7834
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.expiry')),
7835
+ h('div', { className: 'n2s-row' },
7836
+ h('input', { type: 'date', className: 'n2s-input', style: { maxWidth: 180 }, 'aria-label': t('mcp.expiry'), value: value.expires, onChange: e => set('expires', e.target.value) }),
7837
+ value.expires && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => set('expires', '') }, t('mcp.noExpiry'))),
7838
+ h('span', { className: 'n2s-faint n2s-small' }, t('mcp.expiryHint'))),
7839
+ h('div', { className: 'n2s-field', role: 'group', 'aria-label': t('mcp.toolsAllow') },
7840
+ h('span', { className: 'n2s-flabel' }, t('mcp.toolsAllow')),
7841
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: value.allTools, onChange: e => set('allTools', e.target.checked) }), ' ', t('mcp.allTools')),
7842
+ !value.allTools && h('div', { className: 'n2s-mcp-toolgrid' }, (toolNames ?? []).map(n => h('label', { key: n, className: 'n2s-check n2s-mono' },
7843
+ h('input', { type: 'checkbox', 'data-tool': n, checked: value.tools.includes(n), onChange: e => set('tools', e.target.checked ? [...value.tools, n] : value.tools.filter(x => x !== n)) }), ' ', n)))),
7844
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.rateLimit')),
7845
+ h('div', { className: 'n2s-row' },
7846
+ h('input', { className: 'n2s-input n2s-mono', style: { width: 110 }, inputMode: 'numeric', 'aria-label': t('mcp.rateLimit'), placeholder: t('mcp.unlimited'), value: value.rateLimit, onChange: e => set('rateLimit', e.target.value.replace(/\D/g, '').slice(0, 5)) }),
7847
+ h('span', { className: 'n2s-faint n2s-small' }, t('mcp.perMinute')))))
7848
+ }
7849
+
7850
+ /** Limits form state → RPC payload fields. */
7851
+ function mcpLimitsPayload(v) {
7852
+ return {
7853
+ expiresAt: v.expires ? new Date(`${v.expires}T23:59:59`).getTime() : null,
7854
+ tools: v.allTools ? null : v.tools,
7855
+ rateLimit: v.rateLimit ? Number(v.rateLimit) : null,
7856
+ }
7857
+ }
7858
+ const mcpLimitsOf = (tok) => ({ expires: mcpDateInput(tok?.expiresAt), allTools: !Array.isArray(tok?.tools), tools: tok?.tools ?? [], rateLimit: tok?.rateLimit ? String(tok.rateLimit) : '' })
7859
+
7860
+ function McpTokenForm({ t, datasources, toolNames, onCreated, onCancel }) {
7744
7861
  const users = useAsync(() => call('listUsers').catch(() => []), [])
7745
7862
  const [form, setForm] = useState({ name: '', type: 'owner', memberId: '', role: 'analyst', datasourceIds: [], rowColumn: '', rowValues: '' })
7863
+ const [limits, setLimits] = useState(mcpLimitsOf(null))
7746
7864
  const [busy, setBusy] = useState(false)
7747
7865
  const [err, setErr] = useState(null)
7748
7866
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
@@ -7755,8 +7873,10 @@ window.__ModuleLoader__.load({
7755
7873
  if (!form.name.trim()) return setErr(t('mcp.err.name'))
7756
7874
  if (form.type === 'member' && !form.memberId) return setErr(t('mcp.err.member'))
7757
7875
  if (form.type === 'role' && !form.datasourceIds.length) return setErr(t('mcp.err.ds'))
7876
+ if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
7877
+ const extra = mcpLimitsPayload(limits)
7758
7878
  setBusy(true)
7759
- try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope })) } catch (e) { setErr(e.message) }
7879
+ try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope, ...Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== null)) })) } catch (e) { setErr(e.message) }
7760
7880
  setBusy(false)
7761
7881
  }
7762
7882
  return h('div', { className: 'n2s-card n2s-mcp-form' },
@@ -7783,17 +7903,91 @@ window.__ModuleLoader__.load({
7783
7903
  h('input', { className: 'n2s-input n2s-mono', style: { maxWidth: 180 }, 'aria-label': t('mcp.rowColumn'), placeholder: t('mcp.rowColumn'), value: form.rowColumn, onChange: e => set('rowColumn', e.target.value) }),
7784
7904
  h('input', { className: 'n2s-input', 'aria-label': t('mcp.rowValues'), placeholder: t('mcp.rowValues'), value: form.rowValues, onChange: e => set('rowValues', e.target.value) })),
7785
7905
  h('span', { className: 'n2s-faint n2s-small' }, t('mcp.rowRuleHint')))),
7906
+ h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
7786
7907
  err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
7787
7908
  h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
7788
7909
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7789
7910
  h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: submit }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.create'))))
7790
7911
  }
7791
7912
 
7913
+ /** Edit name / expiry / allowlist / rate limit of an existing token (the secret is never shown again). */
7914
+ function McpTokenEditor({ t, tok, toolNames, onSaved, onCancel }) {
7915
+ const [name, setName] = useState(tok.name)
7916
+ const [limits, setLimits] = useState(mcpLimitsOf(tok))
7917
+ const [busy, setBusy] = useState(false)
7918
+ const [err, setErr] = useState(null)
7919
+ const save = async () => {
7920
+ setErr(null)
7921
+ if (!name.trim()) return setErr(t('mcp.err.name'))
7922
+ if (!limits.allTools && !limits.tools.length) return setErr(t('mcp.err.tools'))
7923
+ setBusy(true)
7924
+ try { onSaved(await call('updateMcpToken', { id: tok.id, name: name.trim(), ...mcpLimitsPayload(limits) })) } catch (e) { setErr(e.message) }
7925
+ setBusy(false)
7926
+ }
7927
+ return h('div', { className: 'n2s-card n2s-mcp-editor', 'data-editing': tok.id },
7928
+ h('div', { className: 'n2s-sechead-title', style: { marginBottom: 10 } }, fmt(t, 'mcp.editTitle', { name: tok.name })),
7929
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.tokenName')),
7930
+ h('input', { className: 'n2s-input', value: name, maxLength: 60, 'aria-label': t('mcp.tokenName'), onChange: e => setName(e.target.value) })),
7931
+ h(McpLimitsFields, { t, value: limits, onChange: setLimits, toolNames }),
7932
+ err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
7933
+ h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
7934
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7935
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.save'))))
7936
+ }
7937
+
7938
+ /** "Test connection": mcpSelfTest (discover + tools/list + initialize over loopback with a temporary credential). */
7939
+ function McpSelfTest({ t, disabled }) {
7940
+ const [res, setRes] = useState(null)
7941
+ const [busy, setBusy] = useState(false)
7942
+ const run = async () => {
7943
+ setBusy(true)
7944
+ try { setRes(await call('mcpSelfTest')) } catch (e) { setRes({ ok: false, error: e.message, steps: [] }) }
7945
+ setBusy(false)
7946
+ }
7947
+ return h('div', { className: 'n2s-mcp-selftest' },
7948
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: disabled || busy, onClick: run }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.test')),
7949
+ res && h('div', { className: `n2s-notice ${res.ok ? 'ok' : 'err'}`, role: 'status', 'data-selftest': res.ok ? 'ok' : 'fail', style: { marginTop: 8 } },
7950
+ h('div', null, res.ok ? fmt(t, 'mcp.testOk', { n: res.tools?.length ?? 0 }) : (res.error ? fmt(t, 'mcp.testFail', { error: res.error }) : t('mcp.testFailSteps'))),
7951
+ (res.steps ?? []).length > 0 && h('ul', { className: 'n2s-mcp-steps' }, res.steps.map(s => h('li', { key: s.step }, s.ok ? '✓ ' : '✗ ', h('span', { className: 'n2s-mono' }, s.step), ` · ${s.detail} · ${s.ms} ms`)))))
7952
+ }
7953
+
7954
+ /** Per-token call log with a status / tool filter. */
7955
+ function McpCallLog({ t, tokens }) {
7956
+ const [tokenId, setTokenId] = useState('')
7957
+ const [status, setStatus] = useState('')
7958
+ const [q, setQ] = useState('')
7959
+ const log = useAsync(() => call('mcpCallLog', { ...(tokenId ? { tokenId } : {}), ...(status ? { status } : {}), limit: 200 }), [tokenId, status])
7960
+ const when = (ms) => new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })
7961
+ const rows = (log.value ?? []).filter(r => !q || `${r.tool ?? r.method} ${r.datasource ?? ''}`.toLowerCase().includes(q.toLowerCase()))
7962
+ const badge = (s) => h('span', { className: `n2s-badge ${s === 'ok' ? 'ok' : s === 'cancelled' || s === 'rate-limited' ? 'warn' : 'err'}` }, t(`mcp.log.status.${s}`) ?? s)
7963
+ return h('div', { className: 'n2s-card n2s-mcp-log' },
7964
+ h('div', { className: 'n2s-row', style: { flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
7965
+ h('select', { className: 'n2s-select', 'aria-label': t('mcp.log.token'), value: tokenId, onChange: e => setTokenId(e.target.value) },
7966
+ h('option', { value: '' }, t('mcp.log.allTokens')), tokens.map(tk => h('option', { key: tk.id, value: tk.id }, tk.name))),
7967
+ h('div', { className: 'n2s-seg', role: 'group', 'aria-label': t('mcp.log.status') }, ['', 'ok', 'error', 'cancelled', 'rate-limited'].map(v => h('button', { key: v || 'all', type: 'button', 'aria-pressed': status === v, onClick: () => setStatus(v) }, v ? t(`mcp.log.status.${v}`) : t('mcp.log.all')))),
7968
+ h('input', { className: 'n2s-input', style: { maxWidth: 200 }, 'aria-label': t('mcp.log.filter'), placeholder: t('mcp.log.filter'), value: q, onChange: e => setQ(e.target.value) }),
7969
+ h('span', { className: 'n2s-grow' }),
7970
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => log.reload() }, t('mcp.log.refresh'))),
7971
+ rows.length === 0
7972
+ ? h('div', { className: 'n2s-faint n2s-small' }, log.value ? t('mcp.log.empty') : '…')
7973
+ : h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-logtable' },
7974
+ h('thead', null, h('tr', null, [t('mcp.log.time'), t('mcp.log.token'), t('mcp.log.tool'), t('mcp.log.datasource'), t('mcp.log.ms'), t('mcp.log.rows'), t('mcp.log.status')].map((x, i) => h('th', { key: i }, x)))),
7975
+ h('tbody', null, rows.map(r => h('tr', { key: r.id, 'data-log-id': r.id, title: r.error ?? '' },
7976
+ h('td', { className: 'n2s-small' }, when(r.at)),
7977
+ h('td', { className: 'n2s-small' }, r.tokenName),
7978
+ h('td', { className: 'n2s-mono n2s-small' }, r.tool ?? r.method),
7979
+ h('td', { className: 'n2s-small' }, r.datasource ?? '—'),
7980
+ h('td', { className: 'n2s-small' }, r.ms),
7981
+ h('td', { className: 'n2s-small' }, r.rows ?? '—'),
7982
+ h('td', null, badge(r.status))))))))
7983
+ }
7984
+
7792
7985
  function McpSection({ t, datasources }) {
7793
7986
  const st = useAsync(() => call('mcpStatus'), [])
7794
7987
  const [port, setPort] = useState('')
7795
7988
  const [busy, setBusy] = useState(false)
7796
7989
  const [creating, setCreating] = useState(false)
7990
+ const [editing, setEditing] = useState(null)
7797
7991
  const [fresh, setFresh] = useState(null) // { name, token } shown once
7798
7992
  useEffect(() => { if (st.value) setPort(String(st.value.port)) }, [st.value?.port])
7799
7993
  const s = st.value
@@ -7807,8 +8001,18 @@ window.__ModuleLoader__.load({
7807
8001
  if (!(await confirmDialog({ title: fmt(t, 'mcp.revokeConfirm', { name: tok.name }), message: t('mcp.revokeHint'), confirmLabel: t('mcp.revoke'), danger: true }))) return
7808
8002
  try { await call('revokeMcpToken', { id: tok.id }); if (fresh?.id === tok.id) setFresh(null); notify(t('mcp.revoked'), 'ok'); st.reload() } catch (e) { notifyError(e) }
7809
8003
  }
8004
+ const rotate = async (tok) => {
8005
+ if (!(await confirmDialog({ title: fmt(t, 'mcp.rotateConfirm', { name: tok.name }), message: t('mcp.rotateHint'), confirmLabel: t('mcp.rotate'), danger: true }))) return
8006
+ try { const r = await call('rotateMcpToken', { id: tok.id }); setFresh(r); notify(t('mcp.rotated'), 'ok'); st.reload() } catch (e) { notifyError(e) }
8007
+ }
7810
8008
  if (!s) return st.error ? h('div', { className: 'n2s-notice err' }, st.error.message) : h('div', { className: 'n2s-skel', style: { height: 200 } })
7811
8009
  const portDirty = port !== String(s.port)
8010
+ const toolNames = s.toolNames ?? []
8011
+ const limitsLabel = (tok) => [
8012
+ tok.expiresAt ? (tok.expired ? t('mcp.expired') : fmt(t, 'mcp.expiresOn', { date: new Date(tok.expiresAt).toLocaleDateString(uiLocale()) })) : null,
8013
+ Array.isArray(tok.tools) ? fmt(t, 'mcp.toolsN', { n: tok.tools.length }) : null,
8014
+ tok.rateLimit ? fmt(t, 'mcp.rateN', { n: tok.rateLimit }) : null,
8015
+ ].filter(Boolean)
7812
8016
  return h('div', { className: 'n2s-mcp' },
7813
8017
  h(SectionHead, { title: t('mcp.title'), desc: t('mcp.desc') }),
7814
8018
  h('div', { className: 'n2s-card n2s-mcp-server' },
@@ -7826,7 +8030,8 @@ window.__ModuleLoader__.load({
7826
8030
  h('span', { className: 'n2s-flabel' }, t('mcp.endpoint')),
7827
8031
  h('code', { className: 'n2s-mono', 'data-mcp-url': true }, s.url),
7828
8032
  h(McpCopy, { t, text: s.url })),
7829
- h('div', { className: 'n2s-faint n2s-small' }, t('mcp.localOnly'))),
8033
+ h(McpSelfTest, { t, disabled: !s.running }),
8034
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('mcp.localOnly'))),
7830
8035
  fresh && h('div', { className: 'n2s-card n2s-mcp-fresh', role: 'status' },
7831
8036
  h('div', { className: 'n2s-sechead-title' }, fmt(t, 'mcp.freshTitle', { name: fresh.name })),
7832
8037
  h('div', { className: 'n2s-notice warn', style: { margin: '8px 0' } }, t('mcp.freshHint')),
@@ -7836,17 +8041,25 @@ window.__ModuleLoader__.load({
7836
8041
  h('div', { className: 'n2s-row', style: { margin: '18px 0 8px' } },
7837
8042
  h('div', { className: 'n2s-sechead-title n2s-grow' }, t('mcp.tokens')),
7838
8043
  !creating && h('button', { type: 'button', className: 'n2s-btn', onClick: () => setCreating(true) }, '+ ', t('mcp.newToken'))),
7839
- creating && h(McpTokenForm, { t, datasources, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
8044
+ creating && h(McpTokenForm, { t, datasources, toolNames, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
8045
+ editing && h(McpTokenEditor, { key: editing.id, t, tok: editing, toolNames, onCancel: () => setEditing(null), onSaved: () => { setEditing(null); notify(t('mcp.saved'), 'ok'); st.reload() } }),
7840
8046
  s.tokens.length === 0 && !creating
7841
8047
  ? h('div', { className: 'n2s-empty' }, h('h3', null, t('mcp.noTokens')), h('div', null, t('mcp.noTokensHint')))
7842
8048
  : s.tokens.length > 0 && h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-tokens' },
7843
- h('thead', null, h('tr', null, [t('mcp.col.name'), t('mcp.col.scope'), t('mcp.col.created'), t('mcp.col.lastUsed'), ''].map((x, i) => h('th', { key: i }, x)))),
8049
+ h('thead', null, h('tr', null, [t('mcp.col.name'), t('mcp.col.scope'), t('mcp.col.limits'), t('mcp.col.created'), t('mcp.col.lastUsed'), ''].map((x, i) => h('th', { key: i }, x)))),
7844
8050
  h('tbody', null, s.tokens.map(tok => h('tr', { key: tok.id, 'data-token-id': tok.id },
7845
8051
  h('td', null, h('div', null, tok.name), h('div', { className: 'n2s-faint n2s-small n2s-mono' }, `${tok.prefix}…`)),
7846
8052
  h('td', null, h(McpScopeLabel, { t, scope: tok.scope })),
8053
+ h('td', { className: 'n2s-small' }, limitsLabel(tok).length ? limitsLabel(tok).map((x, i) => h('span', { key: i, className: `n2s-badge ${tok.expired && i === 0 ? 'warn' : ''}` }, x)) : h('span', { className: 'n2s-faint' }, t('mcp.noLimits'))),
7847
8054
  h('td', { className: 'n2s-small' }, when(tok.createdAt)),
7848
8055
  h('td', { className: 'n2s-small' }, when(tok.lastUsedAt)),
7849
- h('td', { style: { textAlign: 'right' } }, h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
8056
+ h('td', { className: 'n2s-mcp-actions' },
8057
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => setEditing(tok) }, t('mcp.edit')),
8058
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => rotate(tok) }, t('mcp.rotate')),
8059
+ h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
8060
+ s.tokens.length > 0 && h(React.Fragment, null,
8061
+ h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.callLog')),
8062
+ h(McpCallLog, { t, tokens: s.tokens })),
7850
8063
  h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.setup')),
7851
8064
  h('div', { className: 'n2s-card' }, h(McpSnippets, { t, url: s.url, token: null }),
7852
8065
  h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('mcp.docsHint'))))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
6
  "bin": {