@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/analysis.js +4 -1
- package/bin/nl2sql-mcp.mjs +103 -0
- 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/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/filters.js +87 -33
- package/index.js +21 -3
- package/lib/client.js +2122 -153
- package/package.json +6 -1
- package/service/alerts.js +60 -26
- 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/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +39 -10
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
- 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
|
|
|
@@ -15,6 +17,129 @@ window.__ModuleLoader__.load({
|
|
|
15
17
|
// ------------------------------------------------------------ locale
|
|
16
18
|
|
|
17
19
|
const zh = {
|
|
20
|
+
'admin.group.insight': "洞察与自动化",
|
|
21
|
+
'admin.group.connect': "集成",
|
|
22
|
+
"fq.title": "数据新鲜度",
|
|
23
|
+
"fq.refresh": "刷新",
|
|
24
|
+
"fq.checkedAt": "检查于 {time}",
|
|
25
|
+
"fq.staleCount": "{n} 张表数据滞后",
|
|
26
|
+
"fq.warnCount": "{n} 张表更新偏慢",
|
|
27
|
+
"fq.allFresh": "数据均为最新",
|
|
28
|
+
"fq.lagMin": "{n} 分钟",
|
|
29
|
+
"fq.lagHour": "{n} 小时",
|
|
30
|
+
"fq.lagDay": "{n} 天",
|
|
31
|
+
"fq.today": "今天 {time}",
|
|
32
|
+
"fq.yesterday": "昨天 {time}",
|
|
33
|
+
"fq.barItem": "{table} 最新数据停在 {when}(滞后 {lag})",
|
|
34
|
+
"fq.barMore": "另有 {n} 张表",
|
|
35
|
+
"fq.st.error": "检查失败",
|
|
36
|
+
"fq.st.empty": "无数据",
|
|
37
|
+
"fq.running": "正在检查数据新鲜度…",
|
|
38
|
+
"fq.failed": "检查失败",
|
|
39
|
+
"fq.noTime": "无时间字段",
|
|
40
|
+
"fq.notAnalyzed": "该数据源尚未分析表结构,请先在「表结构」中分析。",
|
|
41
|
+
"fq.noTables": "没有带时间字段的表。",
|
|
42
|
+
"fq.col.table": "表",
|
|
43
|
+
"fq.col.column": "时间字段",
|
|
44
|
+
"fq.col.latest": "最新数据",
|
|
45
|
+
"fq.col.lag": "滞后",
|
|
46
|
+
"fq.col.threshold": "阈值(小时)",
|
|
47
|
+
"fq.thresholdFor": "{table} 的滞后阈值(小时)",
|
|
48
|
+
"fq.events": "最近的延迟事件",
|
|
49
|
+
"fq.evStale": "数据滞后 {lag}",
|
|
50
|
+
"fq.evRecovered": "已恢复",
|
|
51
|
+
"fq.pushed": "已推送",
|
|
52
|
+
"fq.q.title": "数据质量巡检",
|
|
53
|
+
"fq.q.lastRun": "上次巡检 {time}",
|
|
54
|
+
"fq.q.run": "立即巡检",
|
|
55
|
+
"fq.q.running": "巡检中…",
|
|
56
|
+
"fq.q.empty": "还没有巡检记录,点击「立即巡检」开始。",
|
|
57
|
+
"fq.q.done": "巡检完成,健康分 {score}",
|
|
58
|
+
"fq.q.history": "近期分数",
|
|
59
|
+
"fq.q.score": "健康分",
|
|
60
|
+
"fq.q.rows": "行数",
|
|
61
|
+
"fq.q.issues": "问题",
|
|
62
|
+
"fq.q.noIssues": "未发现问题",
|
|
63
|
+
"fq.more": "还有 {n} 条",
|
|
64
|
+
"fq.less": "收起",
|
|
65
|
+
"fq.admin.nav": "数据健康",
|
|
66
|
+
"fq.admin.title": "数据健康",
|
|
67
|
+
"fq.admin.desc": "监控各表数据是否按时更新,并定期巡检空值、重复键、异常值、未来时间与负值。",
|
|
68
|
+
"fq.admin.nextF": "下次新鲜度检查 {time}",
|
|
69
|
+
"fq.admin.fOff": "新鲜度定时检查已关闭",
|
|
70
|
+
"fq.admin.nextQ": "每天 {time} 质量巡检",
|
|
71
|
+
"fq.admin.qOff": "每日质量巡检已关闭",
|
|
72
|
+
"fq.admin.noDs": "请先添加数据源",
|
|
73
|
+
"fq.set.title": "设置",
|
|
74
|
+
"fq.set.fEnabled": "定时检查数据新鲜度",
|
|
75
|
+
"fq.set.qEnabled": "每日数据质量巡检",
|
|
76
|
+
"fq.set.interval": "新鲜度检查间隔(分钟)",
|
|
77
|
+
"fq.set.time": "质量巡检时间",
|
|
78
|
+
"fq.set.staleHours": "默认滞后阈值(小时)",
|
|
79
|
+
"fq.set.drop": "健康分下降超过(分)时推送",
|
|
80
|
+
"fq.set.fChannel": "数据延迟通知渠道",
|
|
81
|
+
"fq.set.qChannel": "健康分下降通知渠道",
|
|
82
|
+
"fq.set.noPush": "不推送",
|
|
83
|
+
"fq.set.hint": "每张表的阈值可在上方表格中单独设置(0 表示不监控);延迟通知每个滞后周期只推送一次,恢复时再推送一次。",
|
|
84
|
+
"fq.saved": "已保存",
|
|
85
|
+
'pro.close': '关闭', 'pro.sendNow': '立即推送', 'pro.sent': '已推送', 'pro.deleteConfirm': '删除「{name}」?', 'pro.weekday': '星期',
|
|
86
|
+
'pro.wd.0': '周日', 'pro.wd.1': '周一', 'pro.wd.2': '周二', 'pro.wd.3': '周三', 'pro.wd.4': '周四', 'pro.wd.5': '周五', 'pro.wd.6': '周六',
|
|
87
|
+
'pro.fc.menu': '预测未来 {n} 天', 'pro.fc.title': '预测', 'pro.fc.heading': '未来 {n} 期预测', 'pro.fc.running': '正在预测…', 'pro.fc.forecast': '预测', 'pro.fc.band': '95% 区间', 'pro.fc.lower': '下限', 'pro.fc.upper': '上限',
|
|
88
|
+
'pro.fc.hw': 'Holt-Winters · 周季节性', 'pro.fc.holt': 'Holt 线性趋势', 'pro.fc.horizon': '预测期数', 'pro.fc.nDays': '{n} 期', 'pro.fc.filled': '补齐 {n} 天', 'pro.fc.filledHint': '没有数据的日期按 0 计入',
|
|
89
|
+
'pro.fc.note': '基于历史数据的统计外推,未考虑促销、节假日等已知事件;阴影为 95% 预测区间。',
|
|
90
|
+
'pro.goals.title': '目标', 'pro.goals.desc': '为指标设定月 / 季 / 年目标:按已完成天数的日均(区分工作日与周末)预测期末值,并在看板上显示进度。', 'pro.goals.add': '新建目标',
|
|
91
|
+
'pro.goals.name': '名称', 'pro.goals.namePh': '例如:11 月交易量', 'pro.goals.period': '周期', 'pro.goals.target': '目标值', 'pro.goals.direction': '方向', 'pro.goals.dashboard': '显示在看板', 'pro.goals.anyBoard': '使用同一数据源的看板',
|
|
92
|
+
'pro.goals.empty': '还没有目标', 'pro.goals.emptyHint': '先在业务知识 → 指标库定义指标,再为它设定目标。', 'pro.goals.actual': '已完成', 'pro.goals.projected': '预计期末', 'pro.goals.proj': '预计 {v}({p})', 'pro.goals.weekdayAware': '按星期估算',
|
|
93
|
+
'pro.goals.weekly': '目标周报', 'pro.goals.weeklyOn': '每周推送目标进度', 'pro.goals.weeklyHint': '在所选时间把所有目标的进度与预测推送到渠道。',
|
|
94
|
+
'pro.period.month': '月', 'pro.period.quarter': '季度', 'pro.period.year': '年', 'pro.dir.up': '不低于', 'pro.dir.down': '不高于',
|
|
95
|
+
'pro.st.on-track': '进度正常', 'pro.st.at-risk': '有风险', 'pro.st.achieved': '已达成', 'pro.st.missed': '未达成', 'pro.st.error': '出错',
|
|
96
|
+
'pro.dg.title': '每日简报', 'pro.dg.desc': '每天定时对比昨天与近 7 日均值、上周同期,找出明显变化并自动归因,推送一段简短摘要和图表。', 'pro.dg.add': '新建简报',
|
|
97
|
+
'pro.dg.name': '名称', 'pro.dg.namePh': '例如:交易日报', 'pro.dg.source': '内容来源', 'pro.dg.src.metrics': '指标库指标', 'pro.dg.src.dashboard': '看板中的趋势图', 'pro.dg.metrics': '指标', 'pro.dg.dashboard': '看板',
|
|
98
|
+
'pro.dg.language': '语言', 'pro.dg.threshold': '变化阈值(%)', 'pro.dg.hint': '昨天的值偏离近 7 日均值超过 2 个标准差,或变化幅度超过阈值时,标记为值得关注并归因到主要维度。',
|
|
99
|
+
'pro.dg.empty': '还没有每日简报', 'pro.dg.emptyHint': '选择几个核心指标,每天早上自动推送到群里。', 'pro.dg.preview': '预览', 'pro.dg.previewing': '正在生成预览…', 'pro.dg.dailyAt': '每天 {time}',
|
|
100
|
+
"pre.working": "正在生成,请稍候…", "pre.exported": "已导出", "pre.exportPpt": "导出 PPT", "pre.exportImage": "导出长图", "pre.sendImage": "发送长图到 IM", "pre.sendImageHint": "把整个看板渲染成一张长图,发送到飞书应用机器人或 Telegram 机器人渠道。", "pre.noImageChannel": "没有可以发送图片的渠道:请先在「管理 → 渠道」添加飞书应用机器人或 Telegram 机器人。", "pre.channel": "渠道", "pre.send": "发送", "pre.imageSent": "长图已发送到「{channel}」", "pre.mode": "大屏模式", "pre.loading": "加载中…", "pre.lastRefresh": "最后刷新 {time}", "pre.clock": "当前时间", "pre.refresh": "自动刷新", "pre.rotate": "轮播看板", "pre.rotateOff": "不轮播", "pre.everySec": "每 {s} 秒", "pre.prev": "上一个看板(←)", "pre.next": "下一个看板(→)", "pre.exit": "退出大屏模式", "pre.exitHint": "退出(Esc)", "pre.bt.nav": "多维表格同步", "pre.bt.title": "多维表格同步", "pre.bt.desc": "把看板组件或 SQL 的查询结果写入飞书多维表格:缺少的字段自动创建(文本 / 数字 / 日期),可每天定时同步。", "pre.bt.add": "新建同步", "pre.bt.empty": "还没有同步任务", "pre.bt.emptyHint": "例如:每天早上把「按渠道的成交额」同步到运营团队的多维表格。", "pre.bt.name": "名称", "pre.bt.namePh": "如:渠道日报 → 运营多维表格", "pre.bt.channel": "飞书应用(提供 App 凭据)", "pre.bt.noChannel": "请先添加一个飞书应用机器人渠道", "pre.bt.channelMissing": "渠道已删除", "pre.bt.appTokenPh": "粘贴多维表格链接或 app_token", "pre.bt.source": "数据来源", "pre.bt.src.widget": "看板组件", "pre.bt.src.sql": "SQL 查询", "pre.bt.widget": "组件", "pre.bt.mode": "写入方式", "pre.bt.mode.replace": "覆盖", "pre.bt.mode.append": "追加", "pre.bt.modeHint.replace": "每次先删除表中全部记录,再写入最新结果。", "pre.bt.modeHint.append": "每次把结果追加到表末尾,保留已有记录。", "pre.bt.schedule": "定时", "pre.bt.daily": "每天同步", "pre.bt.dailyAt": "每天 {time}", "pre.bt.permHint": "需要在多维表格「… → 更多 → 添加文档应用」中添加该应用(可编辑),并为应用开通 bitable:app 权限。", "pre.bt.run": "立即同步", "pre.bt.ran": "同步完成:写入 {created} 条,删除 {deleted} 条", "pre.bt.lastOk": "成功 · {n} 条", "pre.bt.lastErr": "失败", "pre.bt.never": "未运行", "pre.bt.deleteConfirm": "删除同步任务「{name}」?",
|
|
101
|
+
'mcp.nav': 'MCP 接入', 'mcp.title': 'MCP 接入', 'mcp.desc': '让 Claude Desktop、Cursor、Cline、VS Code 等 AI 工具通过 Model Context Protocol 使用这里的数据源:只读准入、访问控制、成员范围、行级权限与审计同样生效。',
|
|
102
|
+
'mcp.enabled': '已启用', 'mcp.disabled': '未启用', 'mcp.running': '运行中', 'mcp.stopped': '未运行', 'mcp.port': '端口', 'mcp.applyPort': '应用',
|
|
103
|
+
'mcp.endpoint': '端点', 'mcp.localOnly': '服务只监听本机 127.0.0.1,并拒绝非本机的 Origin / Host 请求;每个请求都需要令牌。', 'mcp.copy': '复制', 'mcp.copied': '已复制',
|
|
104
|
+
'mcp.tokens': '访问令牌', 'mcp.newToken': '新建令牌', 'mcp.tokenName': '名称', 'mcp.tokenNamePh': '例如:我的 Cursor', 'mcp.create': '创建令牌',
|
|
105
|
+
'mcp.scope': '权限范围', 'mcp.scope.owner': '本机用户(全部权限)', 'mcp.scope.member': '某个成员', 'mcp.scope.role': '角色 + 数据源',
|
|
106
|
+
'mcp.scopeHint.owner': '与在本页提问相同的权限,仍受访问控制策略约束。', 'mcp.scopeHint.member': '跟随该成员的角色、数据源范围和行级规则;成员被停用后令牌失效。', 'mcp.scopeHint.role': '只能访问所选数据源,可附加一条行级规则。',
|
|
107
|
+
'mcp.member': '成员', 'mcp.pickMember': '选择成员…', 'mcp.noMembers': '还没有成员,请先在「成员」中添加。', 'mcp.memberMissing': '成员已删除', 'mcp.memberDisabled': '成员已停用',
|
|
108
|
+
'mcp.role': '角色', 'mcp.role.admin': '管理员', 'mcp.role.analyst': '分析师', 'mcp.role.viewer': '只读', 'mcp.datasources': '数据源', 'mcp.allDs': '全部数据源',
|
|
109
|
+
'mcp.rowRule': '行级规则(可选)', 'mcp.rowColumn': '字段名', 'mcp.rowValues': '允许的值,逗号分隔', 'mcp.rowRuleHint': '查询含该字段的表时自动加上「字段 IN (值)」过滤。', 'mcp.rowRulesN': '{n} 条行级规则',
|
|
110
|
+
'mcp.err.name': '请填写令牌名称', 'mcp.err.member': '请选择成员', 'mcp.err.ds': '请至少选择一个数据源',
|
|
111
|
+
'mcp.freshTitle': '令牌「{name}」已创建', 'mcp.freshHint': '令牌只显示这一次,请立即复制保存。系统只保存它的哈希值,丢失后只能重新创建。', 'mcp.done': '我已保存',
|
|
112
|
+
'mcp.col.name': '名称', 'mcp.col.scope': '范围', 'mcp.col.created': '创建时间', 'mcp.col.lastUsed': '最近使用', 'mcp.never': '从未',
|
|
113
|
+
'mcp.revoke': '吊销', 'mcp.revokeConfirm': '吊销令牌「{name}」?', 'mcp.revokeHint': '使用该令牌的客户端将立即无法访问。', 'mcp.revoked': '令牌已吊销',
|
|
114
|
+
'mcp.noTokens': '还没有令牌', 'mcp.noTokensHint': '创建一个令牌,再把下面的配置填入 AI 工具。',
|
|
115
|
+
'mcp.setup': '客户端配置', 'mcp.client.claude': 'Claude Desktop', 'mcp.client.cursor': 'Cursor', 'mcp.client.vscode': 'VS Code',
|
|
116
|
+
'mcp.where.claude': '写入 claude_desktop_config.json(通过 stdio 桥接 npx nl2sql-mcp,需要 Node.js 18+)。', 'mcp.where.cursor': '写入 ~/.cursor/mcp.json 或项目的 .cursor/mcp.json(直接走 HTTP)。', 'mcp.where.vscode': '写入 .vscode/mcp.json(直接走 HTTP)。',
|
|
117
|
+
'mcp.placeholderHint': '把 <YOUR_TOKEN> 替换为你的令牌。', 'mcp.docsHint': '详细说明见插件文档 docs/mcp.md。使用期间请保持 DeepSeek Harness 运行。',
|
|
118
|
+
'rc.title': "对账", 'rc.hint': "对比两个数据源(或两条查询)的结果:按键列把行对上,找出只在一侧存在的行和值不一致的列;可每天定时运行并推送差异。", 'rc.add': "新建对账", 'rc.empty': "还没有对账任务", 'rc.emptyHint': "例如:核对订单库与财务库每天的笔数和金额。", 'rc.name': "名称", 'rc.namePh': "如:订单 vs 财务 · 日金额", 'rc.left': "左侧", 'rc.right': "右侧", 'rc.leftCol': "左侧列", 'rc.rightCol': "右侧列(同名可留空)", 'rc.keys': "键列", 'rc.keysHint': "用来把两边的行对上的列(如订单号,或 日期 + 渠道)。", 'rc.addKey': "添加键列", 'rc.compare': "比较列", 'rc.compareHint': "需要一致的值;数值可设置容差(绝对值,或相对比例如 0.001 = 0.1%)。", 'rc.addCompare': "添加比较列", 'rc.tolerance': "容差", 'rc.tolMode': "容差类型", 'rc.tolAbs': "绝对", 'rc.tolRel': "相对", 'rc.trim': "比较文本前去掉首尾空格", 'rc.ignoreCase': "忽略大小写", 'rc.daily': "每天定时运行", 'rc.dailyAt': "每天 {time}", 'rc.manual': "手动", 'rc.noPush': "不推送", 'rc.notifyOn': "推送时机", 'rc.notify.diff': "有差异时", 'rc.notify.always': "每次运行", 'rc.dryRun': "试运行", 'rc.dryResult': "试运行结果(未保存)", 'rc.saved': "已保存对账任务", 'rc.runNow': "立即运行", 'rc.pushed': "已推送", 'rc.history': "运行记录", 'rc.historyOf': "运行记录 · {name}", 'rc.allRuns': "全部任务", 'rc.runOf': "对账结果 · {name}", 'rc.close': "关闭", 'rc.deleteConfirm': "删除对账任务「{name}」?", 'rc.detail': "详情", 'rc.push': "推送", 'rc.scheduled': "定时", 'rc.never': "未运行", 'rc.status.ok': "一致", 'rc.status.diff': "有差异", 'rc.status.error': "失败", 'rc.matchRate': "一致率", 'rc.matched': "匹配的键", 'rc.mismatched': "值不一致的行", 'rc.rowsOf': "{side} 行数", 'rc.onlyIn': "仅{side}", 'rc.totalOf': "{side} 合计", 'rc.col': "比较列", 'rc.mismatchRows': "不一致行", 'rc.diff': "差值", 'rc.hasDiff': "发现差异,一致率 {rate}", 'rc.noDiff': "两边一致,一致率 {rate}", 'rc.incomplete': "结果不完整(已截断)", 'rc.short.onlyLeft': "仅左侧 {n}", 'rc.short.onlyRight': "仅右侧 {n}", 'rc.short.mismatch': "不一致 {n} 行", 'rc.tab.summary': "汇总", 'rc.tab.mismatches': "值不一致", 'rc.tab.only': "仅{side}", 'rc.tool': "对账", 'rc.toolRunning': "正在对账",
|
|
119
|
+
"sub.nav": "群订阅",
|
|
120
|
+
"sub.title": "群订阅",
|
|
121
|
+
"sub.desc": "IM 群里通过机器人创建的定时订阅和告警,按群分组;推送发回原来的群。",
|
|
122
|
+
"sub.refresh": "刷新",
|
|
123
|
+
"sub.empty": "还没有群订阅",
|
|
124
|
+
"sub.emptyHint": "在群里 @机器人 说「每天 9 点把这个发到群里」或「超过 1000 提醒我」即可创建。",
|
|
125
|
+
"sub.col.item": "内容",
|
|
126
|
+
"sub.col.creator": "创建人",
|
|
127
|
+
"sub.col.schedule": "时间",
|
|
128
|
+
"sub.col.last": "最近推送",
|
|
129
|
+
"sub.col.status": "状态",
|
|
130
|
+
"sub.kind.subscription": "订阅",
|
|
131
|
+
"sub.kind.alert": "告警",
|
|
132
|
+
"sub.status.active": "运行中",
|
|
133
|
+
"sub.status.paused": "已暂停",
|
|
134
|
+
"sub.status.suspended": "已停用",
|
|
135
|
+
"sub.next": "下次",
|
|
136
|
+
"sub.pause": "暂停",
|
|
137
|
+
"sub.resume": "恢复",
|
|
138
|
+
"sub.delete": "删除",
|
|
139
|
+
"sub.deleteConfirm": "删除「{name}」?",
|
|
140
|
+
"sub.done.pause": "已暂停",
|
|
141
|
+
"sub.done.resume": "已恢复",
|
|
142
|
+
"sub.done.delete": "已删除",
|
|
18
143
|
'mb.pick.people': "从和机器人说过话的人中选择",
|
|
19
144
|
'mb.pick.chats': "从机器人所在的群中选择",
|
|
20
145
|
'mb.pick.refresh': "刷新",
|
|
@@ -1030,8 +1155,143 @@ window.__ModuleLoader__.load({
|
|
|
1030
1155
|
'tool.running': '正在查询…',
|
|
1031
1156
|
'tool.failed': '查询失败',
|
|
1032
1157
|
'error.generic': '操作失败:{message}',
|
|
1158
|
+
'dash.copyName': '{name} 副本',
|
|
1159
|
+
'set.leaveTitle': '放弃未保存的设置?',
|
|
1160
|
+
'set.leaveMsg': '设置中有未保存的修改,离开后这些修改会丢失。',
|
|
1161
|
+
'set.leaveOk': '放弃并离开',
|
|
1162
|
+
'fb.deleteConfirm': '删除这条反馈?',
|
|
1163
|
+
'hist.source.telegrambot': 'Telegram 机器人',
|
|
1164
|
+
'hist.source.wecombot': '企业微信机器人',
|
|
1165
|
+
'hist.source.dingtalkbot': '钉钉机器人',
|
|
1166
|
+
'hist.source.unknown': '未知来源',
|
|
1167
|
+
'hist.source.genericBot': '{platform} 机器人',
|
|
1168
|
+
'filter.valuesPh': '值,用逗号分隔',
|
|
1169
|
+
'policy.tablesLabel': '可访问的表',
|
|
1033
1170
|
}
|
|
1034
1171
|
const en = {
|
|
1172
|
+
'admin.group.insight': "Insight & automation",
|
|
1173
|
+
'admin.group.connect': "Integrations",
|
|
1174
|
+
"fq.title": "Data freshness",
|
|
1175
|
+
"fq.refresh": "Refresh",
|
|
1176
|
+
"fq.checkedAt": "Checked {time}",
|
|
1177
|
+
"fq.staleCount": "{n} stale table(s)",
|
|
1178
|
+
"fq.warnCount": "{n} table(s) lagging",
|
|
1179
|
+
"fq.allFresh": "All data up to date",
|
|
1180
|
+
"fq.lagMin": "{n} min",
|
|
1181
|
+
"fq.lagHour": "{n} h",
|
|
1182
|
+
"fq.lagDay": "{n} days",
|
|
1183
|
+
"fq.today": "today {time}",
|
|
1184
|
+
"fq.yesterday": "yesterday {time}",
|
|
1185
|
+
"fq.barItem": "{table}: latest data from {when} ({lag} behind)",
|
|
1186
|
+
"fq.barMore": "+{n} more tables",
|
|
1187
|
+
"fq.st.error": "Check failed",
|
|
1188
|
+
"fq.st.empty": "No data",
|
|
1189
|
+
"fq.running": "Checking data freshness…",
|
|
1190
|
+
"fq.failed": "Check failed",
|
|
1191
|
+
"fq.noTime": "No time column",
|
|
1192
|
+
"fq.notAnalyzed": "This data source has not been analyzed yet; analyze it under Schema first.",
|
|
1193
|
+
"fq.noTables": "No tables with a time column.",
|
|
1194
|
+
"fq.col.table": "Table",
|
|
1195
|
+
"fq.col.column": "Time column",
|
|
1196
|
+
"fq.col.latest": "Latest data",
|
|
1197
|
+
"fq.col.lag": "Lag",
|
|
1198
|
+
"fq.col.threshold": "Threshold (h)",
|
|
1199
|
+
"fq.thresholdFor": "Lag threshold for {table} (hours)",
|
|
1200
|
+
"fq.events": "Recent delay events",
|
|
1201
|
+
"fq.evStale": "stale for {lag}",
|
|
1202
|
+
"fq.evRecovered": "recovered",
|
|
1203
|
+
"fq.pushed": "pushed",
|
|
1204
|
+
"fq.q.title": "Data quality patrol",
|
|
1205
|
+
"fq.q.lastRun": "Last run {time}",
|
|
1206
|
+
"fq.q.run": "Run now",
|
|
1207
|
+
"fq.q.running": "Running…",
|
|
1208
|
+
"fq.q.empty": "No runs yet. Click \"Run now\" to start.",
|
|
1209
|
+
"fq.q.done": "Patrol finished, health score {score}",
|
|
1210
|
+
"fq.q.history": "Recent scores",
|
|
1211
|
+
"fq.q.score": "Score",
|
|
1212
|
+
"fq.q.rows": "Rows",
|
|
1213
|
+
"fq.q.issues": "Issues",
|
|
1214
|
+
"fq.q.noIssues": "No issues found",
|
|
1215
|
+
"fq.more": "{n} more",
|
|
1216
|
+
"fq.less": "Show less",
|
|
1217
|
+
"fq.admin.nav": "Data health",
|
|
1218
|
+
"fq.admin.title": "Data health",
|
|
1219
|
+
"fq.admin.desc": "Watch whether tables keep receiving new data, and patrol them for nulls, duplicate keys, outliers, future timestamps and negative amounts.",
|
|
1220
|
+
"fq.admin.nextF": "Next freshness check {time}",
|
|
1221
|
+
"fq.admin.fOff": "Scheduled freshness checks are off",
|
|
1222
|
+
"fq.admin.nextQ": "Quality patrol daily at {time}",
|
|
1223
|
+
"fq.admin.qOff": "Daily quality patrol is off",
|
|
1224
|
+
"fq.admin.noDs": "Add a data source first",
|
|
1225
|
+
"fq.set.title": "Settings",
|
|
1226
|
+
"fq.set.fEnabled": "Check data freshness on a schedule",
|
|
1227
|
+
"fq.set.qEnabled": "Daily data quality patrol",
|
|
1228
|
+
"fq.set.interval": "Freshness check interval (min)",
|
|
1229
|
+
"fq.set.time": "Quality patrol time",
|
|
1230
|
+
"fq.set.staleHours": "Default lag threshold (hours)",
|
|
1231
|
+
"fq.set.drop": "Push when the score drops by more than (points)",
|
|
1232
|
+
"fq.set.fChannel": "Delay notification channel",
|
|
1233
|
+
"fq.set.qChannel": "Score drop notification channel",
|
|
1234
|
+
"fq.set.noPush": "Do not push",
|
|
1235
|
+
"fq.set.hint": "Per-table thresholds can be set in the table above (0 = not monitored). A delay is pushed once per stale episode, and once more when it recovers.",
|
|
1236
|
+
"fq.saved": "Saved",
|
|
1237
|
+
'pro.close': 'Close', 'pro.sendNow': 'Send now', 'pro.sent': 'Sent', 'pro.deleteConfirm': 'Delete "{name}"?', 'pro.weekday': 'Weekday',
|
|
1238
|
+
'pro.wd.0': 'Sunday', 'pro.wd.1': 'Monday', 'pro.wd.2': 'Tuesday', 'pro.wd.3': 'Wednesday', 'pro.wd.4': 'Thursday', 'pro.wd.5': 'Friday', 'pro.wd.6': 'Saturday',
|
|
1239
|
+
'pro.fc.menu': 'Forecast next {n} days', 'pro.fc.title': 'Forecast', 'pro.fc.heading': 'Forecast, next {n} periods', 'pro.fc.running': 'Forecasting…', 'pro.fc.forecast': 'Forecast', 'pro.fc.band': '95% interval', 'pro.fc.lower': 'Lower', 'pro.fc.upper': 'Upper',
|
|
1240
|
+
'pro.fc.hw': 'Holt-Winters · weekly', 'pro.fc.holt': 'Holt linear trend', 'pro.fc.horizon': 'Horizon', 'pro.fc.nDays': '{n} periods', 'pro.fc.filled': '{n} days filled', 'pro.fc.filledHint': 'Days without data count as 0',
|
|
1241
|
+
'pro.fc.note': 'A statistical extrapolation of the history; promotions, holidays and other known events are not taken into account. The band is the 95% prediction interval.',
|
|
1242
|
+
'pro.goals.title': 'Goals', 'pro.goals.desc': 'Monthly / quarterly / yearly targets for metrics: the period end is projected from the daily run rate (weekday-aware), and progress shows on dashboards.', 'pro.goals.add': 'New goal',
|
|
1243
|
+
'pro.goals.name': 'Name', 'pro.goals.namePh': 'e.g. November transactions', 'pro.goals.period': 'Period', 'pro.goals.target': 'Target', 'pro.goals.direction': 'Direction', 'pro.goals.dashboard': 'Show on dashboard', 'pro.goals.anyBoard': 'Dashboards using the same data source',
|
|
1244
|
+
'pro.goals.empty': 'No goals yet', 'pro.goals.emptyHint': 'Define a metric in Knowledge → Metrics first, then set a goal for it.', 'pro.goals.actual': 'Actual', 'pro.goals.projected': 'Projected', 'pro.goals.proj': 'Projected {v} ({p})', 'pro.goals.weekdayAware': 'weekday-aware',
|
|
1245
|
+
'pro.goals.weekly': 'Weekly goal report', 'pro.goals.weeklyOn': 'Push goal progress every week', 'pro.goals.weeklyHint': 'At the chosen time, progress and projections of all goals are pushed to the channel.',
|
|
1246
|
+
'pro.period.month': 'Month', 'pro.period.quarter': 'Quarter', 'pro.period.year': 'Year', 'pro.dir.up': 'At least', 'pro.dir.down': 'At most',
|
|
1247
|
+
'pro.st.on-track': 'On track', 'pro.st.at-risk': 'At risk', 'pro.st.achieved': 'Achieved', 'pro.st.missed': 'Missed', 'pro.st.error': 'Error',
|
|
1248
|
+
'pro.dg.title': 'Daily digest', 'pro.dg.desc': 'Every day, compares yesterday with the 7-day average and the same day last week, explains notable changes and pushes a short summary with charts.', 'pro.dg.add': 'New digest',
|
|
1249
|
+
'pro.dg.name': 'Name', 'pro.dg.namePh': 'e.g. Daily transactions', 'pro.dg.source': 'Content', 'pro.dg.src.metrics': 'Library metrics', 'pro.dg.src.dashboard': 'Dashboard trend charts', 'pro.dg.metrics': 'Metrics', 'pro.dg.dashboard': 'Dashboard',
|
|
1250
|
+
'pro.dg.language': 'Language', 'pro.dg.threshold': 'Change threshold (%)', 'pro.dg.hint': 'A metric is notable when yesterday deviates from the 7-day average by more than 2 standard deviations or by more than the threshold; notable changes are attributed to the main dimension.',
|
|
1251
|
+
'pro.dg.empty': 'No daily digests yet', 'pro.dg.emptyHint': 'Pick a few key metrics and get them in your group chat every morning.', 'pro.dg.preview': 'Preview', 'pro.dg.previewing': 'Generating preview…', 'pro.dg.dailyAt': 'Daily at {time}',
|
|
1252
|
+
"pre.working": "Generating…", "pre.exported": "Exported", "pre.exportPpt": "Export PPT", "pre.exportImage": "Export long image", "pre.sendImage": "Send long image to IM", "pre.sendImageHint": "Render the whole dashboard as one tall image and send it to a Lark app bot or Telegram bot channel.", "pre.noImageChannel": "No channel can receive images: add a Lark app bot or Telegram bot under Admin → Channels first.", "pre.channel": "Channel", "pre.send": "Send", "pre.imageSent": "Long image sent to \"{channel}\"", "pre.mode": "Presentation mode", "pre.loading": "Loading…", "pre.lastRefresh": "Last refresh {time}", "pre.clock": "Current time", "pre.refresh": "Auto refresh", "pre.rotate": "Rotate boards", "pre.rotateOff": "Off", "pre.everySec": "Every {s} s", "pre.prev": "Previous dashboard (←)", "pre.next": "Next dashboard (→)", "pre.exit": "Exit presentation mode", "pre.exitHint": "Exit (Esc)", "pre.bt.nav": "Bitable sync", "pre.bt.title": "Bitable sync", "pre.bt.desc": "Write a dashboard widget or SQL result into a Lark Bitable: missing fields are created (text / number / date); optionally synced daily.", "pre.bt.add": "New sync", "pre.bt.empty": "No syncs yet", "pre.bt.emptyHint": "For example: every morning, sync \"revenue by channel\" to the ops team's Bitable.", "pre.bt.name": "Name", "pre.bt.namePh": "e.g. Channel daily → Ops Bitable", "pre.bt.channel": "Lark app (provides app credentials)", "pre.bt.noChannel": "Add a Lark app bot channel first", "pre.bt.channelMissing": "Channel deleted", "pre.bt.appTokenPh": "Paste the Bitable link or app_token", "pre.bt.source": "Source", "pre.bt.src.widget": "Dashboard widget", "pre.bt.src.sql": "SQL query", "pre.bt.widget": "Widget", "pre.bt.mode": "Write mode", "pre.bt.mode.replace": "Replace", "pre.bt.mode.append": "Append", "pre.bt.modeHint.replace": "Delete all records first, then write the latest result.", "pre.bt.modeHint.append": "Append the result each time, keeping existing records.", "pre.bt.schedule": "Schedule", "pre.bt.daily": "Sync daily", "pre.bt.dailyAt": "Daily {time}", "pre.bt.permHint": "Add the app to the Bitable (… → More → Add document app, with edit access) and grant it the bitable:app scope.", "pre.bt.run": "Sync now", "pre.bt.ran": "Synced: {created} written, {deleted} deleted", "pre.bt.lastOk": "OK · {n} rows", "pre.bt.lastErr": "Failed", "pre.bt.never": "Never run", "pre.bt.deleteConfirm": "Delete sync \"{name}\"?",
|
|
1253
|
+
'mcp.nav': 'MCP access', 'mcp.title': 'MCP access', 'mcp.desc': 'Let AI tools such as Claude Desktop, Cursor, Cline and VS Code use these data sources through the Model Context Protocol. Read-only admission, access policies, member scopes, row-level rules and audit apply as usual.',
|
|
1254
|
+
'mcp.enabled': 'Enabled', 'mcp.disabled': 'Disabled', 'mcp.running': 'Running', 'mcp.stopped': 'Not running', 'mcp.port': 'Port', 'mcp.applyPort': 'Apply',
|
|
1255
|
+
'mcp.endpoint': 'Endpoint', 'mcp.localOnly': 'The server listens on 127.0.0.1 only and rejects non-local Origin / Host headers; every request needs a token.', 'mcp.copy': 'Copy', 'mcp.copied': 'Copied',
|
|
1256
|
+
'mcp.tokens': 'Access tokens', 'mcp.newToken': 'New token', 'mcp.tokenName': 'Name', 'mcp.tokenNamePh': 'e.g. My Cursor', 'mcp.create': 'Create token',
|
|
1257
|
+
'mcp.scope': 'Scope', 'mcp.scope.owner': 'Local owner (full access)', 'mcp.scope.member': 'A member', 'mcp.scope.role': 'Role + data sources',
|
|
1258
|
+
'mcp.scopeHint.owner': 'Same access as asking on this page; access policies still apply.', 'mcp.scopeHint.member': 'Follows the member\'s role, data source scope and row-level rules; the token stops working when the member is disabled.', 'mcp.scopeHint.role': 'Only the selected data sources, with an optional row-level rule.',
|
|
1259
|
+
'mcp.member': 'Member', 'mcp.pickMember': 'Choose a member…', 'mcp.noMembers': 'No members yet; add one under Members first.', 'mcp.memberMissing': 'Member deleted', 'mcp.memberDisabled': 'Member disabled',
|
|
1260
|
+
'mcp.role': 'Role', 'mcp.role.admin': 'Admin', 'mcp.role.analyst': 'Analyst', 'mcp.role.viewer': 'Viewer', 'mcp.datasources': 'Data sources', 'mcp.allDs': 'All data sources',
|
|
1261
|
+
'mcp.rowRule': 'Row-level rule (optional)', 'mcp.rowColumn': 'Column', 'mcp.rowValues': 'Allowed values, comma-separated', 'mcp.rowRuleHint': 'Queries on tables with this column get "column IN (values)" added automatically.', 'mcp.rowRulesN': '{n} row-level rules',
|
|
1262
|
+
'mcp.err.name': 'Enter a token name', 'mcp.err.member': 'Choose a member', 'mcp.err.ds': 'Select at least one data source',
|
|
1263
|
+
'mcp.freshTitle': 'Token "{name}" created', 'mcp.freshHint': 'The token is shown only once — copy it now. Only its hash is stored; if lost, create a new one.', 'mcp.done': 'I saved it',
|
|
1264
|
+
'mcp.col.name': 'Name', 'mcp.col.scope': 'Scope', 'mcp.col.created': 'Created', 'mcp.col.lastUsed': 'Last used', 'mcp.never': 'Never',
|
|
1265
|
+
'mcp.revoke': 'Revoke', 'mcp.revokeConfirm': 'Revoke token "{name}"?', 'mcp.revokeHint': 'Clients using this token lose access immediately.', 'mcp.revoked': 'Token revoked',
|
|
1266
|
+
'mcp.noTokens': 'No tokens yet', 'mcp.noTokensHint': 'Create a token, then add the configuration below to your AI tool.',
|
|
1267
|
+
'mcp.setup': 'Client configuration', 'mcp.client.claude': 'Claude Desktop', 'mcp.client.cursor': 'Cursor', 'mcp.client.vscode': 'VS Code',
|
|
1268
|
+
'mcp.where.claude': 'Add to claude_desktop_config.json (stdio bridge via npx nl2sql-mcp; needs Node.js 18+).', 'mcp.where.cursor': 'Add to ~/.cursor/mcp.json or the project\'s .cursor/mcp.json (HTTP).', 'mcp.where.vscode': 'Add to .vscode/mcp.json (HTTP).',
|
|
1269
|
+
'mcp.placeholderHint': 'Replace <YOUR_TOKEN> with your token.', 'mcp.docsHint': 'See docs/mcp.md in the plugin for details. Keep DeepSeek Harness running while you use it.',
|
|
1270
|
+
'rc.title': "Reconciliation", 'rc.hint': "Compare the results of two data sources (or two queries): rows are matched on key columns to find rows present on one side only and values that differ; run daily and push the differences.", 'rc.add': "New reconciliation", 'rc.empty': "No reconciliations yet", 'rc.emptyHint': "For example: check daily order counts and amounts between the order database and the finance database.", 'rc.name': "Name", 'rc.namePh': "e.g. Orders vs finance · daily amount", 'rc.left': "Left", 'rc.right': "Right", 'rc.leftCol': "Left column", 'rc.rightCol': "Right column (blank = same name)", 'rc.keys': "Key columns", 'rc.keysHint': "Columns that match rows across both sides (an order number, or date + channel).", 'rc.addKey': "Add key", 'rc.compare': "Compare columns", 'rc.compareHint': "Values that must agree; numbers can have a tolerance (absolute, or relative such as 0.001 = 0.1%).", 'rc.addCompare': "Add compare column", 'rc.tolerance': "Tolerance", 'rc.tolMode': "Tolerance type", 'rc.tolAbs': "Absolute", 'rc.tolRel': "Relative", 'rc.trim': "Trim spaces before comparing text", 'rc.ignoreCase': "Ignore case", 'rc.daily': "Run daily", 'rc.dailyAt': "Daily {time}", 'rc.manual': "Manual", 'rc.noPush': "Do not push", 'rc.notifyOn': "Push when", 'rc.notify.diff': "Differences found", 'rc.notify.always': "Every run", 'rc.dryRun': "Dry run", 'rc.dryResult': "Dry run result (not saved)", 'rc.saved': "Reconciliation saved", 'rc.runNow': "Run now", 'rc.pushed': "pushed", 'rc.history': "Run history", 'rc.historyOf': "Run history · {name}", 'rc.allRuns': "All jobs", 'rc.runOf': "Reconciliation result · {name}", 'rc.close': "Close", 'rc.deleteConfirm': "Delete reconciliation \"{name}\"?", 'rc.detail': "Details", 'rc.push': "Push", 'rc.scheduled': "scheduled", 'rc.never': "Never run", 'rc.status.ok': "Consistent", 'rc.status.diff': "Differences", 'rc.status.error': "Failed", 'rc.matchRate': "Match rate", 'rc.matched': "Matched keys", 'rc.mismatched': "Rows with different values", 'rc.rowsOf': "{side} rows", 'rc.onlyIn': "Only in {side}", 'rc.totalOf': "{side} total", 'rc.col': "Column", 'rc.mismatchRows': "Different rows", 'rc.diff': "Difference", 'rc.hasDiff': "Differences found, match rate {rate}", 'rc.noDiff': "Both sides agree, match rate {rate}", 'rc.incomplete': "incomplete (truncated)", 'rc.short.onlyLeft': "left only {n}", 'rc.short.onlyRight': "right only {n}", 'rc.short.mismatch': "{n} rows differ", 'rc.tab.summary': "Summary", 'rc.tab.mismatches': "Different values", 'rc.tab.only': "Only in {side}", 'rc.tool': "Reconciliation", 'rc.toolRunning': "Reconciling",
|
|
1271
|
+
"sub.nav": "Chat subscriptions",
|
|
1272
|
+
"sub.title": "Chat subscriptions",
|
|
1273
|
+
"sub.desc": "Scheduled deliveries and alerts created from IM chats through a bot, grouped by chat; they are delivered back to that chat.",
|
|
1274
|
+
"sub.refresh": "Refresh",
|
|
1275
|
+
"sub.empty": "No chat subscriptions yet",
|
|
1276
|
+
"sub.emptyHint": "In a chat, @-mention the bot with \"send this here every day at 9\" or \"tell me when it exceeds 1000\".",
|
|
1277
|
+
"sub.col.item": "Item",
|
|
1278
|
+
"sub.col.creator": "Created by",
|
|
1279
|
+
"sub.col.schedule": "Schedule",
|
|
1280
|
+
"sub.col.last": "Last delivery",
|
|
1281
|
+
"sub.col.status": "Status",
|
|
1282
|
+
"sub.kind.subscription": "Subscription",
|
|
1283
|
+
"sub.kind.alert": "Alert",
|
|
1284
|
+
"sub.status.active": "Active",
|
|
1285
|
+
"sub.status.paused": "Paused",
|
|
1286
|
+
"sub.status.suspended": "Suspended",
|
|
1287
|
+
"sub.next": "Next",
|
|
1288
|
+
"sub.pause": "Pause",
|
|
1289
|
+
"sub.resume": "Resume",
|
|
1290
|
+
"sub.delete": "Delete",
|
|
1291
|
+
"sub.deleteConfirm": "Delete \"{name}\"?",
|
|
1292
|
+
"sub.done.pause": "Paused",
|
|
1293
|
+
"sub.done.resume": "Resumed",
|
|
1294
|
+
"sub.done.delete": "Deleted",
|
|
1035
1295
|
'mb.pick.people': "Pick someone who talked to a bot",
|
|
1036
1296
|
'mb.pick.chats': "Pick a group the bot is in",
|
|
1037
1297
|
'mb.pick.refresh': "Refresh",
|
|
@@ -1426,7 +1686,7 @@ window.__ModuleLoader__.load({
|
|
|
1426
1686
|
'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
1687
|
'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
1688
|
'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.',
|
|
1689
|
+
'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.',
|
|
1430
1690
|
'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
|
|
1431
1691
|
'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
|
|
1432
1692
|
'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 +1756,12 @@ window.__ModuleLoader__.load({
|
|
|
1496
1756
|
'settings.limits': 'Query limits', 'settings.maxRows': 'Max rows per query', 'settings.timeoutMs': 'Query timeout (ms)', 'settings.save': 'Save settings', 'settings.saved': 'Saved',
|
|
1497
1757
|
'composer.none': 'No data source', 'composer.title': 'Data source queried by this session',
|
|
1498
1758
|
'tool.query': 'Data query', 'tool.chart': 'Chart', 'tool.running': 'Querying…', 'tool.failed': 'Query failed', 'error.generic': 'Failed: {message}',
|
|
1759
|
+
'dash.copyName': '{name} copy',
|
|
1760
|
+
'set.leaveTitle': 'Discard unsaved settings?', 'set.leaveMsg': 'You have unsaved setting changes; they will be lost if you leave.', 'set.leaveOk': 'Discard and leave',
|
|
1761
|
+
'fb.deleteConfirm': 'Delete this feedback?',
|
|
1762
|
+
'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',
|
|
1763
|
+
'filter.valuesPh': 'Values, comma-separated',
|
|
1764
|
+
'policy.tablesLabel': 'Accessible tables',
|
|
1499
1765
|
}
|
|
1500
1766
|
|
|
1501
1767
|
// --------------------------------------------------------------- RPC
|
|
@@ -1978,6 +2244,100 @@ window.__ModuleLoader__.load({
|
|
|
1978
2244
|
.n2s-pickchip:disabled{opacity:.55;cursor:default}
|
|
1979
2245
|
.n2s-pickchip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
|
|
1980
2246
|
.n2s-idpicker{gap:6px}
|
|
2247
|
+
.n2s-sub-group { margin-bottom: 12px; }
|
|
2248
|
+
.n2s-sub-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
2249
|
+
.n2s-sub-chat { font-weight: 600; }
|
|
2250
|
+
.n2s-sub-kind { font-size: 11px; }
|
|
2251
|
+
.n2s-sub-err { color: var(--n2s-err); white-space: normal; max-width: 260px; }
|
|
2252
|
+
/* ---- reconciliation (rc) ---- */
|
|
2253
|
+
.n2s-rc-panes{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:10px 0}
|
|
2254
|
+
@media (max-width:820px){.n2s-rc-panes{grid-template-columns:1fr}}
|
|
2255
|
+
.n2s-rc-pane{display:flex;flex-direction:column;gap:8px;padding:10px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);min-width:0}
|
|
2256
|
+
.n2s-rc-pane-title{font-weight:500;margin-bottom:2px}
|
|
2257
|
+
.n2s-rc-maprow{display:flex;align-items:center;gap:6px;margin-top:6px}
|
|
2258
|
+
.n2s-rc-maprow .n2s-input{flex:1;min-width:0}
|
|
2259
|
+
.n2s-rc-maprow .n2s-input.n2s-rc-tol{flex:0 0 100px}
|
|
2260
|
+
.n2s-rc-dry{margin-top:14px;padding-top:12px;border-top:.5px solid var(--n2s-line)}
|
|
2261
|
+
.n2s-rc-stats{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
|
2262
|
+
.n2s-rc-stat{padding:8px 12px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);min-width:96px}
|
|
2263
|
+
.n2s-rc-stat-v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
2264
|
+
.n2s-rc-stat-l{font-size:12px;color:var(--n2s-muted)}
|
|
2265
|
+
.n2s-rc-stat.bad .n2s-rc-stat-v,.n2s-rc-bad{color:var(--n2s-err)}
|
|
2266
|
+
.n2s-rc-run{cursor:pointer}
|
|
2267
|
+
.n2s-rc-run:hover td{background:color-mix(in srgb,currentColor 4%,transparent)}
|
|
2268
|
+
.n2s-mcp .n2s-card{margin-bottom:12px}
|
|
2269
|
+
.n2s-mcp-endpoint{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0 6px}
|
|
2270
|
+
.n2s-mcp-endpoint code{padding:4px 8px;border-radius:var(--n2s-radius-sm);background:var(--n2s-hover);font-size:12.5px;word-break:break-all}
|
|
2271
|
+
.n2s-mcp-form{display:grid;gap:12px}
|
|
2272
|
+
.n2s-mcp-snippets{margin-top:10px}
|
|
2273
|
+
.n2s-mcp-code{position:relative}
|
|
2274
|
+
.n2s-mcp-code pre{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre;font-size:12px}
|
|
2275
|
+
.n2s-mcp-codeact{position:absolute;top:6px;right:6px}
|
|
2276
|
+
.n2s-mcp-fresh{border-color:var(--n2s-accent)}
|
|
2277
|
+
/* ---- presentation mode (feature present) ---- */
|
|
2278
|
+
.n2s-pre{position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;gap:18px;padding:22px 28px 28px;overflow:auto;box-sizing:border-box;background:#0b1020;color:#e8ecf4;font-size:16px;line-height:1.5;outline:none;
|
|
2279
|
+
--dsw-alias-label-primary:#e8ecf4;--dsw-alias-label-secondary:#9aa4b8;--dsw-alias-label-tertiary:#6f7a90;--dsw-alias-border-l1:rgba(255,255,255,.10);--dsw-alias-border-l2:rgba(255,255,255,.16);--dsw-alias-bg-layer-1:#141b2e;--dsw-alias-bg-base:#0b1020;--n2s-muted:#9aa4b8;--n2s-faint:#6f7a90;--n2s-line:rgba(255,255,255,.10);--n2s-line-2:rgba(255,255,255,.16);--n2s-hover:rgba(255,255,255,.06);--n2s-fill:rgba(255,255,255,.05);--n2s-accent:#5b8cff;--n2s-ok:#34c759;--n2s-warn:#ffb020;--n2s-err:#ff5c5c}
|
|
2280
|
+
.n2s-pre-head{display:flex;align-items:center;gap:24px;flex-wrap:wrap}
|
|
2281
|
+
.n2s-pre-titles{flex:1;min-width:240px}
|
|
2282
|
+
.n2s-pre-titles h1{margin:0;font-size:clamp(26px,2.6vw,40px);font-weight:700;letter-spacing:-.01em;line-height:1.2}
|
|
2283
|
+
.n2s-pre-sub{color:#9aa4b8;font-size:14px;margin-top:4px}
|
|
2284
|
+
.n2s-pre-clock{text-align:right;font-variant-numeric:tabular-nums}
|
|
2285
|
+
.n2s-pre-clock .time{font-size:clamp(26px,2.4vw,38px);font-weight:600;line-height:1.1}
|
|
2286
|
+
.n2s-pre-clock .date{color:#9aa4b8;font-size:13px}
|
|
2287
|
+
.n2s-pre-ctl{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
2288
|
+
.n2s-pre-ctl label{display:inline-flex;align-items:center;gap:6px;color:#9aa4b8;font-size:13px}
|
|
2289
|
+
.n2s-pre-ctl select{height:32px;padding:0 8px;border-radius:8px;border:1px solid rgba(255,255,255,.16);background:#141b2e;color:#e8ecf4;font:inherit;font-size:13px}
|
|
2290
|
+
.n2s-pre-btn{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:8px;border:1px solid rgba(255,255,255,.16);background:#141b2e;color:#e8ecf4;font:inherit;font-size:16px;cursor:pointer}
|
|
2291
|
+
.n2s-pre-btn:hover{background:#1d2640}
|
|
2292
|
+
.n2s-pre-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:18px;align-content:start}
|
|
2293
|
+
.n2s-pre-card{grid-column:span 3;min-width:0;padding:16px 18px;border-radius:14px;background:#141b2e;border:1px solid rgba(255,255,255,.08);box-shadow:0 8px 30px rgba(0,0,0,.25)}
|
|
2294
|
+
.n2s-pre-card.w-third{grid-column:span 2}
|
|
2295
|
+
.n2s-pre-card.w-full{grid-column:span 6}
|
|
2296
|
+
.n2s-pre-card-title{display:flex;align-items:center;font-size:clamp(16px,1.3vw,22px);font-weight:600;margin-bottom:8px}
|
|
2297
|
+
.n2s-pre-card .n2s-result-foot{display:none}
|
|
2298
|
+
.n2s-pre-card .n2s-chart{min-height:300px}
|
|
2299
|
+
.n2s-pre-card .n2s-kpi .v{font-size:clamp(34px,3.6vw,60px)}
|
|
2300
|
+
.n2s-pre-card .n2s-table{font-size:14px}
|
|
2301
|
+
.n2s-pre-empty{padding:80px 0;text-align:center;color:#9aa4b8;font-size:20px}
|
|
2302
|
+
@media (max-width:1100px){.n2s-pre-card,.n2s-pre-card.w-third{grid-column:span 6}}
|
|
2303
|
+
@media (min-width:1101px) and (max-width:1500px){.n2s-pre-card.w-third{grid-column:span 3}}
|
|
2304
|
+
.n2s-pre-dlg{margin-top:10px}
|
|
2305
|
+
|
|
2306
|
+
/* ---- proactive: forecast, goals, digest ---- */
|
|
2307
|
+
.n2s-pro-fc{margin-top:10px;padding:10px 12px;border:.5px dashed var(--n2s-line-2);border-radius:var(--n2s-radius-sm)}
|
|
2308
|
+
.n2s-pro-fc-title{display:inline-flex;align-items:center;gap:6px;font-weight:500;font-size:13px}
|
|
2309
|
+
.n2s-pro-chips{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
2310
|
+
.n2s-pro-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:.5px solid var(--n2s-line-2);font-size:12px;white-space:nowrap}
|
|
2311
|
+
.n2s-pro-bar{position:relative;display:inline-block;width:64px;height:5px;border-radius:999px;background:var(--n2s-fill);overflow:hidden;flex:none}
|
|
2312
|
+
.n2s-pro-bar .proj,.n2s-pro-bar .cur{position:absolute;left:0;top:0;bottom:0;border-radius:999px}
|
|
2313
|
+
.n2s-pro-bar .proj{background:color-mix(in srgb,var(--n2s-accent) 28%,transparent)}
|
|
2314
|
+
.n2s-pro-bar .cur{background:var(--n2s-accent)}
|
|
2315
|
+
.n2s-pro-bar.ok .cur{background:var(--n2s-ok)}.n2s-pro-bar.ok .proj{background:color-mix(in srgb,var(--n2s-ok) 28%,transparent)}
|
|
2316
|
+
.n2s-pro-bar.warn .cur{background:var(--n2s-warn)}.n2s-pro-bar.warn .proj{background:color-mix(in srgb,var(--n2s-warn) 28%,transparent)}
|
|
2317
|
+
.n2s-pro-bar.err .cur{background:var(--n2s-err)}
|
|
2318
|
+
.n2s-pro-bar .tick{position:absolute;top:0;bottom:0;width:1px;background:var(--dsw-alias-label-secondary)}
|
|
2319
|
+
.n2s-list2-row .n2s-pro-bar{width:140px}
|
|
2320
|
+
.n2s-pro-pct{font-variant-numeric:tabular-nums;color:var(--n2s-muted)}
|
|
2321
|
+
.n2s-pro-pct.ok{color:var(--n2s-ok)}.n2s-pro-pct.warn{color:color-mix(in srgb,var(--n2s-warn) 85%,black)}.n2s-pro-pct.err{color:var(--n2s-err)}
|
|
2322
|
+
.n2s-pro-preview{margin-top:10px;padding:12px;border-radius:var(--n2s-radius-sm);background:var(--n2s-fill)}
|
|
2323
|
+
.n2s-pro-preview-charts{display:grid;gap:10px;margin-top:10px}
|
|
2324
|
+
/* ---- data freshness / health (21a-freshness.js) ---- */
|
|
2325
|
+
.n2s-fq-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px;padding:6px 10px;border-radius:var(--n2s-radius-sm);font-size:12.5px;line-height:1.5;color:color-mix(in srgb,var(--n2s-warn) 80%,black);background:color-mix(in srgb,var(--n2s-warn) 12%,transparent)}
|
|
2326
|
+
.n2s-fq-panel{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius-sm);padding:6px 10px;margin-bottom:12px}
|
|
2327
|
+
.n2s-fq-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:2px 16px;margin-top:6px}
|
|
2328
|
+
.n2s-fq-item{display:flex;align-items:center;gap:6px;min-width:0;font-size:12.5px;padding:2px 0}
|
|
2329
|
+
.n2s-fq-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--n2s-faint)}
|
|
2330
|
+
.n2s-fq-dot.ok{background:var(--n2s-ok)}
|
|
2331
|
+
.n2s-fq-dot.warn{background:var(--n2s-warn)}
|
|
2332
|
+
.n2s-fq-dot.err{background:var(--n2s-err)}
|
|
2333
|
+
.n2s-fq-score{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:22px;padding:0 6px;border-radius:6px;font-weight:600;font-size:12px;background:var(--n2s-fill);color:var(--n2s-muted)}
|
|
2334
|
+
.n2s-fq-score.ok{color:var(--n2s-ok);background:color-mix(in srgb,var(--n2s-ok) 12%,transparent)}
|
|
2335
|
+
.n2s-fq-score.warn{color:color-mix(in srgb,var(--n2s-warn) 85%,black);background:color-mix(in srgb,var(--n2s-warn) 14%,transparent)}
|
|
2336
|
+
.n2s-fq-score.err{color:var(--n2s-err);background:color-mix(in srgb,var(--n2s-err) 10%,transparent)}
|
|
2337
|
+
.n2s-fq-summary{display:flex;align-items:center;gap:12px;font-size:13px}
|
|
2338
|
+
.n2s-fq-summary .n2s-fq-score{min-width:48px;height:34px;font-size:17px}
|
|
2339
|
+
.n2s-adminnav-group{margin:14px 12px 4px;font-size:11.5px;letter-spacing:.02em;color:var(--n2s-faint)}
|
|
2340
|
+
.n2s-adminnav-sep{height:.5px;margin:10px 12px;background:var(--n2s-line)}
|
|
1981
2341
|
/* ---- dashboard v2 ---- */
|
|
1982
2342
|
.n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
|
|
1983
2343
|
.n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
|
|
@@ -2036,7 +2396,8 @@ window.__ModuleLoader__.load({
|
|
|
2036
2396
|
.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
2397
|
.n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
|
|
2038
2398
|
.n2s-ptile-name{font-size:13.5px;font-weight:600}
|
|
2039
|
-
.n2s-kindcards{display:grid;gap:8px;margin-
|
|
2399
|
+
.n2s-kindcards{display:grid;gap:8px;margin:8px 0 0;padding:0;list-style:none}
|
|
2400
|
+
.n2s-kindcard-li{display:grid}
|
|
2040
2401
|
.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
2402
|
.n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
|
|
2042
2403
|
.n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
|
|
@@ -2158,10 +2519,35 @@ window.__ModuleLoader__.load({
|
|
|
2158
2519
|
/** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
|
|
2159
2520
|
let uiLang = 'zh'
|
|
2160
2521
|
// LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
|
|
2522
|
+
/**
|
|
2523
|
+
* Extension points for feature files (client-src/2x-*.js). Each feature calls
|
|
2524
|
+
* `extend.<point>.push(...)` at module evaluation:
|
|
2525
|
+
* adminSections: { id, label: (t) => string, icon: () => node, Component({ t, ds, datasources, onNavigate }) }
|
|
2526
|
+
* dashboardMenu: (ctx: { t, boardId, widgets, filters }) => [Dropdown items]
|
|
2527
|
+
* dashboardBar: Component({ t, boardId, widgets, filters }) rendered next to the dashboard actions
|
|
2528
|
+
* resultMenu: (ctx: { t, result }) => [Dropdown items]
|
|
2529
|
+
* resultPanels: Component({ t, result }) rendered under a result (e.g. forecast, freshness note)
|
|
2530
|
+
* schemaPanels: Component({ t, ds, schema, selected }) rendered above the table list
|
|
2531
|
+
* askPanels: Component({ t, ds }) rendered on the Ask page under the composer
|
|
2532
|
+
* toolViews: { key: '<tool name>', make: (ctx) => Component } conversation tool result views
|
|
2533
|
+
*/
|
|
2534
|
+
const extend = { adminSections: [], dashboardMenu: [], dashboardBar: [], resultMenu: [], resultPanels: [], schemaPanels: [], askPanels: [], toolViews: [] }
|
|
2535
|
+
const extItems = (list, arg) => list.flatMap(f => { try { return f(arg) ?? [] } catch (e) { console.warn('nl2sql ext', e); return [] } })
|
|
2536
|
+
|
|
2161
2537
|
/** Join a list for display in the UI language: 「a、b」 / "a, b". */
|
|
2162
2538
|
const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
|
|
2163
2539
|
/** Label + colon in the UI language: 「标签:」 / "Label: ". */
|
|
2164
2540
|
const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
|
|
2541
|
+
/** List separator in the UI language. */
|
|
2542
|
+
const listSep = () => (uiLang === 'zh' ? '、' : ', ')
|
|
2543
|
+
/** Clauses joined in the UI language: 「a;b」 / "a; b". */
|
|
2544
|
+
const joinClauses = (items) => joinList(items, uiLang === 'zh' ? ';' : '; ')
|
|
2545
|
+
/** Parenthetical in the UI language: 「(x)」 / " (x)". */
|
|
2546
|
+
const paren = (s) => (s === undefined || s === null || s === '' ? '' : uiLang === 'zh' ? `(${s})` : ` (${s})`)
|
|
2547
|
+
/** Quoted name in the UI language: 「x」 / "x". */
|
|
2548
|
+
const quoted = (s) => (uiLang === 'zh' ? `「${s}」` : `"${s}"`)
|
|
2549
|
+
/** BCP 47 tag for Intl formatting in the UI language. */
|
|
2550
|
+
const uiLocale = () => (uiLang === 'zh' ? 'zh-CN' : 'en-US')
|
|
2165
2551
|
const setUiLang = (snapshot) => {
|
|
2166
2552
|
const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
|
|
2167
2553
|
uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
|
|
@@ -2176,18 +2562,26 @@ window.__ModuleLoader__.load({
|
|
|
2176
2562
|
}
|
|
2177
2563
|
|
|
2178
2564
|
function formatCompact(v) {
|
|
2565
|
+
if (v === null || v === undefined || v === '') return '—'
|
|
2179
2566
|
const n = Number(v)
|
|
2180
|
-
if (!Number.isFinite(n)) return String(v
|
|
2567
|
+
if (!Number.isFinite(n)) return String(v)
|
|
2181
2568
|
const abs = Math.abs(n)
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2569
|
+
// [divisor, suffix, threshold, digits(abs)] from the largest unit down.
|
|
2570
|
+
const scales = uiLang === 'zh'
|
|
2571
|
+
? [[1e8, '亿', 1e8, a => (a >= 1e10 ? 0 : 2)], [1e4, '万', 1e5, a => (a >= 1e7 ? 0 : 1)]]
|
|
2572
|
+
: [[1e9, 'B', 1e9, a => (a >= 1e11 ? 0 : 2)], [1e6, 'M', 1e6, a => (a >= 1e8 ? 0 : 1)], [1e3, 'K', 1e5, () => 0]]
|
|
2573
|
+
for (let i = 0; i < scales.length; i++) {
|
|
2574
|
+
const [div, suffix, threshold, digits] = scales[i]
|
|
2575
|
+
if (abs < threshold) continue
|
|
2576
|
+
const s = (n / div).toFixed(digits(abs))
|
|
2577
|
+
// Rounding can reach the next unit (999 999 → "1000K"): use the larger unit instead.
|
|
2578
|
+
if (i > 0 && Math.abs(Number(s)) >= scales[i - 1][0] / div) {
|
|
2579
|
+
const [div2, suffix2, , digits2] = scales[i - 1]
|
|
2580
|
+
return `${(n / div2).toFixed(digits2(scales[i - 1][0]))}${suffix2}`
|
|
2581
|
+
}
|
|
2582
|
+
return `${s}${suffix}`
|
|
2187
2583
|
}
|
|
2188
|
-
|
|
2189
|
-
if (abs >= 1e5) return `${(n / 1e4).toFixed(abs >= 1e7 ? 0 : 1)}万`
|
|
2190
|
-
return n.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
2584
|
+
return n.toLocaleString(uiLocale(), { maximumFractionDigits: 2 })
|
|
2191
2585
|
}
|
|
2192
2586
|
|
|
2193
2587
|
function toCsv(result) {
|
|
@@ -2261,23 +2655,72 @@ window.__ModuleLoader__.load({
|
|
|
2261
2655
|
* Async data with request ordering: results of an older request (or of
|
|
2262
2656
|
* previous deps) never overwrite newer ones, and `loading` is true from the
|
|
2263
2657
|
* first render after deps change.
|
|
2658
|
+
*
|
|
2659
|
+
* `value` belongs to the current deps: while new deps load it is undefined,
|
|
2660
|
+
* so e.g. a previous data source's data is never shown or acted on.
|
|
2661
|
+
* `reload()` with unchanged deps keeps `value` until the new one arrives.
|
|
2662
|
+
* `scope` (optional) names the deps that identify *whose* data this is; when
|
|
2663
|
+
* only other deps change (a page offset, a filter) the previous value stays
|
|
2664
|
+
* on screen while the next one loads, as long as the scope is unchanged.
|
|
2264
2665
|
*/
|
|
2265
|
-
function useAsync(fn, deps) {
|
|
2666
|
+
function useAsync(fn, deps, scope) {
|
|
2266
2667
|
const depsKey = JSON.stringify(deps)
|
|
2267
|
-
const
|
|
2668
|
+
const scopeKey = scope === undefined ? depsKey : JSON.stringify(scope)
|
|
2669
|
+
const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null, scope: null })
|
|
2268
2670
|
const seq = useRef(0)
|
|
2269
2671
|
const reload = useCallback(() => {
|
|
2270
2672
|
const id = ++seq.current
|
|
2271
2673
|
setState(s => ({ ...s, loading: true, error: undefined }))
|
|
2272
2674
|
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 }) },
|
|
2675
|
+
value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey, scope: scopeKey }) },
|
|
2676
|
+
error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey, scope: scopeKey }) },
|
|
2275
2677
|
)
|
|
2276
2678
|
return () => { if (seq.current === id) seq.current++ }
|
|
2277
2679
|
}, deps)
|
|
2278
2680
|
useEffect(reload, [reload])
|
|
2279
2681
|
const stale = state.key !== depsKey
|
|
2280
|
-
|
|
2682
|
+
const foreign = state.scope !== scopeKey
|
|
2683
|
+
return { loading: state.loading || stale, value: foreign ? undefined : state.value, error: stale ? undefined : state.error, stale, reload }
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Guard against double submits: `run(fn)` ignores calls while one is in
|
|
2688
|
+
* flight (checked synchronously through a ref, so two clicks in the same
|
|
2689
|
+
* tick still run once) and `busy` re-renders the disabled state.
|
|
2690
|
+
*/
|
|
2691
|
+
function useBusy() {
|
|
2692
|
+
const ref = useRef(false)
|
|
2693
|
+
const [busy, setBusy] = useState(false)
|
|
2694
|
+
const alive = useRef(true)
|
|
2695
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
|
2696
|
+
const run = useCallback(async (fn) => {
|
|
2697
|
+
if (ref.current) return undefined
|
|
2698
|
+
ref.current = true
|
|
2699
|
+
setBusy(true)
|
|
2700
|
+
try { return await fn() } finally { ref.current = false; if (alive.current) setBusy(false) }
|
|
2701
|
+
}, [])
|
|
2702
|
+
return [busy, run]
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
2706
|
+
|
|
2707
|
+
/** Keep Tab / Shift+Tab focus inside the element while `active`. */
|
|
2708
|
+
function useFocusTrap(ref, active = true) {
|
|
2709
|
+
useEffect(() => {
|
|
2710
|
+
const el = ref.current
|
|
2711
|
+
if (!active || !el) return
|
|
2712
|
+
const onKey = (e) => {
|
|
2713
|
+
if (e.key !== 'Tab') return
|
|
2714
|
+
const items = [...el.querySelectorAll(FOCUSABLE)].filter(x => !x.closest('[hidden]') && x.getAttribute('aria-hidden') !== 'true')
|
|
2715
|
+
if (!items.length) { e.preventDefault(); return }
|
|
2716
|
+
const first = items[0]
|
|
2717
|
+
const last = items[items.length - 1]
|
|
2718
|
+
const cur = document.activeElement
|
|
2719
|
+
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() }
|
|
2720
|
+
}
|
|
2721
|
+
el.addEventListener('keydown', onKey)
|
|
2722
|
+
return () => el.removeEventListener('keydown', onKey)
|
|
2723
|
+
}, [ref, active, ref.current])
|
|
2281
2724
|
}
|
|
2282
2725
|
|
|
2283
2726
|
// ------------------------------------------------------------ feedback
|
|
@@ -2314,6 +2757,7 @@ window.__ModuleLoader__.load({
|
|
|
2314
2757
|
const [value, setValue] = useState('')
|
|
2315
2758
|
const [err, setErr] = useState(null)
|
|
2316
2759
|
const box = useRef(null)
|
|
2760
|
+
useFocusTrap(box, Boolean(d))
|
|
2317
2761
|
useEffect(() => {
|
|
2318
2762
|
if (!d) return
|
|
2319
2763
|
setValue(d.value ?? '')
|
|
@@ -2335,7 +2779,7 @@ window.__ModuleLoader__.load({
|
|
|
2335
2779
|
return h(React.Fragment, null,
|
|
2336
2780
|
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
2781
|
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)
|
|
2782
|
+
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
2783
|
h('div', { id: 'n2s-dlg-title', className: 'n2s-modal-title', style: { marginBottom: 8 } }, d.title),
|
|
2340
2784
|
d.message && h('div', { className: 'n2s-muted', style: { fontSize: 13.5, marginBottom: 12, whiteSpace: 'pre-wrap' } }, d.message),
|
|
2341
2785
|
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 +2789,22 @@ window.__ModuleLoader__.load({
|
|
|
2345
2789
|
h('button', { type: 'button', className: `n2s-btn ${d.danger ? 'danger-solid' : 'primary'}`, onClick: ok }, d.confirmLabel ?? t('fb2.ok'))))), document.body))
|
|
2346
2790
|
}
|
|
2347
2791
|
|
|
2792
|
+
|
|
2793
|
+
/** Mount the FeedbackLayer once into its own root under document.body; returns the disposer. */
|
|
2794
|
+
function mountFeedbackLayer(ctx) {
|
|
2795
|
+
const el = document.createElement('div')
|
|
2796
|
+
el.className = 'n2s-feedback-root'
|
|
2797
|
+
el.dataset.plugin = PKG
|
|
2798
|
+
document.body.appendChild(el)
|
|
2799
|
+
const root = ReactDOMClient.createRoot(el)
|
|
2800
|
+
function GlobalFeedback() {
|
|
2801
|
+
const t = useMemo(() => ctx.locale.bind(NS), [])
|
|
2802
|
+
setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
|
|
2803
|
+
return h(FeedbackLayer, { t })
|
|
2804
|
+
}
|
|
2805
|
+
root.render(h(GlobalFeedback))
|
|
2806
|
+
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) }
|
|
2807
|
+
}
|
|
2348
2808
|
// ----------------------------------------------------------- charts
|
|
2349
2809
|
|
|
2350
2810
|
let echartsPromise
|
|
@@ -2551,8 +3011,11 @@ window.__ModuleLoader__.load({
|
|
|
2551
3011
|
const [sending, setSending] = useState(false)
|
|
2552
3012
|
const [rating, setRating] = useState(null)
|
|
2553
3013
|
const [drill, setDrill] = useState(null)
|
|
3014
|
+
const ratingBusy = useRef(false)
|
|
2554
3015
|
useEffect(() => { setType(result.chart?.type ?? 'table') }, [result.resultId])
|
|
2555
3016
|
const rate = async (value, comment) => {
|
|
3017
|
+
if (ratingBusy.current) return
|
|
3018
|
+
ratingBusy.current = true
|
|
2556
3019
|
try {
|
|
2557
3020
|
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
3021
|
setRated(value)
|
|
@@ -2560,11 +3023,15 @@ window.__ModuleLoader__.load({
|
|
|
2560
3023
|
if (value === 'down') onFeedback?.down?.(comment)
|
|
2561
3024
|
} catch (e) {
|
|
2562
3025
|
notifyError(e)
|
|
3026
|
+
} finally {
|
|
3027
|
+
ratingBusy.current = false
|
|
2563
3028
|
}
|
|
2564
3029
|
}
|
|
2565
3030
|
const chart = useMemo(() => (type === result.chart?.type ? result.chart : chartFor(type, result, result.suggestedChart ?? result.chart)) ?? { type: 'table' }, [type, result])
|
|
2566
3031
|
const available = useMemo(() => CHART_CHOICES.filter(c => c === 'table' || chartFor(c, result, result.chart)), [result])
|
|
2567
|
-
const
|
|
3032
|
+
const [pinning, runPin] = useBusy()
|
|
3033
|
+
const pin = () => runPin(async () => {
|
|
3034
|
+
if (pinned) return
|
|
2568
3035
|
try {
|
|
2569
3036
|
await call('pinWidget', { title: result.title || result.sql.slice(0, 60), sql: result.sql, datasourceId: result.datasourceId, chart })
|
|
2570
3037
|
setPinned(true)
|
|
@@ -2572,8 +3039,9 @@ window.__ModuleLoader__.load({
|
|
|
2572
3039
|
} catch (e) {
|
|
2573
3040
|
notifyError(e)
|
|
2574
3041
|
}
|
|
2575
|
-
}
|
|
2576
|
-
const
|
|
3042
|
+
})
|
|
3043
|
+
const [savingExample, runSaveExample] = useBusy()
|
|
3044
|
+
const saveExample = () => runSaveExample(async () => {
|
|
2577
3045
|
const q = await promptDialog({ title: t('kb.examplePrompt'), value: question || result.title || '' })
|
|
2578
3046
|
if (!q) return
|
|
2579
3047
|
try {
|
|
@@ -2582,7 +3050,7 @@ window.__ModuleLoader__.load({
|
|
|
2582
3050
|
} catch (e) {
|
|
2583
3051
|
notifyError(e)
|
|
2584
3052
|
}
|
|
2585
|
-
}
|
|
3053
|
+
})
|
|
2586
3054
|
const rerun = async (confirmScan) => {
|
|
2587
3055
|
setRunning(true); setEditError(null)
|
|
2588
3056
|
try {
|
|
@@ -2626,7 +3094,8 @@ window.__ModuleLoader__.load({
|
|
|
2626
3094
|
isChart && result.chart?.x && result.rows.length >= 2 && { label: t('drill.why'), icon: Icon.spark(14), onClick: () => setDrill({ mode: 'why' }) },
|
|
2627
3095
|
isChart && chartInstance && { label: t('result.png'), icon: Icon.download(14), onClick: exportPng },
|
|
2628
3096
|
!hidden && { divider: true },
|
|
2629
|
-
!hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample, onClick: saveExample },
|
|
3097
|
+
!hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample || savingExample, onClick: saveExample },
|
|
3098
|
+
...extItems(extend.resultMenu, { t, result }),
|
|
2630
3099
|
]
|
|
2631
3100
|
const chartPicker = result.rows.length > 0 && h(Dropdown, {
|
|
2632
3101
|
label: t('result.chartType'),
|
|
@@ -2644,6 +3113,7 @@ window.__ModuleLoader__.load({
|
|
|
2644
3113
|
h('span', { className: 'n2s-grow' }),
|
|
2645
3114
|
chartPicker),
|
|
2646
3115
|
body,
|
|
3116
|
+
...extend.resultPanels.map((C, k) => h(C, { key: `rp${k}`, t, result })),
|
|
2647
3117
|
h('div', { className: 'n2s-result-foot' },
|
|
2648
3118
|
!hideSql && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showSql, onClick: () => setShowSql(v => !v) }, Icon.code(14), t('result.sql')),
|
|
2649
3119
|
isChart && result.rows.length > 0 && !compact && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showTable, onClick: () => setShowTable(v => !v) }, Icon.table(14), t('result.data')),
|
|
@@ -2653,7 +3123,7 @@ window.__ModuleLoader__.load({
|
|
|
2653
3123
|
embedded && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'result.rows', { count: result.rowCount }), ' · ', fmt(t, 'result.elapsed', { ms: result.elapsedMs })),
|
|
2654
3124
|
!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
3125
|
!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()),
|
|
3126
|
+
!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
3127
|
h('button', { type: 'button', className: 'n2s-iconbtn', title: t('im.share'), 'aria-label': t('im.share'), onClick: () => setSending(true) }, Icon.send()),
|
|
2658
3128
|
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: moreItems })),
|
|
2659
3129
|
rating === 'down' && h(FeedbackForm, { t, onCancel: () => setRating(null), onSubmit: (comment) => rate('down', comment) }),
|
|
@@ -2838,6 +3308,18 @@ window.__ModuleLoader__.load({
|
|
|
2838
3308
|
const [state, setState] = useState(null)
|
|
2839
3309
|
const [drag, setDrag] = useState(false)
|
|
2840
3310
|
const inputRef = useRef(null)
|
|
3311
|
+
const box = useRef(null)
|
|
3312
|
+
useFocusTrap(box)
|
|
3313
|
+
const busyRef = useRef(false)
|
|
3314
|
+
busyRef.current = Boolean(state?.busy)
|
|
3315
|
+
useEffect(() => {
|
|
3316
|
+
const prev = document.activeElement
|
|
3317
|
+
setTimeout(() => box.current?.querySelector('[role=button], button')?.focus(), 0)
|
|
3318
|
+
// Escape works wherever focus is (not only inside the dialog), but not under a confirm dialog or mid-upload.
|
|
3319
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) onClose() }
|
|
3320
|
+
document.addEventListener('keydown', onKey)
|
|
3321
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
3322
|
+
}, [])
|
|
2841
3323
|
const pick = async (f) => {
|
|
2842
3324
|
if (!f) return
|
|
2843
3325
|
setFile(f); setSheets(null); setState({ busy: true }); setProgress(0)
|
|
@@ -2850,6 +3332,8 @@ window.__ModuleLoader__.load({
|
|
|
2850
3332
|
} catch (e) { setState({ error: e.message }) }
|
|
2851
3333
|
}
|
|
2852
3334
|
const run = async () => {
|
|
3335
|
+
if (busyRef.current) return
|
|
3336
|
+
busyRef.current = true
|
|
2853
3337
|
setState({ busy: true })
|
|
2854
3338
|
try {
|
|
2855
3339
|
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 +3344,7 @@ window.__ModuleLoader__.load({
|
|
|
2860
3344
|
}
|
|
2861
3345
|
const typeOpts = ['TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME']
|
|
2862
3346
|
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%)' }
|
|
3347
|
+
h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' } },
|
|
2864
3348
|
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
3349
|
!sheets && h('div', {
|
|
2866
3350
|
className: `n2s-drop ${drag ? 'over' : ''}`, role: 'button', tabIndex: 0,
|
|
@@ -2890,19 +3374,19 @@ window.__ModuleLoader__.load({
|
|
|
2890
3374
|
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
3375
|
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
3376
|
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}
|
|
3377
|
+
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
3378
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
2895
3379
|
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('up.privacy')),
|
|
2896
3380
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, state?.done ? t('ds.close') : t('ds.cancel')),
|
|
2897
3381
|
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
3382
|
}
|
|
2899
3383
|
function FileTables({ t, ds }) {
|
|
2900
|
-
const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
|
|
3384
|
+
const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
|
|
2901
3385
|
const tables = list.value ?? []
|
|
2902
3386
|
if (!tables.length) return null
|
|
2903
3387
|
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
3388
|
`${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 })
|
|
3389
|
+
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
3390
|
}
|
|
2907
3391
|
|
|
2908
3392
|
function SourcesTab({ t, datasources }) {
|
|
@@ -2949,7 +3433,7 @@ window.__ModuleLoader__.load({
|
|
|
2949
3433
|
h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
|
|
2950
3434
|
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
3435
|
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 })
|
|
3436
|
+
!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
3437
|
ds.type === 'sqlite' && { label: t('up.addFile'), icon: Icon.up(14), onClick: () => setUpload({ target: ds.id }) },
|
|
2954
3438
|
ds.type !== 'sqlite' && { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(ds) },
|
|
2955
3439
|
{ 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 +3482,12 @@ window.__ModuleLoader__.load({
|
|
|
2998
3482
|
}
|
|
2999
3483
|
const quoteTable = (name) => ds.type === 'mysql' ? `\`${name.replace(/`/g, '``')}\`` : ds.type === 'postgres' ? name.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${name.replace(/"/g, '""')}"`
|
|
3000
3484
|
const runOverview = async (tb, pin) => {
|
|
3485
|
+
const forId = ds.id
|
|
3001
3486
|
setPreview({ busy: true })
|
|
3002
3487
|
try {
|
|
3003
|
-
const res = await call('tableOverview', { datasourceId:
|
|
3004
|
-
setPreview({ overview: res.results, pinned: pin })
|
|
3005
|
-
} catch (e) { setPreview({ error: e.message }) }
|
|
3488
|
+
const res = await call('tableOverview', { datasourceId: forId, table: tb.name, pin })
|
|
3489
|
+
if (currentDs.current === forId) setPreview({ overview: res.results, pinned: pin })
|
|
3490
|
+
} catch (e) { if (currentDs.current === forId) setPreview({ error: e.message }) }
|
|
3006
3491
|
}
|
|
3007
3492
|
// Default to the largest fact table so the detail pane is never blank.
|
|
3008
3493
|
const defaultTable = schema ? ([...schema.tables].sort((x, y) => (y.role === 'fact') - (x.role === 'fact') || (y.rows ?? 0) - (x.rows ?? 0))[0]?.name ?? null) : null
|
|
@@ -3011,6 +3496,7 @@ window.__ModuleLoader__.load({
|
|
|
3011
3496
|
const rels2 = tbl ? schema.relations.filter(r => r.from.table === tbl.name || r.to.table === tbl.name) : []
|
|
3012
3497
|
const keyIcon = (c) => c.key === 'PRI' ? h('span', { className: 'n2s-colkey pk', title: 'PRIMARY KEY' }, 'PK') : c.key === 'UNI' ? h('span', { className: 'n2s-colkey', title: 'UNIQUE' }, 'UQ') : c.key === 'MUL' ? h('span', { className: 'n2s-colkey', title: 'INDEX' }, 'IX') : null
|
|
3013
3498
|
return h('div', null,
|
|
3499
|
+
schema && extend.schemaPanels.map((C, k) => h(C, { key: `sp${k}`, t, ds, schema, selected })),
|
|
3014
3500
|
h('div', { className: 'n2s-toolbar' },
|
|
3015
3501
|
schema && h('span', { className: 'n2s-muted n2s-small' }, fmt(t, 'schema.tables', { count: schema.tables.length }), ' · ', fmt(t, 'schema.relations', { count: schema.relations.length }), ' · ', fmt(t, 'schema.analyzedAt', { time: new Date(schema.analyzedAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })),
|
|
3016
3502
|
h('span', { className: 'n2s-grow' }),
|
|
@@ -3076,7 +3562,7 @@ window.__ModuleLoader__.load({
|
|
|
3076
3562
|
* Hidden once every required step is done or the user dismisses it.
|
|
3077
3563
|
*/
|
|
3078
3564
|
function Onboarding({ t, ds, onNavigate, onAsk, examples }) {
|
|
3079
|
-
const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt])
|
|
3565
|
+
const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt], [])
|
|
3080
3566
|
const [busy, setBusy] = useState(false)
|
|
3081
3567
|
const v = st.value
|
|
3082
3568
|
if (!v || v.dismissed) return null
|
|
@@ -3115,12 +3601,21 @@ window.__ModuleLoader__.load({
|
|
|
3115
3601
|
const [exOffset, setExOffset] = useState(0)
|
|
3116
3602
|
const [recentLimit, setRecentLimit] = useState(8)
|
|
3117
3603
|
const [hiddenRecent, setHiddenRecent] = useState(() => new Set())
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3604
|
+
// Data source switched: reset paging and per-source results during render, so the
|
|
3605
|
+
// first request for the new source already uses offset 0 (no extra fetch).
|
|
3606
|
+
const [stateFor, setStateFor] = useState(ds?.id)
|
|
3607
|
+
if (stateFor !== ds?.id) {
|
|
3608
|
+
setStateFor(ds?.id)
|
|
3609
|
+
setExOffset(0); setRecentLimit(8); setHiddenRecent(new Set())
|
|
3610
|
+
setResult(null); setPlan(null); setError(null)
|
|
3611
|
+
}
|
|
3612
|
+
const pageOffset = stateFor === ds?.id ? exOffset : 0
|
|
3613
|
+
const pageRecent = stateFor === ds?.id ? recentLimit : 8
|
|
3614
|
+
// Scoped by data source + language: within one source the previous page stays on
|
|
3615
|
+
// screen while the next loads (no flicker on 换一批); another source's never does.
|
|
3616
|
+
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])
|
|
3617
|
+
useEffect(() => { if (sugg.value && !sugg.stale) setHiddenRecent(new Set()) }, [sugg.value])
|
|
3618
|
+
const data = sugg.value
|
|
3124
3619
|
const examples = data?.examples?.length ? data.examples : [t('ask.ex.tables'), t('ask.ex.biggest')]
|
|
3125
3620
|
const canShuffle = (data?.total ?? 0) > (data?.pageSize ?? 6)
|
|
3126
3621
|
const recent = (data?.recent ?? []).filter(r => !hiddenRecent.has(r.question))
|
|
@@ -3129,27 +3624,33 @@ window.__ModuleLoader__.load({
|
|
|
3129
3624
|
setHiddenRecent(x => new Set(x).add(r.question))
|
|
3130
3625
|
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
3626
|
}
|
|
3627
|
+
const currentDs = useRef(ds?.id)
|
|
3628
|
+
currentDs.current = ds?.id
|
|
3132
3629
|
const run = async (confirmScan = false) => {
|
|
3630
|
+
const forId = ds.id
|
|
3133
3631
|
setRunning(true); setError(null)
|
|
3134
3632
|
try {
|
|
3135
|
-
|
|
3633
|
+
const r = await call('runQuery', { sql, datasourceId: forId, title: '', confirmScan })
|
|
3634
|
+
if (currentDs.current === forId) setResult(r)
|
|
3136
3635
|
} 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)
|
|
3636
|
+
if (e.code === 'nl2sql/scan-guard' && currentDs.current === forId && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
|
|
3637
|
+
if (currentDs.current === forId) setError(e.message)
|
|
3139
3638
|
}
|
|
3140
3639
|
setRunning(false)
|
|
3141
3640
|
}
|
|
3142
3641
|
const explain = async () => {
|
|
3642
|
+
const forId = ds.id
|
|
3143
3643
|
setRunning(true); setError(null)
|
|
3144
|
-
try {
|
|
3644
|
+
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
3645
|
setRunning(false)
|
|
3146
3646
|
}
|
|
3147
3647
|
const submit = () => { if (question.trim()) { onAsk(question.trim()); setQuestion('') } }
|
|
3148
3648
|
const [uploading, setUploading] = useState(false)
|
|
3149
3649
|
const [sqlOpen, setSqlOpen] = useState(false)
|
|
3150
|
-
const tableCount = ds?.tableCount ??
|
|
3650
|
+
const tableCount = ds?.tableCount ?? data?.tableCount
|
|
3151
3651
|
return h('div', null,
|
|
3152
3652
|
h(Onboarding, { t, ds, onNavigate, onAsk, examples }),
|
|
3653
|
+
...extend.askPanels.map((C, k) => h(C, { key: `ap${k}`, t, ds, onAsk, onNavigate })),
|
|
3153
3654
|
h('div', { className: 'n2s-hero' },
|
|
3154
3655
|
h('h2', null, t('ask.heroTitle')),
|
|
3155
3656
|
h('p', null, ds ? (ds.schemaAnalyzedAt ? (tableCount ? fmt(t, 'ask.heroSub', { name: ds.name, tables: tableCount }) : fmt(t, 'ask.heroSubShort', { name: ds.name })) : fmt(t, 'ask.heroSubNoSchema', { name: ds.name })) : t('ask.hint'))),
|
|
@@ -3196,15 +3697,16 @@ window.__ModuleLoader__.load({
|
|
|
3196
3697
|
result && h('div', { className: 'n2s-card', style: { marginTop: 12 } }, h(ResultView, { result, t, hideSql: true })))))
|
|
3197
3698
|
}
|
|
3198
3699
|
|
|
3199
|
-
/** "3 分钟前" style relative time. */
|
|
3700
|
+
/** "3 分钟前" style relative time in the UI language; future or older than a week → the date. */
|
|
3200
3701
|
function relTime(t, at) {
|
|
3201
3702
|
if (!at) return ''
|
|
3202
3703
|
const d = (Date.now() - at) / 1000
|
|
3704
|
+
if (d < -60) return new Date(at).toLocaleDateString(uiLocale())
|
|
3203
3705
|
if (d < 60) return t('time.justNow')
|
|
3204
3706
|
if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
|
|
3205
3707
|
if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
|
|
3206
3708
|
if (d < 86400 * 7) return fmt(t, 'time.dayAgo', { n: Math.floor(d / 86400) })
|
|
3207
|
-
return new Date(at).toLocaleDateString()
|
|
3709
|
+
return new Date(at).toLocaleDateString(uiLocale())
|
|
3208
3710
|
}
|
|
3209
3711
|
|
|
3210
3712
|
function PlanTable({ plan }) {
|
|
@@ -3290,6 +3792,7 @@ window.__ModuleLoader__.load({
|
|
|
3290
3792
|
}
|
|
3291
3793
|
function KnowledgeEditor({ kind, item, t, fields, schema, noteKeys, onCancel, onSave }) {
|
|
3292
3794
|
const [form, setForm] = useState(() => ({ ...item, ...(Array.isArray(item?.synonyms) ? { synonyms: item.synonyms.join(',') } : {}) }))
|
|
3795
|
+
const [busy, runBusy] = useBusy()
|
|
3293
3796
|
return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
|
|
3294
3797
|
h('div', { className: 'n2s-form' }, fields.map(([key, label, type]) => h('label', { key, className: `n2s-field ${type === 'textarea' || type?.startsWith('code') ? 'full' : ''}` },
|
|
3295
3798
|
h('span', null, t(label)),
|
|
@@ -3300,19 +3803,19 @@ window.__ModuleLoader__.load({
|
|
|
3300
3803
|
h('datalist', { id: 'n2s-notekeys' }, noteKeys.slice(0, 2000).map(k2 => h('option', { key: k2, value: k2 }))),
|
|
3301
3804
|
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
3302
3805
|
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'))))
|
|
3806
|
+
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
3807
|
}
|
|
3305
3808
|
|
|
3306
3809
|
function KnowledgeTab({ t, ds }) {
|
|
3307
3810
|
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])
|
|
3811
|
+
const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt], [ds?.id])
|
|
3309
3812
|
const [section, setSection] = useState('glossary')
|
|
3310
3813
|
const [editing, setEditing] = useState(null)
|
|
3311
3814
|
const [error, setError] = useState(null)
|
|
3312
3815
|
const [drafts, setDrafts] = useState(null)
|
|
3313
3816
|
const noteKeys = useMemo(() => (schema.value?.tables ?? []).flatMap(tb => [tb.name, ...tb.columns.map(c => `${tb.name}.${c.name}`)]), [schema.value])
|
|
3314
3817
|
const allDatasources = useStore(s2 => s2.datasources)
|
|
3315
|
-
const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value])
|
|
3818
|
+
const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value], [ds?.id])
|
|
3316
3819
|
if (!ds) return null
|
|
3317
3820
|
const k = kb.value ?? { glossary: [], metrics: [], notes: {}, examples: [] }
|
|
3318
3821
|
const save = async (kind, item) => {
|
|
@@ -3321,7 +3824,7 @@ window.__ModuleLoader__.load({
|
|
|
3321
3824
|
}
|
|
3322
3825
|
const remove = async (kind, id) => {
|
|
3323
3826
|
if (!await confirmDialog({ title: t('kb.deleteConfirm'), danger: true })) return
|
|
3324
|
-
await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id })
|
|
3827
|
+
try { await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }) } catch (e) { setError(e.message) }
|
|
3325
3828
|
kb.reload()
|
|
3326
3829
|
}
|
|
3327
3830
|
const FIELDS = {
|
|
@@ -3346,7 +3849,7 @@ window.__ModuleLoader__.load({
|
|
|
3346
3849
|
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
3850
|
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
3851
|
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
|
|
3852
|
+
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
3853
|
if (section === 'note') {
|
|
3351
3854
|
const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
|
|
3352
3855
|
list = h(React.Fragment, null,
|
|
@@ -3371,7 +3874,7 @@ window.__ModuleLoader__.load({
|
|
|
3371
3874
|
}
|
|
3372
3875
|
function HistoryTab({ t, datasources, onOpenSession }) {
|
|
3373
3876
|
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])
|
|
3877
|
+
const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search], [])
|
|
3375
3878
|
const [open, setOpen] = useState(null)
|
|
3376
3879
|
const [rerun, setRerun] = useState(null)
|
|
3377
3880
|
const [removed, setRemoved] = useState(() => new Set())
|
|
@@ -3397,13 +3900,7 @@ window.__ModuleLoader__.load({
|
|
|
3397
3900
|
}, [rows])
|
|
3398
3901
|
const [search, setSearch] = useState('')
|
|
3399
3902
|
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
|
-
}
|
|
3903
|
+
const rel = (at) => relTime(t, at)
|
|
3407
3904
|
const statusDot = (st) => h('span', { className: `n2s-statusdot ${st === 'error' ? 'err' : st === 'blocked' ? 'warn' : 'ok'}` }, t(`hist.status.${st ?? 'ok'}`))
|
|
3408
3905
|
const oneLine = (sql) => sql.replace(/\s+/g, ' ').trim()
|
|
3409
3906
|
return h('div', null,
|
|
@@ -3418,7 +3915,7 @@ window.__ModuleLoader__.load({
|
|
|
3418
3915
|
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
3419
3916
|
{ label: t('hist.export'), icon: Icon.download(14), disabled: rows.length === 0, onClick: exportCsv },
|
|
3420
3917
|
{ 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')
|
|
3918
|
+
{ 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
3919
|
] })),
|
|
3423
3920
|
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
3424
3921
|
list.loading && !list.value && h('div', { className: 'n2s-skel', style: { height: 240 } }),
|
|
@@ -3432,7 +3929,7 @@ window.__ModuleLoader__.load({
|
|
|
3432
3929
|
h('span', { className: 'n2s-hist-what' },
|
|
3433
3930
|
h('span', { className: `n2s-hist-title ${r.title ? '' : 'none'}` }, r.title || r.error?.slice(0, 80) || t('hist.untitled')),
|
|
3434
3931
|
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
|
|
3932
|
+
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
3933
|
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
3934
|
h('span', { className: 'n2s-hist-chev' }, Icon.chev(12))),
|
|
3438
3935
|
h('button', { type: 'button', className: 'n2s-iconbtn n2s-rowdel', title: t('hist.deleteOne'), 'aria-label': `${t('hist.deleteOne')}: ${r.title || t('hist.untitled')}`, onClick: () => removeRow(r) }, Icon.trash(14))),
|
|
@@ -3442,7 +3939,7 @@ window.__ModuleLoader__.load({
|
|
|
3442
3939
|
h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
|
|
3443
3940
|
h('button', { type: 'button', className: 'n2s-iconbtn', style: { position: 'absolute', top: 6, right: 6 }, title: t('result.copy'), 'aria-label': t('result.copy'), onClick: () => navigator.clipboard?.writeText(r.sql) }, Icon.copy(14))),
|
|
3444
3941
|
h('div', { className: 'n2s-row' },
|
|
3445
|
-
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString()),
|
|
3942
|
+
h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString(uiLocale())),
|
|
3446
3943
|
r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
|
|
3447
3944
|
h('button', {
|
|
3448
3945
|
type: 'button', className: 'n2s-btn small',
|
|
@@ -3455,6 +3952,15 @@ window.__ModuleLoader__.load({
|
|
|
3455
3952
|
rerun?.id === r.id && (rerun.busy ? h('div', { className: 'n2s-skel', style: { height: 200 } }) : rerun.error ? h('div', { className: 'n2s-notice err' }, rerun.error) : h('div', { className: 'n2s-card' }, h(ResultView, { result: rerun.result, t, hideSql: true })))))
|
|
3456
3953
|
})))
|
|
3457
3954
|
}
|
|
3955
|
+
|
|
3956
|
+
const HIST_SOURCES = ['agent', 'ui', 'dashboard', 'alert', 'eval', 'larkbot', 'telegrambot', 'wecombot', 'dingtalkbot']
|
|
3957
|
+
/** Label of a history `source`; unknown sources (a new bot platform…) fall back to a generic label. */
|
|
3958
|
+
function sourceLabel(t, source) {
|
|
3959
|
+
if (!source) return t('hist.source.unknown')
|
|
3960
|
+
if (HIST_SOURCES.includes(source)) return t(`hist.source.${source}`)
|
|
3961
|
+
const bot = /^(.+)bot$/.exec(source)
|
|
3962
|
+
return bot ? fmt(t, 'hist.source.genericBot', { platform: bot[1] }) : source
|
|
3963
|
+
}
|
|
3458
3964
|
function OverviewGrid({ t, results, pinned }) {
|
|
3459
3965
|
const [sending, setSending] = useState(false)
|
|
3460
3966
|
const ok = results.filter(r => !r.error && r.resultId)
|
|
@@ -3532,7 +4038,7 @@ window.__ModuleLoader__.load({
|
|
|
3532
4038
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, svg(16, [h('circle', { key: 1, cx: 8, cy: 8, r: 5.5 }), h('path', { key: 2, d: 'M8 5v3.2l2 1.3' })])),
|
|
3533
4039
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
3534
4040
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, r.title), r.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), r.channelName)),
|
|
3535
|
-
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(
|
|
4041
|
+
h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(listSep()) + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
|
|
3536
4042
|
h('div', { className: 'n2s-list2-side' },
|
|
3537
4043
|
statusDot(r.status),
|
|
3538
4044
|
h('span', { className: 'n2s-faint n2s-small' }, r.scheduledAt && r.status === 'active' ? `${t('rep.next')} ${new Date(r.scheduledAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}` : '')),
|
|
@@ -3548,25 +4054,32 @@ window.__ModuleLoader__.load({
|
|
|
3548
4054
|
}
|
|
3549
4055
|
function FeedbackForm({ t, onCancel, onSubmit }) {
|
|
3550
4056
|
const [comment, setComment] = useState('')
|
|
4057
|
+
const [busy, runBusy] = useBusy()
|
|
3551
4058
|
const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
|
|
3552
4059
|
return h('div', { className: 'n2s-card', style: { padding: 10 } },
|
|
3553
4060
|
h('div', { className: 'n2s-small', style: { marginBottom: 6 } }, t('fb.whatWrong')),
|
|
3554
|
-
h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ?
|
|
4061
|
+
h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ? joinClauses([c, t(r)]) : t(r))) }, t(r)))),
|
|
3555
4062
|
h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
|
|
3556
4063
|
h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
|
|
3557
4064
|
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: onCancel }, t('ds.cancel')),
|
|
3558
|
-
h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => onSubmit(comment.trim()) }, t('fb.submit'))))
|
|
4065
|
+
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy, onClick: () => runBusy(() => onSubmit(comment.trim())) }, busy ? h('span', { className: 'n2s-spin' }) : null, t('fb.submit'))))
|
|
3559
4066
|
}
|
|
3560
4067
|
|
|
3561
4068
|
function FeedbackSection({ t, ds }) {
|
|
3562
4069
|
const [status, setStatus] = useState('open')
|
|
3563
|
-
const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status])
|
|
3564
|
-
const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value])
|
|
4070
|
+
const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status], [ds?.id])
|
|
4071
|
+
const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value], [ds?.id])
|
|
3565
4072
|
const [fixing, setFixing] = useState(null)
|
|
3566
4073
|
const [error, setError] = useState(null)
|
|
3567
|
-
const
|
|
4074
|
+
const [busy, runBusy] = useBusy()
|
|
4075
|
+
const resolve = (payload) => runBusy(async () => {
|
|
3568
4076
|
setError(null)
|
|
3569
4077
|
try { await call('resolveFeedback', payload); setFixing(null); list.reload() } catch (e) { setError(e.message) }
|
|
4078
|
+
})
|
|
4079
|
+
const remove = async (f) => {
|
|
4080
|
+
if (!await confirmDialog({ title: t('fb.deleteConfirm'), message: f.question || f.title || '', confirmLabel: t('ds.delete'), danger: true })) return
|
|
4081
|
+
try { await call('deleteFeedback', { id: f.id }) } catch (e) { notifyError(e) }
|
|
4082
|
+
list.reload()
|
|
3570
4083
|
}
|
|
3571
4084
|
const st = stats.value
|
|
3572
4085
|
return h('div', null,
|
|
@@ -3588,13 +4101,13 @@ window.__ModuleLoader__.load({
|
|
|
3588
4101
|
h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
|
|
3589
4102
|
f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
|
|
3590
4103
|
h('pre', { className: 'n2s-code', style: { marginTop: 6 } }, f.correctedSql || f.sql),
|
|
3591
|
-
f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${t('fb.resolution')}
|
|
4104
|
+
f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${colon(t('fb.resolution'))}${f.resolution}`)),
|
|
3592
4105
|
h('div', { style: { display: 'grid', gap: 4 } },
|
|
3593
4106
|
f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
|
|
3594
4107
|
f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => setFixing({ id: f.id, sql: f.sql, question: f.question || f.title, resolution: '' }) }, t('fb.fix')),
|
|
3595
4108
|
f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
|
|
3596
4109
|
f.rating === 'up' && !f.savedAsExample && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'resolved', saveExample: true }) }, t('kb.saveExample')),
|
|
3597
|
-
h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick:
|
|
4110
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick: () => remove(f) }, t('ds.delete')))),
|
|
3598
4111
|
fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
|
|
3599
4112
|
h('label', { className: 'n2s-field' }, h('span', null, t('kb.question')), h('input', { className: 'n2s-input', value: fixing.question, onChange: e => setFixing(x => ({ ...x, question: e.target.value })) })),
|
|
3600
4113
|
h('label', { className: 'n2s-field' }, h('span', null, t('fb.correctedSql')), h('textarea', { className: 'n2s-textarea n2s-mono', style: { minHeight: 120 }, spellCheck: false, value: fixing.sql, onChange: e => setFixing(x => ({ ...x, sql: e.target.value })) })),
|
|
@@ -3640,10 +4153,16 @@ window.__ModuleLoader__.load({
|
|
|
3640
4153
|
area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
|
|
3641
4154
|
h('div', null,
|
|
3642
4155
|
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 6 } }, tables.length ? fmt(t, 'policy.preview', { allowed: allowed.length, total: tables.length }) : t('schema.notAnalyzed')),
|
|
3643
|
-
h('div', { className: 'n2s-list', style: { maxHeight: 320 } }, tables.map(tb => {
|
|
4156
|
+
h('div', { className: 'n2s-list', role: 'group', 'aria-label': t('policy.tablesLabel'), style: { maxHeight: 320 } }, tables.map(tb => {
|
|
3644
4157
|
const ok = allowed.includes(tb)
|
|
3645
|
-
|
|
3646
|
-
|
|
4158
|
+
const editable = form.mode !== 'all'
|
|
4159
|
+
return h('div', {
|
|
4160
|
+
key: tb.name, className: 'n2s-item', style: { cursor: editable ? 'pointer' : 'default', opacity: ok ? 1 : 0.5 },
|
|
4161
|
+
role: 'checkbox', 'aria-checked': ok, 'aria-disabled': editable ? undefined : true, tabIndex: editable ? 0 : -1, 'aria-label': tb.name,
|
|
4162
|
+
onClick: () => editable && toggleTable(tb.name),
|
|
4163
|
+
onKeyDown: (e) => { if (editable && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); toggleTable(tb.name) } },
|
|
4164
|
+
},
|
|
4165
|
+
h('span', { 'aria-hidden': true }, ok ? '✓' : '✕'), h('span', { className: 'name n2s-mono' }, tb.name), roleBadge(t, tb.role))
|
|
3647
4166
|
})),
|
|
3648
4167
|
form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
|
|
3649
4168
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
@@ -3662,8 +4181,16 @@ window.__ModuleLoader__.load({
|
|
|
3662
4181
|
const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
|
|
3663
4182
|
onChange([...(next ? [next] : []), ...ins])
|
|
3664
4183
|
}
|
|
3665
|
-
|
|
4184
|
+
// keep: an emptied free-text field keeps its tag (with no values) instead of disappearing.
|
|
4185
|
+
const setIn = (column, values, keep) => {
|
|
4186
|
+
const others = ins.filter(f => f.column !== column)
|
|
4187
|
+
const at = ins.findIndex(f => f.column === column)
|
|
4188
|
+
const mine = values.length || keep ? [{ kind: 'in', column, values }] : []
|
|
4189
|
+
const next = at < 0 ? [...others, ...mine] : [...others.slice(0, at), ...mine, ...others.slice(at)]
|
|
4190
|
+
onChange([...(time ? [time] : []), ...next])
|
|
4191
|
+
}
|
|
3666
4192
|
const c = cand.value ?? { time: [], dims: [] }
|
|
4193
|
+
const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
|
|
3667
4194
|
return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
|
|
3668
4195
|
h('span', { className: 'n2s-faint', style: { display: 'inline-flex' } }, svg(14, [h('path', { key: 1, d: 'M2.5 3.5h11l-4.2 5v4l-2.6 1.2V8.5L2.5 3.5z' })])),
|
|
3669
4196
|
h('select', { className: 'n2s-select', value: time ? time.range : '', onChange: e => setTime(e.target.value ? { range: e.target.value } : null), 'aria-label': t('filter.range') },
|
|
@@ -3680,7 +4207,7 @@ window.__ModuleLoader__.load({
|
|
|
3680
4207
|
h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
|
|
3681
4208
|
dim?.values?.length
|
|
3682
4209
|
? h('select', { className: 'n2s-select', 'aria-label': dim.label, value: f.values[0] ?? '', onChange: e => setIn(f.column, e.target.value ? [e.target.value] : []) }, dim.values.map(v => h('option', { key: v, value: v }, v)))
|
|
3683
|
-
: h(
|
|
4210
|
+
: h(FilterValuesInput, { key: f.column, t, label: dim?.label ?? f.column, values: f.values, onCommit: (values) => setIn(f.column, values, true) }),
|
|
3684
4211
|
h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 22, height: 22 }, 'aria-label': t('filter.remove'), onClick: () => setIn(f.column, []) }, Icon.close(11)))
|
|
3685
4212
|
}),
|
|
3686
4213
|
c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
|
|
@@ -3689,9 +4216,39 @@ window.__ModuleLoader__.load({
|
|
|
3689
4216
|
items: c.dims.filter(d => !ins.some(f => f.column === d.column)).map(d => ({ label: d.label === d.column ? d.column : `${d.label}`, hint: d.label === d.column ? '' : d.column, onClick: () => setIn(d.column, d.values?.slice(0, 1) ?? []) })),
|
|
3690
4217
|
}),
|
|
3691
4218
|
h('span', { className: 'n2s-grow' }),
|
|
3692
|
-
|
|
4219
|
+
activeN > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: activeN })),
|
|
3693
4220
|
filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
|
|
3694
4221
|
}
|
|
4222
|
+
|
|
4223
|
+
/**
|
|
4224
|
+
* Free-text values of an "in" filter: typed into a local draft (commas allowed,
|
|
4225
|
+
* clearing does not remove the tag) and committed on Enter, blur or after a pause.
|
|
4226
|
+
*/
|
|
4227
|
+
function FilterValuesInput({ t, label, values, onCommit }) {
|
|
4228
|
+
const joined = values.join(', ')
|
|
4229
|
+
const [draft, setDraft] = useState(joined)
|
|
4230
|
+
const committed = useRef(joined)
|
|
4231
|
+
// Adopt outside changes (clear all, another board) when not mid-edit.
|
|
4232
|
+
useEffect(() => { if (joined !== committed.current) { committed.current = joined; setDraft(joined) } }, [joined])
|
|
4233
|
+
const parse = (s) => s.split(/[,,]/).map(x => x.trim()).filter(Boolean)
|
|
4234
|
+
const commit = (s) => {
|
|
4235
|
+
const next = parse(s)
|
|
4236
|
+
if (next.join('\u0000') === parse(committed.current).join('\u0000')) return
|
|
4237
|
+
committed.current = next.join(', ')
|
|
4238
|
+
onCommit(next)
|
|
4239
|
+
}
|
|
4240
|
+
useEffect(() => {
|
|
4241
|
+
if (draft === committed.current) return
|
|
4242
|
+
const id = setTimeout(() => commit(draft), 800)
|
|
4243
|
+
return () => clearTimeout(id)
|
|
4244
|
+
}, [draft])
|
|
4245
|
+
return h('input', {
|
|
4246
|
+
className: 'n2s-input', style: { width: 120, height: 26, border: 0 }, 'aria-label': label, placeholder: t('filter.valuesPh'), value: draft,
|
|
4247
|
+
onChange: e => setDraft(e.target.value),
|
|
4248
|
+
onBlur: () => commit(draft),
|
|
4249
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && !e.nativeEvent?.isComposing) { e.preventDefault(); commit(draft) } },
|
|
4250
|
+
})
|
|
4251
|
+
}
|
|
3695
4252
|
// ------------------------------------------------------------ IM push
|
|
3696
4253
|
|
|
3697
4254
|
function makeReplyAction(ctx) {
|
|
@@ -4073,6 +4630,11 @@ window.__ModuleLoader__.load({
|
|
|
4073
4630
|
}, [])
|
|
4074
4631
|
const [state, setState] = useState(null)
|
|
4075
4632
|
const ref = useRef(null)
|
|
4633
|
+
useFocusTrap(ref)
|
|
4634
|
+
const closeTimer = useRef(null)
|
|
4635
|
+
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
|
4636
|
+
const busyRef = useRef(false)
|
|
4637
|
+
busyRef.current = Boolean(state?.busy)
|
|
4076
4638
|
useEffect(() => {
|
|
4077
4639
|
const def = channels.find(c => c.isDefault) ?? channels[0]
|
|
4078
4640
|
if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
|
|
@@ -4080,11 +4642,14 @@ window.__ModuleLoader__.load({
|
|
|
4080
4642
|
useEffect(() => {
|
|
4081
4643
|
const prev = document.activeElement
|
|
4082
4644
|
ref.current?.querySelector('select, textarea, input, button')?.focus()
|
|
4083
|
-
|
|
4645
|
+
// Escape belongs to a confirm/prompt dialog on top, if any.
|
|
4646
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) onClose() }
|
|
4084
4647
|
document.addEventListener('keydown', onKey)
|
|
4085
4648
|
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
4086
4649
|
}, [])
|
|
4087
4650
|
const send = async () => {
|
|
4651
|
+
if (busyRef.current) return
|
|
4652
|
+
busyRef.current = true
|
|
4088
4653
|
setState({ busy: true })
|
|
4089
4654
|
try {
|
|
4090
4655
|
const res = initial.dashboard
|
|
@@ -4092,7 +4657,7 @@ window.__ModuleLoader__.load({
|
|
|
4092
4657
|
: await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
|
|
4093
4658
|
setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
|
|
4094
4659
|
refreshChannels()
|
|
4095
|
-
setTimeout(onClose, 1200)
|
|
4660
|
+
closeTimer.current = setTimeout(onClose, 1200)
|
|
4096
4661
|
} catch (e) {
|
|
4097
4662
|
setState({ error: e.message })
|
|
4098
4663
|
}
|
|
@@ -4159,6 +4724,7 @@ window.__ModuleLoader__.load({
|
|
|
4159
4724
|
/** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
|
|
4160
4725
|
function Modal({ label, labelledBy, onClose, width, children, className }) {
|
|
4161
4726
|
const box = useRef(null)
|
|
4727
|
+
useFocusTrap(box)
|
|
4162
4728
|
useEffect(() => {
|
|
4163
4729
|
const prev = document.activeElement
|
|
4164
4730
|
setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
|
|
@@ -4184,13 +4750,13 @@ window.__ModuleLoader__.load({
|
|
|
4184
4750
|
h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
|
|
4185
4751
|
p && h(React.Fragment, null,
|
|
4186
4752
|
h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
|
|
4187
|
-
h('
|
|
4188
|
-
(p.kinds ?? []).map(k => h('
|
|
4753
|
+
h('ul', { className: 'n2s-kindcards', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
|
|
4754
|
+
(p.kinds ?? []).map(k => h('li', { key: k.id, className: 'n2s-kindcard-li' }, h('button', { type: 'button', 'data-nav': true, className: 'n2s-kindcard', onClick: () => onPick(p.platform, k.id) },
|
|
4189
4755
|
h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
|
|
4190
4756
|
k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
|
|
4191
4757
|
h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
|
|
4192
4758
|
h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
|
|
4193
|
-
k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
|
|
4759
|
+
k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl')))))))))
|
|
4194
4760
|
}
|
|
4195
4761
|
|
|
4196
4762
|
// ---------------------------------------------------------------- step 2
|
|
@@ -4427,7 +4993,7 @@ window.__ModuleLoader__.load({
|
|
|
4427
4993
|
|
|
4428
4994
|
// ----------------------------------------------------------------- list
|
|
4429
4995
|
|
|
4430
|
-
const shortTime = (ts) => new Date(ts).toLocaleString(
|
|
4996
|
+
const shortTime = (ts) => new Date(ts).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
4431
4997
|
|
|
4432
4998
|
function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
|
|
4433
4999
|
const kind = findKind(catalog, c.type)?.kind
|
|
@@ -4491,7 +5057,7 @@ window.__ModuleLoader__.load({
|
|
|
4491
5057
|
const channels = useChannels()
|
|
4492
5058
|
const bots = useBots()
|
|
4493
5059
|
const { catalog } = useCatalog(t)
|
|
4494
|
-
const deliveries = useAsync(() => call('listDeliveries'), [channels])
|
|
5060
|
+
const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
|
|
4495
5061
|
const [editing, setEditing] = useState(null)
|
|
4496
5062
|
// Pick up host upgrades (catalog, new channel shape) whenever the page opens.
|
|
4497
5063
|
useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
|
|
@@ -4617,7 +5183,12 @@ window.__ModuleLoader__.load({
|
|
|
4617
5183
|
useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
|
|
4618
5184
|
const filtersReady = filters !== null && filtersFor === boardId
|
|
4619
5185
|
const [sendingDash, setSendingDash] = useState(false)
|
|
4620
|
-
|
|
5186
|
+
// Saves are serialized so an older save can never land after a newer one.
|
|
5187
|
+
const saveChain = useRef(Promise.resolve())
|
|
5188
|
+
const changeFilters = (next) => {
|
|
5189
|
+
setFilters(next)
|
|
5190
|
+
saveChain.current = saveChain.current.then(() => call('saveDashboardFilters', { filters: next })).catch((e) => notifyError(e))
|
|
5191
|
+
}
|
|
4621
5192
|
const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
|
|
4622
5193
|
// Per-widget load status → header summary ("updated 10:42 · 2 from cache").
|
|
4623
5194
|
const [loads, setLoads] = useState({})
|
|
@@ -4680,7 +5251,7 @@ window.__ModuleLoader__.load({
|
|
|
4680
5251
|
h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
|
|
4681
5252
|
(mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
|
|
4682
5253
|
['all', 'current'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': scope === v, disabled: v === 'current' && !ds, onClick: () => setScope(v) }, v === 'all' ? t('dash.scopeAll') : fmt(t, 'dash.scopeCurrent', { name: ds?.name ?? '' }))))),
|
|
4683
|
-
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort'))
|
|
5254
|
+
showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: joinList(sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort'))) })),
|
|
4684
5255
|
h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
|
|
4685
5256
|
loadingN > 0 ? fmt(t, 'dash.loadingN', { n: loadingN }) : lastAt ? fmt(t, 'dash.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : '',
|
|
4686
5257
|
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,
|
|
@@ -4689,7 +5260,9 @@ window.__ModuleLoader__.load({
|
|
|
4689
5260
|
h(Dropdown, { label: t('dash.export'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, Icon.download(13), h('span', { className: 'n2s-hide-narrow' }, t('dash.export')), Icon.chev(10)) }, items: [
|
|
4690
5261
|
{ label: t('result.excel'), icon: Icon.download(14), onClick: () => exportExcel({ dashboardId: boards.value?.current }) },
|
|
4691
5262
|
{ label: t('dash.snapshot'), icon: Icon.pin(14), onClick: async () => { try { const s = await call('snapshotDashboard', { dashboardId: boards.value?.current }); notify(fmt(t, 'dash.snapshotDone', { n: s.widgets.length }), 'ok') } catch (e) { notifyError(e) } } },
|
|
5263
|
+
...extItems(extend.dashboardMenu, { t, boardId: boards.value?.current, widgets, filters: filters ?? [] }),
|
|
4692
5264
|
] }),
|
|
5265
|
+
...extend.dashboardBar.map((C, i) => h(C, { key: `x${i}`, t, boardId: boards.value?.current, widgets, filters: filters ?? [] })),
|
|
4693
5266
|
h(Dropdown, { label: t('dash.auto'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, Icon.refresh(13), h('span', { className: 'n2s-hide-narrow' }, auto ? fmt(t, 'dash.autoEvery', { min: auto / 60 }) : t('dash.autoShort')), Icon.chev(10)) },
|
|
4694
5267
|
items: [0, 60, 300, 900].map(v => ({ label: v === 0 ? t('dash.autoOff') : fmt(t, 'dash.autoEvery', { min: v / 60 }), checked: auto === v, onClick: () => setAuto(v) })) }),
|
|
4695
5268
|
h('button', { type: 'button', className: 'n2s-btn small', onClick: () => setTick(x => x + 1) }, t('dash.refresh')),
|
|
@@ -4701,7 +5274,7 @@ window.__ModuleLoader__.load({
|
|
|
4701
5274
|
!list.loading && allWidgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
|
|
4702
5275
|
widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
|
|
4703
5276
|
filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
|
|
4704
|
-
key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
|
|
5277
|
+
key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).filter(f => f.kind !== 'in' || f.values.length > 0).length,
|
|
4705
5278
|
onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
|
|
4706
5279
|
onMove: (delta) => move(w, delta),
|
|
4707
5280
|
onRemove: async () => { if (!await confirmDialog({ title: fmt(t, 'dash.removeConfirm', { title: w.title }), danger: true })) return; try { await call('unpinWidget', { id: w.id }); notify(t('dash.removed'), 'ok') } catch (e) { notifyError(e) } list.reload() },
|
|
@@ -4756,24 +5329,32 @@ window.__ModuleLoader__.load({
|
|
|
4756
5329
|
h('span', { className: 'n2s-wf-bar' }, h('span', { className: `n2s-wf-fill ${it.delta > 0 ? 'up' : it.delta < 0 ? 'down' : ''}`, style: { width: `${Math.max(2, Math.abs(it.delta) / max * 100)}%` } })),
|
|
4757
5330
|
h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
|
|
4758
5331
|
h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
|
|
4759
|
-
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(
|
|
5332
|
+
why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(listSep())}`))
|
|
4760
5333
|
}
|
|
4761
5334
|
|
|
4762
5335
|
// ------------------------------------------------------------ alerts
|
|
4763
5336
|
|
|
4764
5337
|
function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
|
|
4765
5338
|
const channels = useChannels()
|
|
4766
|
-
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: channels.find(c => c.isDefault)?.id ?? '', mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
|
|
5339
|
+
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: initial ? '' : (channels.find(c => c.isDefault)?.id ?? ''), mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
|
|
4767
5340
|
const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
|
|
4768
5341
|
const [error, setError] = useState(null)
|
|
4769
5342
|
const [test, setTest] = useState(null)
|
|
5343
|
+
const [busy, runBusy] = useBusy()
|
|
5344
|
+
// Channels may load after the form opens: adopt the default once, unless the user already chose.
|
|
5345
|
+
const channelTouched = useRef(Boolean(initial) || Boolean(form.channelId))
|
|
5346
|
+
useEffect(() => {
|
|
5347
|
+
if (channelTouched.current) return
|
|
5348
|
+
const def = channels.find(c => c.isDefault)
|
|
5349
|
+
if (def) { channelTouched.current = true; setForm(f => ({ ...f, channelId: def.id })) }
|
|
5350
|
+
}, [channels])
|
|
4770
5351
|
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
|
|
4771
5352
|
const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
|
|
4772
5353
|
const metrics = k.value?.metrics ?? []
|
|
4773
|
-
const save = async () => {
|
|
5354
|
+
const save = () => runBusy(async () => {
|
|
4774
5355
|
setError(null)
|
|
4775
5356
|
try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
|
|
4776
|
-
}
|
|
5357
|
+
})
|
|
4777
5358
|
const tryIt = async () => {
|
|
4778
5359
|
if (test?.busy) return
|
|
4779
5360
|
setTest({ busy: true })
|
|
@@ -4802,24 +5383,24 @@ window.__ModuleLoader__.load({
|
|
|
4802
5383
|
h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
|
|
4803
5384
|
h('label', { className: 'n2s-field' }, h('span', null, t('al.every')), h('select', { className: 'n2s-select', value: form.every, onChange: set('every') }, ['15m', 'hour', 'day'].map(v => h('option', { key: v, value: v }, t(`al.every.${v}`))))),
|
|
4804
5385
|
form.every === 'day' ? h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.at, onChange: set('at') })) : h('label', { className: 'n2s-field' }, h('span', null, t('al.cooldown')), h('input', { className: 'n2s-input', type: 'number', value: form.cooldownMin, onChange: set('cooldownMin') })),
|
|
4805
|
-
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
|
|
5386
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => { channelTouched.current = true; setForm(f => ({ ...f, channelId: v })) } })),
|
|
4806
5387
|
h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
|
|
4807
|
-
test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${test.triggered ? t('al.wouldFire') : t('al.wouldNotFire')}
|
|
5388
|
+
test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${colon(test.triggered ? t('al.wouldFire') : t('al.wouldNotFire'))}${test.message}`),
|
|
4808
5389
|
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
4809
5390
|
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
4810
5391
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
4811
5392
|
h('button', { type: 'button', className: 'n2s-btn', disabled: test?.busy, onClick: tryIt }, t('al.test')),
|
|
4812
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: save }, t('ds.save'))))
|
|
5393
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
4813
5394
|
}
|
|
4814
5395
|
|
|
4815
5396
|
function AlertsTab({ t, ds, datasources }) {
|
|
4816
5397
|
const list = useAsync(() => call('listAlerts'), [])
|
|
4817
|
-
const events = useAsync(() => call('listAlertEvents', {}), [list.value])
|
|
5398
|
+
const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
|
|
4818
5399
|
const [editing, setEditing] = useState(null)
|
|
4819
5400
|
const [notice, setNotice] = useState(null)
|
|
4820
5401
|
const act = async (fn, ok) => { setNotice(null); try { const r = await fn(); if (ok) setNotice({ ok: true, text: typeof ok === 'function' ? ok(r) : ok }) } catch (e) { setNotice({ ok: false, text: e.message }) } list.reload() }
|
|
4821
5402
|
const alerts = list.value ?? []
|
|
4822
|
-
const sched = useAsync(() => call('schedulerStatus'), [list.value])
|
|
5403
|
+
const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
|
|
4823
5404
|
const condText = (a) => a.condition.type === 'threshold' ? `${a.condition.op} ${a.condition.value}` : a.condition.type === 'change' ? `${t(`al.dir.${a.condition.direction ?? 'any'}`)} ≥ ${a.condition.pct}%` : a.condition.type === 'anomaly' ? `Z ≥ ${a.condition.z ?? 3}` : t('al.cond.missing')
|
|
4824
5405
|
return h('div', null,
|
|
4825
5406
|
h(SectionHead, { title: t('tab.alerts'), desc: t('al.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !ds, onClick: () => setEditing({}) }, '+ ', t('al.add'))),
|
|
@@ -4870,7 +5451,7 @@ window.__ModuleLoader__.load({
|
|
|
4870
5451
|
const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
|
|
4871
5452
|
return () => clearInterval(id)
|
|
4872
5453
|
}, [latest?.status, latest?.id])
|
|
4873
|
-
const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done])
|
|
5454
|
+
const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done], [open])
|
|
4874
5455
|
const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
|
|
4875
5456
|
const cases = set.value ?? []
|
|
4876
5457
|
return h('div', null,
|
|
@@ -4895,7 +5476,7 @@ window.__ModuleLoader__.load({
|
|
|
4895
5476
|
c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
|
|
4896
5477
|
h('div', { className: 'n2s-form', style: { marginTop: 6 } }, h('pre', { className: 'n2s-code' }, prettySql(c.expectedSql)), h('pre', { className: 'n2s-code' }, prettySql(c.actualSql))))))),
|
|
4897
5478
|
!open && cases.length > 0 && h('div', { className: 'n2s-list2', style: { marginTop: 12 } }, cases.map(c => h('div', { key: c.id, className: 'n2s-list2-row', style: { padding: '8px 16px' } },
|
|
4898
|
-
h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked })
|
|
5479
|
+
h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { try { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked }) } catch (err) { notifyError(err) } set.reload() } }),
|
|
4899
5480
|
h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
|
|
4900
5481
|
c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
|
|
4901
5482
|
}
|
|
@@ -4903,16 +5484,16 @@ window.__ModuleLoader__.load({
|
|
|
4903
5484
|
// ---------------------------------------------------- schema changes
|
|
4904
5485
|
|
|
4905
5486
|
function SchemaChanges({ t, ds }) {
|
|
4906
|
-
const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
|
|
5487
|
+
const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
|
|
4907
5488
|
const open = (list.value ?? []).filter(c => c.status === 'open')
|
|
4908
5489
|
if (!open.length) return null
|
|
4909
5490
|
const kindLabel = (k) => t(`sc.${k}`)
|
|
4910
5491
|
return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
|
|
4911
5492
|
open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
|
|
4912
5493
|
h('div', { className: 'n2s-row' }, h('strong', null, fmt(t, 'sc.title', { count: c.changes.length, time: new Date(c.at).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })), h('span', { className: 'n2s-grow' }),
|
|
4913
|
-
h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id })
|
|
4914
|
-
h('div', { className: 'n2s-small', style: { marginTop: 4 } }, c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${x.detail
|
|
4915
|
-
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}
|
|
5494
|
+
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'))),
|
|
5495
|
+
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)}`))),
|
|
5496
|
+
c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), joinList(c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}${uiLang === 'zh' ? '' : ' '}${quoted(i.name)}`))))))
|
|
4916
5497
|
}
|
|
4917
5498
|
|
|
4918
5499
|
// -------------------------------------------------- knowledge io
|
|
@@ -4920,9 +5501,26 @@ window.__ModuleLoader__.load({
|
|
|
4920
5501
|
function KnowledgeIO({ t, ds, datasources, onImported }) {
|
|
4921
5502
|
const fileRef = useRef(null)
|
|
4922
5503
|
const [state, setState] = useState(null)
|
|
5504
|
+
const box = useRef(null)
|
|
5505
|
+
useFocusTrap(box, Boolean(state))
|
|
5506
|
+
// A dry run belongs to the data source it was computed for.
|
|
5507
|
+
useEffect(() => { setState(null) }, [ds?.id])
|
|
5508
|
+
useEffect(() => {
|
|
5509
|
+
if (!state) return
|
|
5510
|
+
const prev = document.activeElement
|
|
5511
|
+
setTimeout(() => box.current?.querySelector('button.primary, button')?.focus(), 0)
|
|
5512
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) setState(null) }
|
|
5513
|
+
document.addEventListener('keydown', onKey)
|
|
5514
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
5515
|
+
}, [Boolean(state)])
|
|
5516
|
+
const [busy, runBusy] = useBusy()
|
|
5517
|
+
const busyRef = useRef(false)
|
|
5518
|
+
busyRef.current = busy
|
|
4923
5519
|
const doExport = async (all) => {
|
|
4924
|
-
|
|
4925
|
-
|
|
5520
|
+
try {
|
|
5521
|
+
const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
|
|
5522
|
+
download(`knowledge-${all ? 'all' : safeName(ds.name)}-${new Date().toISOString().slice(0, 10)}.json`, new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
|
|
5523
|
+
} catch (e) { notifyError(e) }
|
|
4926
5524
|
}
|
|
4927
5525
|
const onFile = async (e) => {
|
|
4928
5526
|
const f = e.target.files?.[0]
|
|
@@ -4932,13 +5530,14 @@ window.__ModuleLoader__.load({
|
|
|
4932
5530
|
const text = await f.text()
|
|
4933
5531
|
const doc = JSON.parse(text)
|
|
4934
5532
|
const single = doc.datasources?.length === 1
|
|
4935
|
-
const
|
|
4936
|
-
|
|
5533
|
+
const dsId = ds.id
|
|
5534
|
+
const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? dsId : undefined, dryRun: true })
|
|
5535
|
+
setState({ doc, single, dry, dsId, dsName: ds.name })
|
|
4937
5536
|
} catch (err) { setState({ error: err.message }) }
|
|
4938
5537
|
}
|
|
4939
|
-
const apply = async (
|
|
4940
|
-
try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ?
|
|
4941
|
-
}
|
|
5538
|
+
const apply = (mode) => runBusy(async () => {
|
|
5539
|
+
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 }) }
|
|
5540
|
+
})
|
|
4942
5541
|
return h(React.Fragment, null,
|
|
4943
5542
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
|
|
4944
5543
|
h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
|
|
@@ -4948,17 +5547,17 @@ window.__ModuleLoader__.load({
|
|
|
4948
5547
|
{ label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
|
|
4949
5548
|
] }),
|
|
4950
5549
|
state && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setState(null) } },
|
|
4951
|
-
h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true },
|
|
5550
|
+
h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('kb.import') },
|
|
4952
5551
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
|
|
4953
5552
|
state.error && h('div', { className: 'n2s-notice err' }, state.error),
|
|
4954
5553
|
state.dry && h(React.Fragment, null,
|
|
4955
|
-
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name:
|
|
5554
|
+
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name: state.dsName }) : t('kb.importByName')),
|
|
4956
5555
|
h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('kb.src'), t('kb.target'), t('kb.glossary'), t('kb.metrics'), t('kb.notes'), t('kb.examples'), t('kb.invalid')].map((x, i) => h('th', { key: i }, x)))),
|
|
4957
5556
|
h('tbody', null, state.dry.report.map((r, i) => h('tr', { key: i }, h('td', null, r.source), h('td', null, r.target ?? h('span', { style: { color: 'var(--n2s-err)' } }, r.skipped)), h('td', null, r.glossary ?? '—'), h('td', null, r.metrics ?? '—'), h('td', null, r.notes ?? '—'), h('td', null, r.examples ?? '—'), h('td', null, r.invalid ?? '—'))))),
|
|
4958
5557
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
4959
5558
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setState(null) }, t('ds.cancel')),
|
|
4960
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
|
|
4961
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => apply('merge') }, t('kb.merge')))),
|
|
5559
|
+
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')),
|
|
5560
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => apply('merge') }, busy ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge')))),
|
|
4962
5561
|
state.done && h(React.Fragment, null, h('div', { className: 'n2s-notice ok' }, t('kb.imported')), h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setState(null) }, t('ds.close')))))), document.body))
|
|
4963
5562
|
}
|
|
4964
5563
|
|
|
@@ -4981,7 +5580,7 @@ window.__ModuleLoader__.load({
|
|
|
4981
5580
|
if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
|
|
4982
5581
|
if (s.kind === 'metric') return h('div', null,
|
|
4983
5582
|
h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
|
|
4984
|
-
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions
|
|
5583
|
+
(it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${joinList(it.dimensions)}`].filter(Boolean).join(' · ')))
|
|
4985
5584
|
if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
|
|
4986
5585
|
return h('div', null, h('div', { className: 'n2s-small' }, it.definition), it.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 4 } }, it.sql))
|
|
4987
5586
|
}
|
|
@@ -4999,14 +5598,17 @@ window.__ModuleLoader__.load({
|
|
|
4999
5598
|
const [busy, setBusy] = useState(null)
|
|
5000
5599
|
const items = list.value?.suggestions ?? []
|
|
5001
5600
|
const done = () => { list.reload(); onChanged?.() }
|
|
5601
|
+
const inFlight = useRef(new Set())
|
|
5002
5602
|
const accept = async (s, item) => {
|
|
5603
|
+
if (inFlight.current.has(s.id)) return
|
|
5604
|
+
inFlight.current.add(s.id)
|
|
5003
5605
|
setBusy(s.id)
|
|
5004
5606
|
try {
|
|
5005
5607
|
await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
|
|
5006
5608
|
setEditing(null)
|
|
5007
5609
|
notify(t('sg.accepted'), 'ok')
|
|
5008
5610
|
done()
|
|
5009
|
-
} catch (e) { notifyError(e) } finally { setBusy(null) }
|
|
5611
|
+
} catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
|
|
5010
5612
|
}
|
|
5011
5613
|
const dismiss = async (s) => {
|
|
5012
5614
|
if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
|
|
@@ -5029,8 +5631,8 @@ window.__ModuleLoader__.load({
|
|
|
5029
5631
|
h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
|
|
5030
5632
|
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
|
|
5031
5633
|
h(Dropdown, { label: t('sg.acceptDirect'), trigger: { className: 'n2s-iconbtn', content: Icon.chev(10) }, items: [
|
|
5032
|
-
{ label: t('sg.acceptEdit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
|
|
5033
|
-
{ label: t('sg.acceptDirect'), icon: Icon.check(14), onClick: () => accept(s) },
|
|
5634
|
+
{ label: t('sg.acceptEdit'), icon: Icon.edit(14), disabled: busy === s.id, onClick: () => setEditing(s) },
|
|
5635
|
+
{ label: t('sg.acceptDirect'), icon: Icon.check(14), disabled: busy === s.id, onClick: () => accept(s) },
|
|
5034
5636
|
] }),
|
|
5035
5637
|
h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
|
|
5036
5638
|
editing?.id === s.id && h('div', { style: { marginTop: 10 } },
|
|
@@ -5119,10 +5721,10 @@ window.__ModuleLoader__.load({
|
|
|
5119
5721
|
seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
|
|
5120
5722
|
items.length === 0
|
|
5121
5723
|
? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
|
|
5122
|
-
: h('div', { className: 'n2s-pickchips', role: '
|
|
5724
|
+
: h('div', { className: 'n2s-pickchips', role: 'group', 'aria-label': kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people') }, items.slice(0, 30).map(x => {
|
|
5123
5725
|
const taken = x.memberId && !isChosen(x)
|
|
5124
5726
|
return h('button', {
|
|
5125
|
-
key: `${x.platform}:${x.id}`, type: 'button',
|
|
5727
|
+
key: `${x.platform}:${x.id}`, type: 'button', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
|
|
5126
5728
|
title: [`${platformName(t, x.platform, catalog)} · ${x.id}`, x.email, x.via ? fmt(t, 'mb.pick.via', { name: x.via }) : '', taken ? fmt(t, 'mb.pick.taken', { name: x.memberName }) : '', x.refused ? t('mb.pick.refused') : ''].filter(Boolean).join('\n'),
|
|
5127
5729
|
onClick: () => onPick(x),
|
|
5128
5730
|
},
|
|
@@ -5300,8 +5902,8 @@ window.__ModuleLoader__.load({
|
|
|
5300
5902
|
h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
|
|
5301
5903
|
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
5302
5904
|
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, u.name), h('span', { className: 'n2s-badge' }, t(`mb.role.${u.role}`)), u.disabled && h('span', { className: 'n2s-badge err' }, t('mb.disabled'))),
|
|
5303
|
-
h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(
|
|
5304
|
-
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`)
|
|
5905
|
+
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'))),
|
|
5906
|
+
(u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${joinClauses(u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`))}`)),
|
|
5305
5907
|
h('div', { className: 'n2s-list2-side' }, h('span', { className: 'n2s-small' }, us ? fmt(t, 'mb.queries', { n: us.queries }) : t('mb.noUsage')), us?.lastAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(us.lastAt).toLocaleDateString())),
|
|
5306
5908
|
h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
|
|
5307
5909
|
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
|
|
@@ -5319,7 +5921,7 @@ window.__ModuleLoader__.load({
|
|
|
5319
5921
|
// ---------------------------------------------------- dashboards bar
|
|
5320
5922
|
|
|
5321
5923
|
function DashboardSwitcher({ t, value, onChange }) {
|
|
5322
|
-
const list = useAsync(() => call('listDashboards'), [value?.id])
|
|
5924
|
+
const list = useAsync(() => call('listDashboards'), [value?.id], [])
|
|
5323
5925
|
const v = list.value
|
|
5324
5926
|
if (!v) return null
|
|
5325
5927
|
const cur = v.dashboards.find(d => d.id === v.current)
|
|
@@ -5328,7 +5930,7 @@ window.__ModuleLoader__.load({
|
|
|
5328
5930
|
v.dashboards.map(d => h('button', { key: d.id, type: 'button', role: 'tab', 'aria-selected': d.id === v.current, onClick: () => act(() => call('selectDashboard', { id: d.id })) }, d.name, h('span', { className: 'count' }, d.widgets))),
|
|
5329
5931
|
h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
|
|
5330
5932
|
{ label: t('dash.new'), icon: Icon.chart(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName') }); if (name) act(() => call('saveDashboard', { name })) } },
|
|
5331
|
-
{ label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value:
|
|
5933
|
+
{ label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value: fmt(t, 'dash.copyName', { name: cur?.name ?? '' }) }); if (name) act(() => call('saveDashboard', { name, copyFrom: v.current })) } },
|
|
5332
5934
|
{ label: t('dash.renameBoard'), icon: Icon.edit(14), onClick: async () => { const name = await promptDialog({ title: t('dash.renameBoard'), value: cur?.name }); if (name) act(() => call('saveDashboard', { id: v.current, name })) } },
|
|
5333
5935
|
{ divider: true },
|
|
5334
5936
|
v.dashboards.length > 1 && { label: t('dash.deleteBoard'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'dash.deleteBoardConfirm', { name: cur?.name }), danger: true })) act(() => call('deleteDashboard', { id: v.current })) } },
|
|
@@ -5340,7 +5942,7 @@ window.__ModuleLoader__.load({
|
|
|
5340
5942
|
const v = st.value
|
|
5341
5943
|
return h('div', { className: 'n2s-row' },
|
|
5342
5944
|
h('span', { className: 'n2s-small n2s-muted' }, v ? fmt(t, 'settings.cacheStatsText', { results: v.results, mb: (v.bytes / 1048576).toFixed(1), cache: v.cacheEntries }) : '…'),
|
|
5343
|
-
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { await call('clearCache')
|
|
5945
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { try { await call('clearCache') } catch (e) { notifyError(e) } st.reload() } }, t('settings.clearCache')))
|
|
5344
5946
|
}
|
|
5345
5947
|
|
|
5346
5948
|
function ChannelSelect({ t, value, onChange }) {
|
|
@@ -5348,8 +5950,25 @@ window.__ModuleLoader__.load({
|
|
|
5348
5950
|
}
|
|
5349
5951
|
|
|
5350
5952
|
// Questions started from the page bind their new session to the chosen
|
|
5351
|
-
// data source as soon as the session appears in the composer.
|
|
5953
|
+
// data source as soon as the session appears in the composer. Only the
|
|
5954
|
+
// session our own startSession created may claim an ask: the one whose id
|
|
5955
|
+
// startSession returned (when the host returns it), otherwise a session the
|
|
5956
|
+
// composer had not seen before the ask was made, within PENDING_ASK_MS.
|
|
5352
5957
|
const pendingAsks = []
|
|
5958
|
+
const seenSessions = new Set()
|
|
5959
|
+
const PENDING_ASK_MS = 60000
|
|
5960
|
+
function addPendingAsk(datasourceId, question) {
|
|
5961
|
+
const pending = { datasourceId, question, at: Date.now(), seen: new Set(seenSessions), sessionId: null }
|
|
5962
|
+
pendingAsks.push(pending)
|
|
5963
|
+
setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, PENDING_ASK_MS)
|
|
5964
|
+
return pending
|
|
5965
|
+
}
|
|
5966
|
+
/** Remove and return the pending ask that belongs to this session, if any. */
|
|
5967
|
+
function claimPendingAsk(sessionId) {
|
|
5968
|
+
const now = Date.now()
|
|
5969
|
+
const i = pendingAsks.findIndex(p => now - p.at < PENDING_ASK_MS && (p.sessionId ? p.sessionId === sessionId : !p.seen.has(sessionId)))
|
|
5970
|
+
return i >= 0 ? pendingAsks.splice(i, 1)[0] : null
|
|
5971
|
+
}
|
|
5353
5972
|
|
|
5354
5973
|
// ------------------------------------------------ composer selector
|
|
5355
5974
|
|
|
@@ -5366,8 +5985,15 @@ window.__ModuleLoader__.load({
|
|
|
5366
5985
|
if (!sessionId) return
|
|
5367
5986
|
let cancelled = false
|
|
5368
5987
|
;(async () => {
|
|
5369
|
-
const
|
|
5370
|
-
|
|
5988
|
+
const fresh = pendingAsks.length > 0 && !seenSessions.has(sessionId)
|
|
5989
|
+
seenSessions.add(sessionId)
|
|
5990
|
+
if (fresh) {
|
|
5991
|
+
// A session already bound to a data source, or running another preset, is not ours.
|
|
5992
|
+
const info = await call('sessionInfo', { sessionId }).catch(() => null)
|
|
5993
|
+
const candidate = info && !info.boundDatasourceId && (info.preset === PRESET || info.preset === null || info.preset === undefined)
|
|
5994
|
+
const pending = candidate ? claimPendingAsk(sessionId) : null
|
|
5995
|
+
if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
|
|
5996
|
+
}
|
|
5371
5997
|
if (!cancelled) await loadSessionInfo(sessionId)
|
|
5372
5998
|
})()
|
|
5373
5999
|
if (!store.get().loaded) refreshDatasources()
|
|
@@ -5502,7 +6128,13 @@ window.__ModuleLoader__.load({
|
|
|
5502
6128
|
// where the user was.
|
|
5503
6129
|
|
|
5504
6130
|
const PRIMARY_TABS = ['ask', 'dashboard', 'schema', 'knowledge', 'history']
|
|
5505
|
-
const
|
|
6131
|
+
const BASE_ADMIN_SECTIONS = ['sources', 'members', 'automation', 'push', 'settings']
|
|
6132
|
+
// Feature sections (extend.adminSections) go before 设置.
|
|
6133
|
+
const adminSections = () => [...BASE_ADMIN_SECTIONS.slice(0, -1), ...extend.adminSections.map(x => x.id), 'settings']
|
|
6134
|
+
// Nav groups: feature sections are grouped by purpose so the list stays scannable.
|
|
6135
|
+
const NAV_GROUP_OF = { sources: 'core', members: 'core', automation: 'core', push: 'core', health: 'insight', reconcile: 'insight', goals: 'insight', digests: 'insight', subscriptions: 'insight', bitable: 'connect', mcp: 'connect', settings: 'end' }
|
|
6136
|
+
const navGroupOf = (k) => NAV_GROUP_OF[k] ?? extend.adminSections.find(x => x.id === k)?.group ?? 'insight'
|
|
6137
|
+
const NAV_GROUP_ORDER = ['core', 'insight', 'connect', 'end']
|
|
5506
6138
|
const NAV_KEY = 'nl2sql:nav'
|
|
5507
6139
|
|
|
5508
6140
|
function loadNav() {
|
|
@@ -5519,17 +6151,21 @@ window.__ModuleLoader__.load({
|
|
|
5519
6151
|
}
|
|
5520
6152
|
|
|
5521
6153
|
function AdminArea({ t, ctx, section, sub, onNavigate, ds, datasources }) {
|
|
5522
|
-
const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section])
|
|
6154
|
+
const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section], [])
|
|
5523
6155
|
const [alerts, reports, channels, users] = counts.value ?? []
|
|
5524
6156
|
const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
|
|
5525
6157
|
const firing = (alerts ?? []).filter(a => a.state === 'firing').length
|
|
5526
6158
|
const autoSub = sub === 'reports' ? 'reports' : 'alerts'
|
|
5527
6159
|
return h('div', { className: 'n2s-admin' },
|
|
5528
6160
|
h('nav', { className: 'n2s-adminnav', 'aria-label': t('tab.admin') },
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
6161
|
+
NAV_GROUP_ORDER.flatMap(g => {
|
|
6162
|
+
const keys = adminSections().filter(k => navGroupOf(k) === g)
|
|
6163
|
+
if (!keys.length) return []
|
|
6164
|
+
return [g !== 'core' && g !== 'end' && h('div', { key: `g:${g}`, className: 'n2s-adminnav-group', role: 'presentation' }, t(`admin.group.${g}`)), g === 'end' && h('div', { key: 'g:end', className: 'n2s-adminnav-sep', role: 'presentation' }), ...keys.map(k => h('button', { key: k, type: 'button', className: 'n2s-adminnav-item', 'aria-current': section === k ? 'page' : undefined, onClick: () => onNavigate({ section: k, sub: null }) },
|
|
6165
|
+
h('span', { className: 'ico', 'aria-hidden': true }, (AdminIcons[k] ?? extend.adminSections.find(x => x.id === k)?.icon ?? (() => Icon.chart(15)))()),
|
|
6166
|
+
h('span', { className: 'n2s-grow' }, extend.adminSections.find(x => x.id === k)?.label(t) ?? t(`admin.${k}`)),
|
|
6167
|
+
k === 'automation' && firing > 0 ? h('span', { className: 'n2s-navbadge err', title: t('al.firing') }, firing) : badge[k] ? h('span', { className: 'n2s-navbadge' }, badge[k]) : null))].filter(Boolean)
|
|
6168
|
+
})),
|
|
5533
6169
|
h('div', { className: 'n2s-adminbody' },
|
|
5534
6170
|
section === 'sources' ? h(SourcesTab, { t, datasources }) :
|
|
5535
6171
|
section === 'members' ? h(MembersTab, { t, datasources }) :
|
|
@@ -5540,6 +6176,7 @@ window.__ModuleLoader__.load({
|
|
|
5540
6176
|
k === 'reports' && reports?.length ? h('span', { className: 'n2s-faint', style: { marginLeft: 6 } }, reports.length) : null))),
|
|
5541
6177
|
autoSub === 'alerts' ? h(AlertsTab, { t, ds, datasources }) : h(ReportsTab, { t, ds, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id), onOpenSchedules: () => ctx.layout.selectPanel('schedules') })) :
|
|
5542
6178
|
section === 'push' ? h(PushTab, { t }) :
|
|
6179
|
+
extend.adminSections.some(x => x.id === section) ? h(extend.adminSections.find(x => x.id === section).Component, { t, ds, datasources, onNavigate }) :
|
|
5543
6180
|
h(SettingsPage, { t, datasources, onNavigate })))
|
|
5544
6181
|
}
|
|
5545
6182
|
|
|
@@ -5551,32 +6188,49 @@ window.__ModuleLoader__.load({
|
|
|
5551
6188
|
const { datasources, loaded, error } = useStore(s => s)
|
|
5552
6189
|
const [nav, setNavState] = useState(loadNav)
|
|
5553
6190
|
const setNav = useCallback((patch) => setNavState(n => { const next = { ...n, ...patch }; try { localStorage.setItem(NAV_KEY, JSON.stringify(next)) } catch {} return next }), [])
|
|
6191
|
+
// Leaving 管理 → 设置 with unsaved settings asks first (SettingsPage sets fbCtx.settingsDirty).
|
|
6192
|
+
const navRef = useRef(nav)
|
|
6193
|
+
navRef.current = nav
|
|
6194
|
+
const guardedNav = useCallback(async (patch) => {
|
|
6195
|
+
const cur = navRef.current
|
|
6196
|
+
const next = { ...cur, ...patch }
|
|
6197
|
+
const inSettings = cur.tab === 'admin' && cur.section === 'settings'
|
|
6198
|
+
const leaving = inSettings && (next.tab !== 'admin' || next.section !== 'settings')
|
|
6199
|
+
if (leaving && fbCtx.settingsDirty) {
|
|
6200
|
+
const ok = await confirmDialog({ title: t('set.leaveTitle'), message: t('set.leaveMsg'), confirmLabel: t('set.leaveOk'), danger: true })
|
|
6201
|
+
if (!ok) return false
|
|
6202
|
+
fbCtx.settingsDirty = false
|
|
6203
|
+
}
|
|
6204
|
+
setNav(patch)
|
|
6205
|
+
return true
|
|
6206
|
+
}, [])
|
|
5554
6207
|
const [selectedId, setSelectedId] = useState(null)
|
|
5555
6208
|
useEffect(() => { refreshDatasources() }, [])
|
|
5556
6209
|
const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
|
|
5557
6210
|
const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
|
|
5558
6211
|
const ask = useCallback((question) => {
|
|
5559
6212
|
if (!ds) return
|
|
5560
|
-
const pending =
|
|
5561
|
-
pendingAsks.push(pending)
|
|
5562
|
-
setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, 60000)
|
|
6213
|
+
const pending = addPendingAsk(ds.id, question)
|
|
5563
6214
|
try {
|
|
5564
|
-
ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
|
|
6215
|
+
const r = ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
|
|
6216
|
+
// Hosts that return the new session id (directly or as a promise) pin the ask to it.
|
|
6217
|
+
const adopt = (id) => { if (typeof id === 'string' && id) pending.sessionId = id }
|
|
6218
|
+
if (r && typeof r.then === 'function') r.then(adopt, () => {}); else adopt(r)
|
|
5565
6219
|
} catch (e) {
|
|
6220
|
+
const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
|
|
5566
6221
|
notifyError(e)
|
|
5567
6222
|
}
|
|
5568
6223
|
}, [ds?.id])
|
|
5569
6224
|
fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
|
|
5570
|
-
fbCtx.navigate = (patch) =>
|
|
6225
|
+
fbCtx.navigate = (patch) => { guardedNav(patch) }
|
|
5571
6226
|
const tabs = [...PRIMARY_TABS, 'admin']
|
|
5572
6227
|
const onTabKey = (e) => {
|
|
5573
6228
|
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
|
5574
6229
|
const i = tabs.indexOf(nav.tab)
|
|
5575
6230
|
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]
|
|
5576
|
-
|
|
5577
|
-
setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0)
|
|
6231
|
+
guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
|
|
5578
6232
|
}
|
|
5579
|
-
const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () =>
|
|
6233
|
+
const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () => { guardedNav({ tab: k }) } },
|
|
5580
6234
|
k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
|
|
5581
6235
|
const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
|
|
5582
6236
|
const showPicker = datasources.length > 0 && nav.tab !== 'admin'
|
|
@@ -5591,20 +6245,19 @@ window.__ModuleLoader__.load({
|
|
|
5591
6245
|
h('span', { className: 'name' }, ds?.name ?? '—'),
|
|
5592
6246
|
ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
|
|
5593
6247
|
Icon.chev(10),
|
|
5594
|
-
h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); await call('setCurrent', { id })
|
|
6248
|
+
h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); try { await call('setCurrent', { id }) } catch (err) { notifyError(err) } refreshDatasources() } },
|
|
5595
6249
|
datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
|
|
5596
6250
|
h('div', { className: 'n2s-tabs', role: 'tablist', 'aria-label': t('page.title'), onKeyDown: onTabKey }, PRIMARY_TABS.map(tabBtn), h('span', { className: 'n2s-grow' }), tabBtn('admin'))),
|
|
5597
6251
|
h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
|
|
5598
6252
|
error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
|
|
5599
6253
|
!loaded ? h('div', { style: { display: 'grid', gap: 12 } }, h('div', { className: 'n2s-skel', style: { height: 120 } }), h('div', { className: 'n2s-skel', style: { height: 60 } })) :
|
|
5600
|
-
needsSource ? h(EmptySourceHint, { t, onGo: () =>
|
|
5601
|
-
nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate:
|
|
5602
|
-
nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment
|
|
5603
|
-
nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
|
|
6254
|
+
needsSource ? h(EmptySourceHint, { t, onGo: () => guardedNav({ tab: 'admin', section: 'sources' }) }) :
|
|
6255
|
+
nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: guardedNav }) :
|
|
6256
|
+
nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: paren(tb.comment) })) }) :
|
|
6257
|
+
nav.tab === 'knowledge' ? h(KnowledgeTab, { key: `kb:${ds?.id ?? ''}`, t, ds }) :
|
|
5604
6258
|
nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
|
|
5605
6259
|
nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
|
|
5606
|
-
h(AdminArea, { t, ctx, section:
|
|
5607
|
-
h(FeedbackLayer, { t })))
|
|
6260
|
+
h(AdminArea, { t, ctx, section: adminSections().includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
|
|
5608
6261
|
}
|
|
5609
6262
|
}
|
|
5610
6263
|
|
|
@@ -5706,7 +6359,7 @@ window.__ModuleLoader__.load({
|
|
|
5706
6359
|
form.resultToModel === 'rows' && h(SettingRow, { label: t('settings.modelRows'), hint: t('set.h.modelRows'), changed: changed('modelRows'), htmlFor: 'set-modelRows' }, num('modelRows', { min: 0, max: 500 })),
|
|
5707
6360
|
h(SettingRow, { label: t('set.l.mask'), hint: t('set.h.maskSensitive'), changed: changed('maskSensitive') }, h('label', { className: 'n2s-switch' }, h('input', { type: 'checkbox', role: 'switch', checked: Boolean(form.maskSensitive), onChange: e => set('maskSensitive', e.target.checked) }), h('span', null, form.maskSensitive ? t('set.on') : t('set.off')))),
|
|
5708
6361
|
h('div', { className: 'n2s-setnote' }, overrides.length
|
|
5709
|
-
? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 &&
|
|
6362
|
+
? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 && listSep(), h('button', { type: 'button', className: 'n2s-link', onClick: () => onNavigate({ section: 'sources' }) }, d.name))))
|
|
5710
6363
|
: t('set.noOverrides'))) :
|
|
5711
6364
|
group === 'query' ? h(React.Fragment, null,
|
|
5712
6365
|
h(SettingRow, { label: t('set.l.maxRows'), hint: t('set.h.maxRows'), changed: changed('maxRows'), htmlFor: 'set-maxRows' }, num('maxRows', { min: 1, max: 100000 })),
|
|
@@ -5810,6 +6463,17 @@ window.__ModuleLoader__.load({
|
|
|
5810
6463
|
const retention = form.retention ?? defaults.retention ?? {}
|
|
5811
6464
|
const fileRef = useRef(null)
|
|
5812
6465
|
const [importState, setImportState] = useState(null)
|
|
6466
|
+
const importBox = useRef(null)
|
|
6467
|
+
useFocusTrap(importBox, Boolean(importState))
|
|
6468
|
+
const [importing, runImport] = useBusy()
|
|
6469
|
+
useEffect(() => {
|
|
6470
|
+
if (!importState) return
|
|
6471
|
+
const prev = document.activeElement
|
|
6472
|
+
setTimeout(() => importBox.current?.querySelector('button.primary')?.focus(), 0)
|
|
6473
|
+
const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) setImportState(null) }
|
|
6474
|
+
document.addEventListener('keydown', onKey)
|
|
6475
|
+
return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
|
|
6476
|
+
}, [Boolean(importState)])
|
|
5813
6477
|
const exportAll = async () => {
|
|
5814
6478
|
try {
|
|
5815
6479
|
const doc = await call('exportConfig')
|
|
@@ -5827,7 +6491,7 @@ window.__ModuleLoader__.load({
|
|
|
5827
6491
|
setImportState({ doc, dry })
|
|
5828
6492
|
} catch (err) { notifyError(err) }
|
|
5829
6493
|
}
|
|
5830
|
-
const doImport = async (
|
|
6494
|
+
const doImport = (mode) => runImport(async () => {
|
|
5831
6495
|
if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
|
|
5832
6496
|
try {
|
|
5833
6497
|
const r = await call('importConfig', { data: importState.doc, mode })
|
|
@@ -5837,7 +6501,7 @@ window.__ModuleLoader__.load({
|
|
|
5837
6501
|
const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
|
|
5838
6502
|
notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
|
|
5839
6503
|
} catch (err) { notifyError(err) }
|
|
5840
|
-
}
|
|
6504
|
+
})
|
|
5841
6505
|
const purge = async (kind) => {
|
|
5842
6506
|
if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
|
|
5843
6507
|
try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
|
|
@@ -5863,17 +6527,17 @@ window.__ModuleLoader__.load({
|
|
|
5863
6527
|
h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
|
|
5864
6528
|
h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
|
|
5865
6529
|
importState && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setImportState(null) } },
|
|
5866
|
-
h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import')
|
|
6530
|
+
h('div', { ref: importBox, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import') },
|
|
5867
6531
|
h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
|
|
5868
6532
|
h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 10 } }, fmt(t, 'set.importFrom', { time: importState.doc.exportedAt ? new Date(importState.doc.exportedAt).toLocaleString() : '?' })),
|
|
5869
6533
|
h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('set.section'), t('set.toAdd'), t('set.toUpdate'), t('set.note')].map((x, i) => h('th', { key: i }, x)))),
|
|
5870
6534
|
h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
|
|
5871
6535
|
h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
|
|
5872
|
-
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret
|
|
6536
|
+
h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, joinClauses([r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: joinList(r.needsSecret) }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''])))))),
|
|
5873
6537
|
h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
|
|
5874
6538
|
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
|
|
5875
|
-
h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
|
|
5876
|
-
h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => doImport('merge') }, t('kb.merge'))))), document.body))
|
|
6539
|
+
h('button', { type: 'button', className: 'n2s-btn danger', disabled: importing, onClick: () => doImport('replace') }, t('kb.replace')),
|
|
6540
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: importing, onClick: () => doImport('merge') }, importing ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge'))))), document.body))
|
|
5877
6541
|
}
|
|
5878
6542
|
|
|
5879
6543
|
function AboutSection({ t }) {
|
|
@@ -5888,6 +6552,1307 @@ window.__ModuleLoader__.load({
|
|
|
5888
6552
|
row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
|
|
5889
6553
|
}
|
|
5890
6554
|
|
|
6555
|
+
// ------------------------------------------------------------ data freshness + quality (health)
|
|
6556
|
+
// - resultPanels: a warning bar under results whose referenced tables are stale
|
|
6557
|
+
// (server resolves the tables with policy.js referencedTables via freshnessFor({ sql })).
|
|
6558
|
+
// - schemaPanels: tables with lag (green / amber / red) and a refresh button.
|
|
6559
|
+
// - adminSections 'health': freshness table with per-table thresholds, quality patrol
|
|
6560
|
+
// results, run now, settings (channels, thresholds, schedule).
|
|
6561
|
+
// - toolViews: conversation view of the nl2sql_freshness agent tool.
|
|
6562
|
+
|
|
6563
|
+
/** Shared freshness cache: key → { at, promise, value }; bumping `rev` re-renders subscribers. */
|
|
6564
|
+
const fqCache = (() => {
|
|
6565
|
+
let rev = 0
|
|
6566
|
+
const map = new Map()
|
|
6567
|
+
const subs = new Set()
|
|
6568
|
+
const TTL = 5 * 60000
|
|
6569
|
+
return {
|
|
6570
|
+
get(datasourceId, sql) {
|
|
6571
|
+
const key = `${datasourceId}\u0000${sql ?? ''}`
|
|
6572
|
+
const hit = map.get(key)
|
|
6573
|
+
if (hit && Date.now() - hit.at < TTL) return hit.promise
|
|
6574
|
+
const promise = call('freshnessFor', { datasourceId, ...(sql ? { sql } : {}) })
|
|
6575
|
+
map.set(key, { at: Date.now(), promise })
|
|
6576
|
+
promise.catch(() => map.delete(key))
|
|
6577
|
+
return promise
|
|
6578
|
+
},
|
|
6579
|
+
async refresh(datasourceId) {
|
|
6580
|
+
const v = await call('freshnessFor', { datasourceId, refresh: true })
|
|
6581
|
+
for (const k of [...map.keys()]) if (k.startsWith(`${datasourceId}\u0000`)) map.delete(k)
|
|
6582
|
+
map.set(`${datasourceId}\u0000`, { at: Date.now(), promise: Promise.resolve(v) })
|
|
6583
|
+
rev++
|
|
6584
|
+
for (const f of subs) f()
|
|
6585
|
+
return v
|
|
6586
|
+
},
|
|
6587
|
+
subscribe(f) { subs.add(f); return () => subs.delete(f) },
|
|
6588
|
+
rev: () => rev,
|
|
6589
|
+
}
|
|
6590
|
+
})()
|
|
6591
|
+
|
|
6592
|
+
const fqStatusClass = { ok: 'ok', warn: 'warn', stale: 'err', error: 'err', empty: 'off', off: 'off', unknown: 'off' }
|
|
6593
|
+
const fqPad = (n) => String(n).padStart(2, '0')
|
|
6594
|
+
|
|
6595
|
+
function fqLag(t, min) {
|
|
6596
|
+
if (min === null || min === undefined || !Number.isFinite(min)) return '—'
|
|
6597
|
+
const m = Math.max(0, Math.round(min))
|
|
6598
|
+
if (m < 60) return fmt(t, 'fq.lagMin', { n: m })
|
|
6599
|
+
if (m < 48 * 60) return fmt(t, 'fq.lagHour', { n: Math.round(m / 60) })
|
|
6600
|
+
return fmt(t, 'fq.lagDay', { n: Math.round(m / 1440) })
|
|
6601
|
+
}
|
|
6602
|
+
|
|
6603
|
+
/** 「昨天 17:55」 / "yesterday 17:55" relative to now. */
|
|
6604
|
+
function fqWhen(t, ms) {
|
|
6605
|
+
if (!Number.isFinite(ms)) return '—'
|
|
6606
|
+
const d = new Date(ms)
|
|
6607
|
+
const now = new Date()
|
|
6608
|
+
const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
|
6609
|
+
const diff = Math.round((day(now) - day(d)) / 86400000)
|
|
6610
|
+
const hm = `${fqPad(d.getHours())}:${fqPad(d.getMinutes())}`
|
|
6611
|
+
if (diff === 0) return fmt(t, 'fq.today', { time: hm })
|
|
6612
|
+
if (diff === 1) return fmt(t, 'fq.yesterday', { time: hm })
|
|
6613
|
+
return `${d.getFullYear() === now.getFullYear() ? '' : `${d.getFullYear()}-`}${fqPad(d.getMonth() + 1)}-${fqPad(d.getDate())} ${hm}`
|
|
6614
|
+
}
|
|
6615
|
+
|
|
6616
|
+
const fqTime = (at) => (at ? new Date(at).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—')
|
|
6617
|
+
|
|
6618
|
+
function useFqRev() {
|
|
6619
|
+
return useSyncExternalStore(fqCache.subscribe, fqCache.rev, fqCache.rev)
|
|
6620
|
+
}
|
|
6621
|
+
|
|
6622
|
+
// ---- result warning bar
|
|
6623
|
+
|
|
6624
|
+
function FreshnessResultPanel({ t, result }) {
|
|
6625
|
+
const rev = useFqRev()
|
|
6626
|
+
const dsId = result?.datasourceId
|
|
6627
|
+
const sql = result?.sql
|
|
6628
|
+
const [view, setView] = useState(null)
|
|
6629
|
+
useEffect(() => {
|
|
6630
|
+
if (!dsId || !sql || result?.error) return
|
|
6631
|
+
let alive = true
|
|
6632
|
+
fqCache.get(dsId, sql).then(v => alive && setView(v), () => {})
|
|
6633
|
+
return () => { alive = false }
|
|
6634
|
+
}, [dsId, sql, rev])
|
|
6635
|
+
const stale = (view?.tables ?? []).filter(x => x.stale)
|
|
6636
|
+
if (!stale.length) return null
|
|
6637
|
+
return h('div', { className: 'n2s-fq-bar', role: 'status' },
|
|
6638
|
+
h('span', { 'aria-hidden': true }, '⚠️'),
|
|
6639
|
+
h('span', null, joinClauses(stale.slice(0, 3).map(x => fmt(t, 'fq.barItem', { table: x.table, when: fqWhen(t, x.latestAt), lag: fqLag(t, x.lagMinutes) })))),
|
|
6640
|
+
stale.length > 3 && h('span', { className: 'n2s-faint' }, fmt(t, 'fq.barMore', { n: stale.length - 3 })))
|
|
6641
|
+
}
|
|
6642
|
+
extend.resultPanels.push(FreshnessResultPanel)
|
|
6643
|
+
|
|
6644
|
+
// ---- schema panel
|
|
6645
|
+
|
|
6646
|
+
function FqDot({ status }) {
|
|
6647
|
+
return h('span', { className: `n2s-fq-dot ${fqStatusClass[status] ?? 'off'}`, 'aria-hidden': true })
|
|
6648
|
+
}
|
|
6649
|
+
|
|
6650
|
+
function FreshnessSchemaPanel({ t, ds }) {
|
|
6651
|
+
const rev = useFqRev()
|
|
6652
|
+
const [state, setState] = useState({ loading: true })
|
|
6653
|
+
const [open, setOpen] = useState(null)
|
|
6654
|
+
const [busy, setBusy] = useState(false)
|
|
6655
|
+
useEffect(() => {
|
|
6656
|
+
if (!ds?.id) return
|
|
6657
|
+
let alive = true
|
|
6658
|
+
setState(s => ({ ...s, loading: true }))
|
|
6659
|
+
fqCache.get(ds.id).then(v => alive && setState({ value: v }), e => alive && setState({ error: e }))
|
|
6660
|
+
return () => { alive = false }
|
|
6661
|
+
}, [ds?.id, rev])
|
|
6662
|
+
const v = state.value
|
|
6663
|
+
if (!ds || state.error || !v || !v.tables?.length) return null
|
|
6664
|
+
const stale = v.tables.filter(x => x.stale).length
|
|
6665
|
+
const warn = v.tables.filter(x => x.status === 'warn').length
|
|
6666
|
+
const expanded = open ?? stale > 0
|
|
6667
|
+
const refresh = async () => {
|
|
6668
|
+
setBusy(true)
|
|
6669
|
+
try { await fqCache.refresh(ds.id) } catch (e) { notifyError(e) }
|
|
6670
|
+
setBusy(false)
|
|
6671
|
+
}
|
|
6672
|
+
return h('div', { className: 'n2s-fq-panel' },
|
|
6673
|
+
h('div', { className: 'n2s-row' },
|
|
6674
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': expanded, onClick: () => setOpen(!expanded) }, Icon.chev(10), t('fq.title')),
|
|
6675
|
+
h('span', { className: `n2s-badge ${stale ? 'err' : warn ? 'warn' : 'ok'}` }, stale ? fmt(t, 'fq.staleCount', { n: stale }) : warn ? fmt(t, 'fq.warnCount', { n: warn }) : t('fq.allFresh')),
|
|
6676
|
+
h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.checkedAt', { time: fqTime(v.checkedAt) })),
|
|
6677
|
+
h('span', { className: 'n2s-grow' }),
|
|
6678
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: busy, onClick: refresh }, busy ? h('span', { className: 'n2s-spin' }) : Icon.refresh(13), t('fq.refresh'))),
|
|
6679
|
+
expanded && h('div', { className: 'n2s-fq-list' }, v.tables.map(x => h('div', { key: x.table, className: 'n2s-fq-item', title: x.error ?? `${x.column} · ${x.latestLocal ?? '—'}` },
|
|
6680
|
+
h(FqDot, { status: x.status }),
|
|
6681
|
+
h('span', { className: 'n2s-ellipsis n2s-mono' }, x.table),
|
|
6682
|
+
h('span', { className: 'n2s-grow' }),
|
|
6683
|
+
h('span', { className: 'n2s-faint n2s-small' }, x.error ? t('fq.st.error') : x.latestAt === null ? t('fq.st.empty') : fqLag(t, x.lagMinutes))))))
|
|
6684
|
+
}
|
|
6685
|
+
extend.schemaPanels.push(FreshnessSchemaPanel)
|
|
6686
|
+
|
|
6687
|
+
// ---- conversation tool view
|
|
6688
|
+
|
|
6689
|
+
function makeFreshnessToolView(ctx) {
|
|
6690
|
+
return function Nl2SqlFreshnessView(props) {
|
|
6691
|
+
const t = useMemo(() => ctx.locale.bind(NS), [])
|
|
6692
|
+
setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
|
|
6693
|
+
const block = props.block
|
|
6694
|
+
const settled = props.phase === 'result'
|
|
6695
|
+
const f = settled ? block.meta?.freshness : undefined
|
|
6696
|
+
if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('fq.running'))))
|
|
6697
|
+
if (block.isError || !f) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-notice err' }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n') || t('fq.failed')))
|
|
6698
|
+
return h('div', { className: 'n2s-toolcard' },
|
|
6699
|
+
h('div', { className: 'n2s-row', style: { marginBottom: 6 } }, h('span', { style: { fontWeight: 500 } }, t('fq.title')), f.datasource && h('span', { className: 'n2s-badge' }, f.datasource)),
|
|
6700
|
+
(f.warnings ?? []).map((w, i) => h('div', { key: i, className: 'n2s-fq-bar' }, w)),
|
|
6701
|
+
h('div', { className: 'n2s-fq-list' }, (f.tables ?? []).map(x => h('div', { key: x.table, className: 'n2s-fq-item' },
|
|
6702
|
+
h(FqDot, { status: x.status ?? 'unknown' }),
|
|
6703
|
+
h('span', { className: 'n2s-mono n2s-ellipsis' }, x.table),
|
|
6704
|
+
h('span', { className: 'n2s-grow' }),
|
|
6705
|
+
h('span', { className: 'n2s-faint n2s-small' }, x.note && !x.latestAt ? t('fq.noTime') : x.latestAt ? `${x.latestAt} · ${fqLag(t, x.lagMinutes)}` : t('fq.st.empty'))))))
|
|
6706
|
+
}
|
|
6707
|
+
}
|
|
6708
|
+
if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_freshness', make: makeFreshnessToolView })
|
|
6709
|
+
|
|
6710
|
+
// ---- admin section
|
|
6711
|
+
|
|
6712
|
+
function FqScore({ score }) {
|
|
6713
|
+
const cls = score === null || score === undefined ? 'off' : score >= 90 ? 'ok' : score >= 70 ? 'warn' : 'err'
|
|
6714
|
+
return h('span', { className: `n2s-fq-score ${cls}` }, score === null || score === undefined ? '—' : score)
|
|
6715
|
+
}
|
|
6716
|
+
|
|
6717
|
+
function HealthSettings({ t, onSaved }) {
|
|
6718
|
+
const st = useAsync(() => call('healthSettings'), [])
|
|
6719
|
+
const [form, setForm] = useState(null)
|
|
6720
|
+
const [busy, runBusy] = useBusy()
|
|
6721
|
+
useEffect(() => { if (st.value) setForm(structuredClone(st.value)) }, [st.value])
|
|
6722
|
+
if (!form) return h('div', { className: 'n2s-skel', style: { height: 120 } })
|
|
6723
|
+
const setF = (k, v) => setForm(f => ({ ...f, freshness: { ...f.freshness, [k]: v } }))
|
|
6724
|
+
const setQ = (k, v) => setForm(f => ({ ...f, quality: { ...f.quality, [k]: v } }))
|
|
6725
|
+
const save = () => runBusy(async () => {
|
|
6726
|
+
try {
|
|
6727
|
+
const { overrides: _o, ...freshness } = form.freshness
|
|
6728
|
+
const out = await call('saveHealthSettings', { freshness: { ...freshness, intervalMin: Number(freshness.intervalMin), staleHours: Number(freshness.staleHours), channelId: freshness.channelId || null }, quality: { ...form.quality, dropPoints: Number(form.quality.dropPoints), channelId: form.quality.channelId || null } })
|
|
6729
|
+
setForm(structuredClone(out))
|
|
6730
|
+
notify(t('fq.saved'), 'ok')
|
|
6731
|
+
onSaved?.()
|
|
6732
|
+
} catch (e) { notifyError(e) }
|
|
6733
|
+
})
|
|
6734
|
+
return h('div', { className: 'n2s-card' },
|
|
6735
|
+
h('div', { className: 'n2s-form' },
|
|
6736
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.freshness.enabled), onChange: e => setF('enabled', e.target.checked) }), t('fq.set.fEnabled')),
|
|
6737
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.quality.enabled), onChange: e => setQ('enabled', e.target.checked) }), t('fq.set.qEnabled')),
|
|
6738
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.interval')), h('input', { className: 'n2s-input', type: 'number', min: 5, max: 1440, value: form.freshness.intervalMin, onChange: e => setF('intervalMin', e.target.value) })),
|
|
6739
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.time')), h('input', { className: 'n2s-input', type: 'time', value: form.quality.time, onChange: e => setQ('time', e.target.value) })),
|
|
6740
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.staleHours')), h('input', { className: 'n2s-input', type: 'number', min: 0.25, step: 0.25, value: form.freshness.staleHours, onChange: e => setF('staleHours', e.target.value) })),
|
|
6741
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.drop')), h('input', { className: 'n2s-input', type: 'number', min: 1, max: 100, value: form.quality.dropPoints, onChange: e => setQ('dropPoints', e.target.value) })),
|
|
6742
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-fq-fch', className: 'n2s-flabel' }, t('fq.set.fChannel')), h(ChannelPicker, { t, id: 'n2s-fq-fch', value: form.freshness.channelId ?? '', noneLabel: t('fq.set.noPush'), onChange: v => setF('channelId', v) })),
|
|
6743
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-fq-qch', className: 'n2s-flabel' }, t('fq.set.qChannel')), h(ChannelPicker, { t, id: 'n2s-fq-qch', value: form.quality.channelId ?? '', noneLabel: t('fq.set.noPush'), onChange: v => setQ('channelId', v) }))),
|
|
6744
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 10 } }, t('fq.set.hint')),
|
|
6745
|
+
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
6746
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
6747
|
+
}
|
|
6748
|
+
|
|
6749
|
+
function HealthFreshnessTable({ t, dsId }) {
|
|
6750
|
+
const rev = useFqRev()
|
|
6751
|
+
const fr = useAsync(() => (dsId ? fqCache.get(dsId) : null), [dsId, rev])
|
|
6752
|
+
const settings = useAsync(() => call('healthSettings'), [dsId, rev])
|
|
6753
|
+
const events = useAsync(() => (dsId ? call('freshnessEvents', { datasourceId: dsId, limit: 10 }) : []), [dsId, rev])
|
|
6754
|
+
const [busy, setBusy] = useState(false)
|
|
6755
|
+
const refresh = async () => {
|
|
6756
|
+
setBusy(true)
|
|
6757
|
+
try { await fqCache.refresh(dsId) } catch (e) { notifyError(e) }
|
|
6758
|
+
setBusy(false)
|
|
6759
|
+
}
|
|
6760
|
+
const setOverride = async (table, value) => {
|
|
6761
|
+
const cur = structuredClone(settings.value?.freshness?.overrides ?? {})
|
|
6762
|
+
cur[dsId] ??= {}
|
|
6763
|
+
if (value === '' || value === null) delete cur[dsId][table]
|
|
6764
|
+
else cur[dsId][table] = Number(value)
|
|
6765
|
+
try { await call('saveHealthSettings', { freshness: { overrides: cur } }); await fqCache.refresh(dsId) } catch (e) { notifyError(e) }
|
|
6766
|
+
}
|
|
6767
|
+
const v = fr.value
|
|
6768
|
+
const overrides = settings.value?.freshness?.overrides?.[dsId] ?? {}
|
|
6769
|
+
return h('div', null,
|
|
6770
|
+
h('div', { className: 'n2s-row', style: { marginBottom: 8 } },
|
|
6771
|
+
h('div', { className: 'n2s-setsub', style: { margin: 0 } }, t('fq.title')),
|
|
6772
|
+
v && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.checkedAt', { time: fqTime(v.checkedAt) })),
|
|
6773
|
+
h('span', { className: 'n2s-grow' }),
|
|
6774
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: busy || !dsId, onClick: refresh }, busy ? h('span', { className: 'n2s-spin' }) : Icon.refresh(13), t('fq.refresh'))),
|
|
6775
|
+
fr.error && h('div', { className: 'n2s-notice err' }, fr.error.message),
|
|
6776
|
+
v && !v.analyzed && h('div', { className: 'n2s-notice' }, t('fq.notAnalyzed')),
|
|
6777
|
+
v && v.analyzed && !v.tables.length && h('div', { className: 'n2s-notice' }, t('fq.noTables')),
|
|
6778
|
+
v && v.tables.length > 0 && h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
|
|
6779
|
+
h('thead', null, h('tr', null, [t('fq.col.table'), t('fq.col.column'), t('fq.col.latest'), t('fq.col.lag'), t('fq.col.threshold')].map((x, i) => h('th', { key: i, className: i === 3 ? 'num' : undefined }, x)))),
|
|
6780
|
+
h('tbody', null, v.tables.map(x => h('tr', { key: x.table },
|
|
6781
|
+
h('td', null, h('span', { className: 'n2s-row', style: { flexWrap: 'nowrap' } }, h(FqDot, { status: x.status }), h('span', { className: 'n2s-mono' }, x.table))),
|
|
6782
|
+
h('td', { className: 'n2s-mono n2s-muted' }, x.column),
|
|
6783
|
+
h('td', { title: x.error ?? '' }, x.error ? h('span', { className: 'n2s-badge err' }, t('fq.st.error')) : x.latestAt === null ? t('fq.st.empty') : fqWhen(t, x.latestAt)),
|
|
6784
|
+
h('td', { className: 'num' }, fqLag(t, x.lagMinutes)),
|
|
6785
|
+
h('td', null, h('input', { className: 'n2s-input', type: 'number', min: 0, step: 0.5, style: { width: 90, height: 28 }, 'aria-label': fmt(t, 'fq.thresholdFor', { table: x.table }), placeholder: String(settings.value?.freshness?.staleHours ?? 24), defaultValue: overrides[x.table] ?? '', onBlur: e => { const val = e.target.value; if (String(overrides[x.table] ?? '') !== val) setOverride(x.table, val) } }))))))),
|
|
6786
|
+
(events.value ?? []).length > 0 && h('div', { style: { marginTop: 10 } },
|
|
6787
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginBottom: 4 } }, t('fq.events')),
|
|
6788
|
+
events.value.map(e => h('div', { key: e.id, className: 'n2s-small n2s-muted' }, `${fqTime(e.at)} · ${e.kind === 'stale' ? '⚠️' : '✅'} ${e.table} ${e.kind === 'stale' ? fmt(t, 'fq.evStale', { lag: fqLag(t, e.lagMinutes) }) : t('fq.evRecovered')}${e.pushed ? ` · ${t('fq.pushed')}` : ''}`))))
|
|
6789
|
+
}
|
|
6790
|
+
|
|
6791
|
+
function HealthQuality({ t, dsId }) {
|
|
6792
|
+
const res = useAsync(() => (dsId ? call('qualityResults', { datasourceId: dsId }) : null), [dsId])
|
|
6793
|
+
const [busy, setBusy] = useState(false)
|
|
6794
|
+
const [open, setOpen] = useState({})
|
|
6795
|
+
const run = async () => {
|
|
6796
|
+
setBusy(true)
|
|
6797
|
+
try { const r = await call('qualityScan', { datasourceId: dsId }); notify(fmt(t, 'fq.q.done', { score: r.score ?? '—' }), 'ok'); res.reload() } catch (e) { notifyError(e) }
|
|
6798
|
+
setBusy(false)
|
|
6799
|
+
}
|
|
6800
|
+
const latest = res.value?.latest
|
|
6801
|
+
const lang = uiLang === 'zh' ? 'zh' : 'en'
|
|
6802
|
+
return h('div', null,
|
|
6803
|
+
h('div', { className: 'n2s-row', style: { margin: '22px 0 8px' } },
|
|
6804
|
+
h('div', { className: 'n2s-setsub', style: { margin: 0 } }, t('fq.q.title')),
|
|
6805
|
+
latest && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.q.lastRun', { time: fqTime(latest.at) })),
|
|
6806
|
+
h('span', { className: 'n2s-grow' }),
|
|
6807
|
+
h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy || !dsId, onClick: run }, busy ? h('span', { className: 'n2s-spin' }) : Icon.spark(13), busy ? t('fq.q.running') : t('fq.q.run'))),
|
|
6808
|
+
res.error && h('div', { className: 'n2s-notice err' }, res.error.message),
|
|
6809
|
+
!res.loading && !latest && h('div', { className: 'n2s-notice' }, t('fq.q.empty')),
|
|
6810
|
+
latest && h('div', { className: 'n2s-fq-summary' },
|
|
6811
|
+
h(FqScore, { score: latest.score }),
|
|
6812
|
+
h('div', { className: 'n2s-grow' },
|
|
6813
|
+
h('div', null, latest.summary?.[lang] ?? ''),
|
|
6814
|
+
res.value.history.length > 1 && h('div', { className: 'n2s-faint n2s-small' }, colon(t('fq.q.history')), res.value.history.slice(0, 10).reverse().map(x => x.score ?? '—').join(' → ')))),
|
|
6815
|
+
latest && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
|
|
6816
|
+
h('thead', null, h('tr', null, [t('fq.col.table'), t('fq.q.score'), t('fq.q.rows'), t('fq.q.issues')].map((x, i) => h('th', { key: i, className: i === 1 || i === 2 ? 'num' : undefined }, x)))),
|
|
6817
|
+
h('tbody', null, latest.tables.map(r => {
|
|
6818
|
+
const issues = (r.issues ?? []).filter(i => i.deduct > 0 || r.score === null)
|
|
6819
|
+
const shown = open[r.table] ? issues : issues.slice(0, 2)
|
|
6820
|
+
return h('tr', { key: r.table },
|
|
6821
|
+
h('td', { className: 'n2s-mono' }, r.table),
|
|
6822
|
+
h('td', { className: 'num' }, h(FqScore, { score: r.score })),
|
|
6823
|
+
h('td', { className: 'num' }, r.rowCount ?? '—'),
|
|
6824
|
+
h('td', null,
|
|
6825
|
+
issues.length ? shown.map((i, k) => h('div', { key: k, className: 'n2s-small' }, i[lang])) : h('span', { className: 'n2s-faint n2s-small' }, t('fq.q.noIssues')),
|
|
6826
|
+
issues.length > 2 && h('button', { type: 'button', className: 'n2s-link n2s-small', onClick: () => setOpen(o => ({ ...o, [r.table]: !o[r.table] })) }, open[r.table] ? t('fq.less') : fmt(t, 'fq.more', { n: issues.length - 2 }))))
|
|
6827
|
+
})))))
|
|
6828
|
+
}
|
|
6829
|
+
|
|
6830
|
+
function HealthSection({ t, ds, datasources }) {
|
|
6831
|
+
const [dsId, setDsId] = useState(ds?.id ?? datasources?.[0]?.id ?? '')
|
|
6832
|
+
useEffect(() => { if (!dsId && (ds?.id || datasources?.[0]?.id)) setDsId(ds?.id ?? datasources[0].id) }, [ds?.id, datasources?.length])
|
|
6833
|
+
const status = useAsync(() => call('healthStatus'), [])
|
|
6834
|
+
return h('div', null,
|
|
6835
|
+
h(SectionHead, { title: t('fq.admin.title'), desc: t('fq.admin.desc') },
|
|
6836
|
+
(datasources ?? []).length > 1 && h('select', { className: 'n2s-select inline', 'aria-label': t('ds.select'), value: dsId, onChange: e => setDsId(e.target.value) }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
|
|
6837
|
+
status.value && h('div', { className: 'n2s-faint n2s-small', style: { marginBottom: 10 } },
|
|
6838
|
+
status.value.freshness.enabled ? fmt(t, 'fq.admin.nextF', { time: fqTime(status.value.freshness.nextAt) }) : t('fq.admin.fOff'),
|
|
6839
|
+
' · ',
|
|
6840
|
+
status.value.quality.enabled ? fmt(t, 'fq.admin.nextQ', { time: status.value.quality.time }) : t('fq.admin.qOff')),
|
|
6841
|
+
!dsId ? h('div', { className: 'n2s-empty' }, h('h3', null, t('fq.admin.noDs'))) : h(React.Fragment, null,
|
|
6842
|
+
h(HealthFreshnessTable, { key: `f-${dsId}`, t, dsId }),
|
|
6843
|
+
h(HealthQuality, { key: `q-${dsId}`, t, dsId })),
|
|
6844
|
+
h('div', { className: 'n2s-setsub' }, t('fq.set.title')),
|
|
6845
|
+
h(HealthSettings, { t, onSaved: () => status.reload() }))
|
|
6846
|
+
}
|
|
6847
|
+
extend.adminSections.push({ id: 'health', label: (t) => t('fq.admin.nav'), icon: () => svg(15, [h('path', { key: 1, d: 'M1.5 8.5h3l1.5-4 3 7 1.5-3h4' })]), Component: HealthSection })
|
|
6848
|
+
// ------------------------------------------------------------ reconciliation (对账)
|
|
6849
|
+
// Admin section 'reconcile': saved jobs (two queries, key mapping, compare columns,
|
|
6850
|
+
// schedule, channel), dry run with summary + difference tables (ResultView), run history.
|
|
6851
|
+
// Also the conversation view of the nl2sql_reconcile agent tool.
|
|
6852
|
+
|
|
6853
|
+
const rcPct = (r) => (r === null || r === undefined ? '—' : `${(r * 100).toFixed(r === 1 || r === 0 ? 0 : 2)}%`)
|
|
6854
|
+
const rcTime = (at) => new Date(at).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
6855
|
+
|
|
6856
|
+
function rcBlank(ds) {
|
|
6857
|
+
return {
|
|
6858
|
+
name: '', left: { datasourceId: ds?.id ?? '', sql: '' }, right: { datasourceId: ds?.id ?? '', sql: '' },
|
|
6859
|
+
keys: [{ left: '', right: '' }], compare: [], options: { trim: true, ignoreCase: false },
|
|
6860
|
+
schedule: { time: '09:00', enabled: false }, channelId: '', notifyOn: 'diff',
|
|
6861
|
+
}
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6864
|
+
/** Definition as sent to the server: empty rows dropped, numbers parsed. */
|
|
6865
|
+
function rcPayload(form) {
|
|
6866
|
+
return {
|
|
6867
|
+
...(form.id ? { id: form.id } : {}),
|
|
6868
|
+
name: form.name, left: form.left, right: form.right,
|
|
6869
|
+
keys: form.keys.filter(k => k.left.trim()).map(k => ({ left: k.left.trim(), right: (k.right || k.left).trim() })),
|
|
6870
|
+
compare: form.compare.filter(c => c.left.trim()).map(c => ({ left: c.left.trim(), right: (c.right || c.left).trim(), tolerance: c.tolerance === '' || c.tolerance === undefined ? 0 : Number(c.tolerance), relative: Boolean(c.relative) })),
|
|
6871
|
+
options: form.options, schedule: form.schedule, channelId: form.channelId || null, notifyOn: form.notifyOn,
|
|
6872
|
+
}
|
|
6873
|
+
}
|
|
6874
|
+
|
|
6875
|
+
/** Summary numbers + warnings + the four detail tables (ResultView each). */
|
|
6876
|
+
function ReconcileOutcome({ t, out }) {
|
|
6877
|
+
const parts = ['summary', 'mismatches', 'onlyLeft', 'onlyRight']
|
|
6878
|
+
const results = out.results ?? []
|
|
6879
|
+
const firstDiff = results.findIndex((r, i) => i > 0 && r.rowCount > 0)
|
|
6880
|
+
const [tab, setTab] = useState(firstDiff > 0 ? firstDiff : 0)
|
|
6881
|
+
const s = out.summary
|
|
6882
|
+
if (!s) return null
|
|
6883
|
+
const lname = out.left?.label ?? out.left?.datasourceName
|
|
6884
|
+
const rname = out.right?.label ?? out.right?.datasourceName
|
|
6885
|
+
const stat = (label, value, bad) => h('div', { className: `n2s-rc-stat${bad ? ' bad' : ''}` }, h('div', { className: 'n2s-rc-stat-v' }, value), h('div', { className: 'n2s-rc-stat-l' }, label))
|
|
6886
|
+
const label = (i) => (i === 0 ? t('rc.tab.summary') : i === 1 ? t('rc.tab.mismatches') : i === 2 ? fmt(t, 'rc.tab.only', { side: lname }) : fmt(t, 'rc.tab.only', { side: rname }))
|
|
6887
|
+
const cur = results[tab]
|
|
6888
|
+
return h('div', { className: 'n2s-rc-out' },
|
|
6889
|
+
h('div', { className: `n2s-notice ${s.hasDiff ? 'warn' : 'ok'}`, role: 'status' }, s.hasDiff ? fmt(t, 'rc.hasDiff', { rate: rcPct(s.matchRate) }) : fmt(t, 'rc.noDiff', { rate: rcPct(s.matchRate) })),
|
|
6890
|
+
h('div', { className: 'n2s-rc-stats' },
|
|
6891
|
+
stat(t('rc.matchRate'), rcPct(s.matchRate), s.matchRate < 1),
|
|
6892
|
+
stat(fmt(t, 'rc.rowsOf', { side: lname }), `${formatNumber(s.leftRows)}${s.truncated?.left ? '+' : ''}`),
|
|
6893
|
+
stat(fmt(t, 'rc.rowsOf', { side: rname }), `${formatNumber(s.rightRows)}${s.truncated?.right ? '+' : ''}`),
|
|
6894
|
+
stat(t('rc.matched'), formatNumber(s.matched)),
|
|
6895
|
+
stat(fmt(t, 'rc.onlyIn', { side: lname }), formatNumber(s.onlyLeft), s.onlyLeft > 0),
|
|
6896
|
+
stat(fmt(t, 'rc.onlyIn', { side: rname }), formatNumber(s.onlyRight), s.onlyRight > 0),
|
|
6897
|
+
s.columns?.length > 0 && stat(t('rc.mismatched'), formatNumber(s.mismatchedRows), s.mismatchedRows > 0)),
|
|
6898
|
+
s.columns?.length > 0 && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
|
|
6899
|
+
h('thead', null, h('tr', null, [t('rc.col'), t('rc.mismatchRows'), fmt(t, 'rc.totalOf', { side: lname }), fmt(t, 'rc.totalOf', { side: rname }), t('rc.diff')].map((x, i) => h('th', { key: i }, x)))),
|
|
6900
|
+
h('tbody', null, s.columns.map((c, i) => h('tr', { key: i },
|
|
6901
|
+
h('td', null, c.label, c.tolerance ? h('span', { className: 'n2s-faint n2s-small' }, ` ±${c.relative ? `${+(c.tolerance * 100).toFixed(4)}%` : c.tolerance}`) : null),
|
|
6902
|
+
h('td', { className: c.mismatches ? 'n2s-rc-bad' : undefined }, formatNumber(c.mismatches)),
|
|
6903
|
+
h('td', null, c.leftTotal === undefined ? '—' : formatNumber(c.leftTotal)),
|
|
6904
|
+
h('td', null, c.rightTotal === undefined ? '—' : formatNumber(c.rightTotal)),
|
|
6905
|
+
h('td', { className: c.diff ? 'n2s-rc-bad' : undefined }, c.diff === undefined ? '—' : formatNumber(c.diff))))))),
|
|
6906
|
+
(out.warnings ?? []).map((w, i) => h('div', { key: `w${i}`, className: 'n2s-notice warn', style: { marginTop: 8 } }, w)),
|
|
6907
|
+
results.length > 0 && h('div', { className: 'n2s-seg', role: 'tablist', style: { marginTop: 12 } }, results.map((r, i) => h('button', { key: parts[i] ?? i, type: 'button', role: 'tab', 'aria-pressed': tab === i, 'aria-selected': tab === i, onClick: () => setTab(i) }, label(i), i > 0 ? ` (${formatNumber(r.rowCount)}${r.truncated ? '+' : ''})` : ''))),
|
|
6908
|
+
cur && h('div', { style: { marginTop: 8 } }, cur.rows ? h(ResultView, { key: cur.resultId, t, result: cur, compact: true, onPinned: { hidden: true } }) : h('div', { className: 'n2s-faint' }, t('result.expired'))))
|
|
6909
|
+
}
|
|
6910
|
+
|
|
6911
|
+
function RcSidePane({ t, title, side, datasources, onChange }) {
|
|
6912
|
+
return h('div', { className: 'n2s-rc-pane' },
|
|
6913
|
+
h('div', { className: 'n2s-rc-pane-title' }, title),
|
|
6914
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')),
|
|
6915
|
+
h('select', { className: 'n2s-select', value: side.datasourceId, onChange: e => onChange({ ...side, datasourceId: e.target.value }) },
|
|
6916
|
+
h('option', { value: '' }, '—'), datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
|
|
6917
|
+
h('label', { className: 'n2s-field' }, h('span', null, 'SQL'),
|
|
6918
|
+
h('textarea', { className: 'n2s-textarea n2s-mono', spellCheck: false, rows: 6, value: side.sql, onChange: e => onChange({ ...side, sql: e.target.value }), placeholder: 'SELECT order_no, amount FROM orders WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY' })))
|
|
6919
|
+
}
|
|
6920
|
+
|
|
6921
|
+
function ReconcileEditor({ t, initial, datasources, ds, onDone, onCancel }) {
|
|
6922
|
+
const [form, setForm] = useState(() => {
|
|
6923
|
+
const b = rcBlank(ds)
|
|
6924
|
+
if (!initial) return b
|
|
6925
|
+
return { ...b, ...initial, channelId: initial.channelId ?? '', keys: initial.keys?.length ? initial.keys : b.keys, compare: (initial.compare ?? []).map(c => ({ ...c, tolerance: c.tolerance ?? 0 })), options: { ...b.options, ...(initial.options ?? {}) }, schedule: { ...b.schedule, ...(initial.schedule ?? {}) } }
|
|
6926
|
+
})
|
|
6927
|
+
const [error, setError] = useState(null)
|
|
6928
|
+
const [dry, setDry] = useState(null)
|
|
6929
|
+
const [busy, runBusy] = useBusy()
|
|
6930
|
+
const patch = (p) => setForm(f => ({ ...f, ...p }))
|
|
6931
|
+
const setList = (key, i, p) => setForm(f => ({ ...f, [key]: f[key].map((x, j) => (j === i ? { ...x, ...p } : x)) }))
|
|
6932
|
+
const addRow = (key, row) => setForm(f => ({ ...f, [key]: [...f[key], row] }))
|
|
6933
|
+
const delRow = (key, i) => setForm(f => ({ ...f, [key]: f[key].filter((_, j) => j !== i) }))
|
|
6934
|
+
const save = () => runBusy(async () => {
|
|
6935
|
+
setError(null)
|
|
6936
|
+
try { await call('saveReconcile', rcPayload(form)); notify(t('rc.saved'), 'ok'); onDone() } catch (e) { setError(e.message) }
|
|
6937
|
+
})
|
|
6938
|
+
const dryRun = async () => {
|
|
6939
|
+
if (dry?.busy) return
|
|
6940
|
+
setDry({ busy: true })
|
|
6941
|
+
try { setDry({ out: await call('runReconcile', { definition: { ...rcPayload(form), name: form.name || t('rc.dryRun') } }) }) } catch (e) { setDry({ error: e.message }) }
|
|
6942
|
+
}
|
|
6943
|
+
const colInput = (value, onChange, ph, label) => h('input', { className: 'n2s-input', value, placeholder: ph, 'aria-label': label, onChange: e => onChange(e.target.value) })
|
|
6944
|
+
return h('div', { className: 'n2s-card n2s-rc-editor', style: { marginBottom: 12 } },
|
|
6945
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('rc.name')), h('input', { className: 'n2s-input', value: form.name, onChange: e => patch({ name: e.target.value }), placeholder: t('rc.namePh') })),
|
|
6946
|
+
h('div', { className: 'n2s-rc-panes' },
|
|
6947
|
+
h(RcSidePane, { t, title: t('rc.left'), side: form.left, datasources, onChange: (left) => patch({ left }) }),
|
|
6948
|
+
h(RcSidePane, { t, title: t('rc.right'), side: form.right, datasources, onChange: (right) => patch({ right }) })),
|
|
6949
|
+
h('div', { className: 'n2s-field full' }, h('span', null, t('rc.keys')), h('span', { className: 'n2s-faint n2s-small' }, t('rc.keysHint')),
|
|
6950
|
+
form.keys.map((k, i) => h('div', { key: i, className: 'n2s-rc-maprow' },
|
|
6951
|
+
colInput(k.left, v => setList('keys', i, { left: v }), t('rc.leftCol'), `${t('rc.keys')} ${i + 1} ${t('rc.left')}`),
|
|
6952
|
+
h('span', { className: 'n2s-muted', 'aria-hidden': true }, '⇄'),
|
|
6953
|
+
colInput(k.right, v => setList('keys', i, { right: v }), k.left || t('rc.rightCol'), `${t('rc.keys')} ${i + 1} ${t('rc.right')}`),
|
|
6954
|
+
h('button', { type: 'button', className: 'n2s-iconbtn', disabled: form.keys.length <= 1, title: t('ds.delete'), 'aria-label': t('ds.delete'), onClick: () => delRow('keys', i) }, Icon.trash(14)))),
|
|
6955
|
+
h('div', null, h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => addRow('keys', { left: '', right: '' }) }, '+ ', t('rc.addKey')))),
|
|
6956
|
+
h('div', { className: 'n2s-field full' }, h('span', null, t('rc.compare')), h('span', { className: 'n2s-faint n2s-small' }, t('rc.compareHint')),
|
|
6957
|
+
form.compare.map((c, i) => h('div', { key: i, className: 'n2s-rc-maprow' },
|
|
6958
|
+
colInput(c.left, v => setList('compare', i, { left: v }), t('rc.leftCol'), `${t('rc.compare')} ${i + 1} ${t('rc.left')}`),
|
|
6959
|
+
h('span', { className: 'n2s-muted', 'aria-hidden': true }, '⇄'),
|
|
6960
|
+
colInput(c.right, v => setList('compare', i, { right: v }), c.left || t('rc.rightCol'), `${t('rc.compare')} ${i + 1} ${t('rc.right')}`),
|
|
6961
|
+
h('input', { className: 'n2s-input n2s-rc-tol', type: 'number', min: 0, step: 'any', value: c.tolerance, 'aria-label': t('rc.tolerance'), title: t('rc.tolerance'), placeholder: t('rc.tolerance'), onChange: e => setList('compare', i, { tolerance: e.target.value }) }),
|
|
6962
|
+
h('select', { className: 'n2s-select inline', value: c.relative ? 'rel' : 'abs', 'aria-label': t('rc.tolMode'), onChange: e => setList('compare', i, { relative: e.target.value === 'rel' }) },
|
|
6963
|
+
h('option', { value: 'abs' }, t('rc.tolAbs')), h('option', { value: 'rel' }, t('rc.tolRel'))),
|
|
6964
|
+
h('button', { type: 'button', className: 'n2s-iconbtn', title: t('ds.delete'), 'aria-label': t('ds.delete'), onClick: () => delRow('compare', i) }, Icon.trash(14)))),
|
|
6965
|
+
h('div', null, h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => addRow('compare', { left: '', right: '', tolerance: 0, relative: false }) }, '+ ', t('rc.addCompare')))),
|
|
6966
|
+
h('div', { className: 'n2s-row', style: { gap: 16, marginTop: 4 } },
|
|
6967
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.options.trim, onChange: e => patch({ options: { ...form.options, trim: e.target.checked } }) }), t('rc.trim')),
|
|
6968
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.options.ignoreCase, onChange: e => patch({ options: { ...form.options, ignoreCase: e.target.checked } }) }), t('rc.ignoreCase'))),
|
|
6969
|
+
h('div', { className: 'n2s-form', style: { marginTop: 10 } },
|
|
6970
|
+
h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.schedule.enabled, onChange: e => patch({ schedule: { ...form.schedule, enabled: e.target.checked } }) }), t('rc.daily')),
|
|
6971
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.schedule.time, disabled: !form.schedule.enabled, onChange: e => patch({ schedule: { ...form.schedule, time: e.target.value } }) })),
|
|
6972
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-rc-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-rc-channel', value: form.channelId ?? '', noneLabel: t('rc.noPush'), onChange: (v) => patch({ channelId: v }) })),
|
|
6973
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('rc.notifyOn')), h('select', { className: 'n2s-select', value: form.notifyOn, disabled: !form.channelId, onChange: e => patch({ notifyOn: e.target.value }) },
|
|
6974
|
+
h('option', { value: 'diff' }, t('rc.notify.diff')), h('option', { value: 'always' }, t('rc.notify.always'))))),
|
|
6975
|
+
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
6976
|
+
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
6977
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
6978
|
+
h('button', { type: 'button', className: 'n2s-btn', disabled: dry?.busy, onClick: dryRun }, dry?.busy ? h('span', { className: 'n2s-spin' }) : null, t('rc.dryRun')),
|
|
6979
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))),
|
|
6980
|
+
dry?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, dry.error),
|
|
6981
|
+
dry?.out && h('div', { className: 'n2s-rc-dry' }, h('div', { className: 'n2s-rc-pane-title' }, t('rc.dryResult')), h(ReconcileOutcome, { t, out: dry.out })))
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
function RcStatus({ t, run }) {
|
|
6985
|
+
if (!run) return h('span', { className: 'n2s-statusdot' }, t('rc.never'))
|
|
6986
|
+
if (run.status === 'error') return h('span', { className: 'n2s-statusdot err', title: run.error }, t('rc.status.error'))
|
|
6987
|
+
return h('span', { className: `n2s-statusdot ${run.status === 'diff' ? 'warn' : 'ok'}` }, run.status === 'diff' ? t('rc.status.diff') : t('rc.status.ok'))
|
|
6988
|
+
}
|
|
6989
|
+
|
|
6990
|
+
function rcDiffText(t, run) {
|
|
6991
|
+
if (!run || run.status === 'error') return ''
|
|
6992
|
+
const s = run.summary ?? run
|
|
6993
|
+
return joinList([
|
|
6994
|
+
`${t('rc.matchRate')} ${rcPct(s.matchRate)}`,
|
|
6995
|
+
s.onlyLeft ? fmt(t, 'rc.short.onlyLeft', { n: s.onlyLeft }) : null,
|
|
6996
|
+
s.onlyRight ? fmt(t, 'rc.short.onlyRight', { n: s.onlyRight }) : null,
|
|
6997
|
+
s.mismatchedRows ? fmt(t, 'rc.short.mismatch', { n: s.mismatchedRows }) : null,
|
|
6998
|
+
s.complete === false ? t('rc.incomplete') : null,
|
|
6999
|
+
], ' · ')
|
|
7000
|
+
}
|
|
7001
|
+
|
|
7002
|
+
function ReconcileSection({ t, ds, datasources }) {
|
|
7003
|
+
const list = useAsync(() => call('listReconciles'), [])
|
|
7004
|
+
const [editing, setEditing] = useState(null)
|
|
7005
|
+
const [historyOf, setHistoryOf] = useState(null)
|
|
7006
|
+
const [shown, setShown] = useState(null)
|
|
7007
|
+
const runs = useAsync(() => call('reconcileRuns', { id: historyOf ?? undefined, limit: 50 }), [historyOf, list.value])
|
|
7008
|
+
const [running, setRunning] = useState(null)
|
|
7009
|
+
const jobs = list.value ?? []
|
|
7010
|
+
const runNow = async (job) => {
|
|
7011
|
+
if (running) return
|
|
7012
|
+
setRunning(job.id)
|
|
7013
|
+
try {
|
|
7014
|
+
const out = await call('runReconcile', { id: job.id })
|
|
7015
|
+
if (out.ok === false) notify(out.error, 'err')
|
|
7016
|
+
else { notify(`${out.status === 'diff' ? '⚠️ ' : '✓ '}${rcDiffText(t, out)}${out.pushed ? ` · ${t('rc.pushed')}` : ''}`, out.status === 'diff' ? 'info' : 'ok'); setShown(out) }
|
|
7017
|
+
} catch (e) { notifyError(e) } finally { setRunning(null); list.reload() }
|
|
7018
|
+
}
|
|
7019
|
+
const showRun = async (r) => {
|
|
7020
|
+
if (!r.resultIds?.length) return setShown({ ...r, results: [] })
|
|
7021
|
+
const results = await Promise.all(r.resultIds.map(id => call('getResult', { resultId: id }).catch(() => null)))
|
|
7022
|
+
setShown({ ...r, results: results.map((x, i) => x ?? { resultId: r.resultIds[i], title: '', rowCount: 0 }) })
|
|
7023
|
+
}
|
|
7024
|
+
return h('div', { className: 'n2s-rc' },
|
|
7025
|
+
h(SectionHead, { title: t('rc.title'), desc: t('rc.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => { setShown(null); setEditing({}) } }, '+ ', t('rc.add'))),
|
|
7026
|
+
editing && h(ReconcileEditor, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
|
|
7027
|
+
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
7028
|
+
!list.loading && !list.error && jobs.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('rc.empty')), h('div', null, t('rc.emptyHint')), h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, t('rc.add'))),
|
|
7029
|
+
jobs.length > 0 && h('div', { className: 'n2s-list2' }, jobs.map(j => h('div', { key: j.id, className: 'n2s-list2-row' },
|
|
7030
|
+
h('span', { className: 'n2s-tile', 'aria-hidden': true }, '⇄'),
|
|
7031
|
+
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
7032
|
+
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, j.name),
|
|
7033
|
+
j.schedule?.enabled ? h('span', { className: 'n2s-badge' }, fmt(t, 'rc.dailyAt', { time: j.schedule.time })) : h('span', { className: 'n2s-badge' }, t('rc.manual')),
|
|
7034
|
+
j.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), j.channelName)),
|
|
7035
|
+
h('div', { className: 'n2s-list2-sub' }, `${j.leftName} ⇄ ${j.rightName} · ${colon(t('rc.keys'))}${joinList(j.keys.map(k => (k.left === k.right ? k.left : `${k.left}/${k.right}`)))}`),
|
|
7036
|
+
j.lastRun && h('div', { className: 'n2s-list2-sub' }, `${rcTime(j.lastRun.at)} · `, j.lastRun.status === 'error' ? h('span', { style: { color: 'var(--n2s-err)' } }, j.lastRun.error) : rcDiffText(t, j.lastRun))),
|
|
7037
|
+
h('div', { className: 'n2s-list2-side' }, h(RcStatus, { t, run: j.lastRun })),
|
|
7038
|
+
h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
|
|
7039
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: Boolean(running), onClick: () => runNow(j) }, running === j.id ? h('span', { className: 'n2s-spin' }) : null, t('rc.runNow')),
|
|
7040
|
+
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
7041
|
+
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => { setShown(null); setEditing(j) } },
|
|
7042
|
+
{ label: t('rc.history'), icon: Icon.table(14), onClick: () => setHistoryOf(j.id) },
|
|
7043
|
+
{ label: j.schedule?.enabled ? t('rep.pause') : t('rep.resume'), icon: Icon.refresh(14), onClick: async () => { try { await call('saveReconcile', { id: j.id, schedule: { ...j.schedule, enabled: !j.schedule?.enabled } }) } catch (e) { notifyError(e) } list.reload() } },
|
|
7044
|
+
{ divider: true },
|
|
7045
|
+
{ label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'rc.deleteConfirm', { name: j.name }), danger: true })) { try { await call('deleteReconcile', { id: j.id }) } catch (e) { notifyError(e) } list.reload() } } },
|
|
7046
|
+
] }))))),
|
|
7047
|
+
shown && h('div', { className: 'n2s-section' },
|
|
7048
|
+
h('div', { className: 'n2s-row' }, h('h2', { className: 'n2s-grow' }, fmt(t, 'rc.runOf', { name: shown.title ?? shown.name ?? '' }), shown.at ? h('span', { className: 'n2s-faint n2s-small' }, ` · ${rcTime(shown.at)}`) : null),
|
|
7049
|
+
h('button', { type: 'button', className: 'n2s-iconbtn', title: t('rc.close'), 'aria-label': t('rc.close'), onClick: () => setShown(null) }, Icon.close(14))),
|
|
7050
|
+
shown.error ? h('div', { className: 'n2s-notice err' }, shown.error) : h(ReconcileOutcome, { key: shown.runId ?? shown.id ?? shown.at, t, out: shown })),
|
|
7051
|
+
(runs.value ?? []).length > 0 && h('div', { className: 'n2s-section' },
|
|
7052
|
+
h('div', { className: 'n2s-row' }, h('h2', { className: 'n2s-grow' }, historyOf ? fmt(t, 'rc.historyOf', { name: jobs.find(j => j.id === historyOf)?.name ?? '' }) : t('rc.history')),
|
|
7053
|
+
historyOf && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => setHistoryOf(null) }, t('rc.allRuns'))),
|
|
7054
|
+
h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-rc-runs' },
|
|
7055
|
+
h('thead', null, h('tr', null, [t('im.col.time'), t('rc.name'), t('im.col.status'), t('rc.detail'), t('rc.push')].map((x, i) => h('th', { key: i }, x)))),
|
|
7056
|
+
h('tbody', null, runs.value.map(r => h('tr', { key: r.id, className: 'n2s-rc-run', tabIndex: 0, onClick: () => showRun(r), onKeyDown: (e) => { if (e.key === 'Enter') showRun(r) } },
|
|
7057
|
+
h('td', { className: 'n2s-muted' }, rcTime(r.at)),
|
|
7058
|
+
h('td', null, r.name, r.source === 'schedule' ? h('span', { className: 'n2s-faint n2s-small' }, ` · ${t('rc.scheduled')}`) : null),
|
|
7059
|
+
h('td', null, h(RcStatus, { t, run: r })),
|
|
7060
|
+
h('td', { style: { whiteSpace: 'normal' } }, r.status === 'error' ? r.error : rcDiffText(t, r)),
|
|
7061
|
+
h('td', null, r.pushed ? h('span', { className: 'n2s-statusdot ok' }, t('im.sentOk')) : r.pushError ? h('span', { className: 'n2s-statusdot err', title: r.pushError }, t('im.lastFailed')) : h('span', { className: 'n2s-faint' }, '—')))))))))
|
|
7062
|
+
}
|
|
7063
|
+
|
|
7064
|
+
extend.adminSections.push({ id: 'reconcile', label: (t) => t('rc.title'), icon: () => svg(15, [h('path', { key: 1, d: 'M2.5 5h10M10 2.5 12.5 5 10 7.5M13.5 11h-10M6 8.5 3.5 11 6 13.5' })]), Component: ReconcileSection })
|
|
7065
|
+
|
|
7066
|
+
/** Conversation view of nl2sql_reconcile: summary + difference tables. */
|
|
7067
|
+
function makeReconcileToolView(ctx) {
|
|
7068
|
+
return function Nl2SqlReconcileView(props) {
|
|
7069
|
+
const t = useMemo(() => ctx.locale.bind(NS), [])
|
|
7070
|
+
setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
|
|
7071
|
+
const block = props.block
|
|
7072
|
+
const settled = props.phase === 'result'
|
|
7073
|
+
const meta = settled ? block.meta : undefined
|
|
7074
|
+
const args = parseArgs(settled ? block.call?.argsRaw : block?.argsRaw)
|
|
7075
|
+
const [results, setResults] = useState(null)
|
|
7076
|
+
useEffect(() => {
|
|
7077
|
+
if (!settled || block.isError || !meta?.results) return
|
|
7078
|
+
let alive = true
|
|
7079
|
+
Promise.all(meta.results.map(r => call('getResult', { resultId: r.resultId }).then(full => full ?? { ...r, rows: null }).catch(() => ({ ...r, rows: null }))))
|
|
7080
|
+
.then(list => alive && setResults(list))
|
|
7081
|
+
return () => { alive = false }
|
|
7082
|
+
}, [settled, meta])
|
|
7083
|
+
const title = args.title || t('rc.tool')
|
|
7084
|
+
if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('rc.toolRunning')), h('span', null, title)))
|
|
7085
|
+
if (block.isError) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-badge err' }, t('tool.failed')), h('span', null, title)), h('div', { className: 'n2s-notice err', style: { marginTop: 8 } }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n')))
|
|
7086
|
+
return h('div', { className: 'n2s-toolcard' },
|
|
7087
|
+
h('div', { className: 'n2s-row', style: { marginBottom: 8 } }, h('span', { style: { fontWeight: 500 } }, meta?.title ?? title), meta?.left && h('span', { className: 'n2s-badge' }, `${meta.left.datasourceName} ⇄ ${meta.right.datasourceName}`)),
|
|
7088
|
+
!meta?.summary ? null : !results ? h('span', { className: 'n2s-spin n2s-muted' }) : h(ReconcileOutcome, { t, out: { ...meta, results } }))
|
|
7089
|
+
}
|
|
7090
|
+
}
|
|
7091
|
+
if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_reconcile', make: makeReconcileToolView })
|
|
7092
|
+
// ------------------------------------------------------------ chat subscriptions (admin section)
|
|
7093
|
+
|
|
7094
|
+
/** Subscriptions and alerts created from IM chats, grouped by chat, with pause / resume / delete. */
|
|
7095
|
+
function ChatSubscriptionsSection({ t }) {
|
|
7096
|
+
const list = useAsync(() => call('listChatSubscriptions'), [])
|
|
7097
|
+
const [notice, setNotice] = useState(null)
|
|
7098
|
+
const groups = list.value ?? []
|
|
7099
|
+
const when = (ms) => (ms ? new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—')
|
|
7100
|
+
const act = async (item, action) => {
|
|
7101
|
+
setNotice(null)
|
|
7102
|
+
if (action === 'delete' && !(await confirmDialog({ title: fmt(t, 'sub.deleteConfirm', { name: item.title }), danger: true }))) return
|
|
7103
|
+
try { await call('setChatSubscription', { id: item.id, action }); setNotice({ ok: true, text: t(`sub.done.${action}`) }) } catch (e) { setNotice({ ok: false, text: e.message }) }
|
|
7104
|
+
list.reload()
|
|
7105
|
+
}
|
|
7106
|
+
const statusDot = (s) => h('span', { className: `n2s-statusdot ${s === 'active' ? 'ok' : s === 'suspended' ? 'err' : ''}` }, t(`sub.status.${s}`))
|
|
7107
|
+
return h('div', { className: 'n2s-sub' },
|
|
7108
|
+
h(SectionHead, { title: t('sub.title'), desc: t('sub.desc') },
|
|
7109
|
+
h('button', { type: 'button', className: 'n2s-btn', onClick: () => list.reload() }, Icon.refresh(14), ' ', t('sub.refresh'))),
|
|
7110
|
+
notice && h('div', { className: `n2s-notice ${notice.ok ? 'ok' : 'err'}`, role: 'status', style: { marginBottom: 10 } }, notice.text),
|
|
7111
|
+
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
7112
|
+
!list.loading && !list.error && groups.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('sub.empty')), h('div', null, t('sub.emptyHint'))),
|
|
7113
|
+
groups.map(g => h('div', { key: g.key, className: 'n2s-card n2s-sub-group' },
|
|
7114
|
+
h('div', { className: 'n2s-sub-head' },
|
|
7115
|
+
h('span', { className: 'n2s-sub-chat' }, g.chatName || g.chatId),
|
|
7116
|
+
h('span', { className: 'n2s-badge' }, Icon.send(11), ' ', g.channelName ?? g.channelId),
|
|
7117
|
+
h('span', { className: 'n2s-faint n2s-small' }, g.platform)),
|
|
7118
|
+
h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
|
|
7119
|
+
h('thead', null, h('tr', null, [t('sub.col.item'), t('sub.col.creator'), t('sub.col.schedule'), t('sub.col.last'), t('sub.col.status'), ''].map((x, i) => h('th', { key: i }, x)))),
|
|
7120
|
+
h('tbody', null, g.items.map(it => h('tr', { key: it.id },
|
|
7121
|
+
h('td', null,
|
|
7122
|
+
h('span', { className: 'n2s-badge n2s-sub-kind' }, t(`sub.kind.${it.kind}`)), ' ', it.title,
|
|
7123
|
+
h('div', { className: 'n2s-faint n2s-small' }, joinList([it.datasourceName, it.conditionText, it.metric]))),
|
|
7124
|
+
h('td', null, it.createdBy?.name ?? '—'),
|
|
7125
|
+
h('td', null, it.schedule, it.nextAt ? h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('sub.next'))}${when(it.nextAt)}`) : null),
|
|
7126
|
+
h('td', null, when(it.lastDeliveredAt)),
|
|
7127
|
+
h('td', null, statusDot(it.status), it.lastError && h('div', { className: 'n2s-small n2s-sub-err' }, it.lastError)),
|
|
7128
|
+
h('td', null, h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
|
|
7129
|
+
it.status === 'active'
|
|
7130
|
+
? h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(it, 'pause') }, t('sub.pause'))
|
|
7131
|
+
: h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(it, 'resume') }, t('sub.resume')),
|
|
7132
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-label': t('sub.delete'), title: t('sub.delete'), onClick: () => act(it, 'delete') }, Icon.trash(13))))))))))))
|
|
7133
|
+
}
|
|
7134
|
+
|
|
7135
|
+
extend.adminSections.push({ id: 'subscriptions', label: (t) => t('sub.nav'), icon: () => svg(15, [h('path', { key: 1, d: 'M4 6.5a4 4 0 0 1 8 0c0 3 1.5 4.5 1.5 4.5h-11S4 9.5 4 6.5M6.5 13.5a1.6 1.6 0 0 0 3 0' })]), Component: ChatSubscriptionsSection })
|
|
7136
|
+
// ------------------------------------------------------------ proactive (daily digest, goals, forecast)
|
|
7137
|
+
// Admin sections 'digests' (每日简报) and 'goals' (目标); goal chips in the dashboard bar;
|
|
7138
|
+
// "预测未来 N 天" in the result menu + forecast panel under the result; nl2sql_forecast tool view.
|
|
7139
|
+
|
|
7140
|
+
// ---- forecast
|
|
7141
|
+
|
|
7142
|
+
/** resultId → horizon requested from the result menu (shared by the menu item and the panel). */
|
|
7143
|
+
const proForecastStore = createStore({})
|
|
7144
|
+
|
|
7145
|
+
function proForecastOption(f, el, t) {
|
|
7146
|
+
const label = cssVar(el, '--dsw-alias-label-secondary', '#888')
|
|
7147
|
+
const text = cssVar(el, '--dsw-alias-label-primary', '#222')
|
|
7148
|
+
const border = cssVar(el, '--dsw-alias-border-l1', 'rgba(128,128,128,.25)')
|
|
7149
|
+
const surface = cssVar(el, '--dsw-alias-bg-layer-1', '#fff')
|
|
7150
|
+
const color = '#3b82f6'
|
|
7151
|
+
const fcColor = '#f59e0b'
|
|
7152
|
+
const hist = f.history ?? []
|
|
7153
|
+
const fc = f.forecast ?? []
|
|
7154
|
+
const cats = [...hist.map(p => String(p.x)), ...fc.map(p => String(p.x))]
|
|
7155
|
+
const n = hist.length
|
|
7156
|
+
const pad = (k) => new Array(k).fill(null)
|
|
7157
|
+
const histData = hist.map(p => p.y)
|
|
7158
|
+
// The dashed forecast starts at the last observation so the two lines join.
|
|
7159
|
+
const fcData = [...pad(Math.max(0, n - 1)), n ? hist[n - 1].y : null, ...fc.map(p => p.value)]
|
|
7160
|
+
const lower = [...pad(n), ...fc.map(p => p.lower)]
|
|
7161
|
+
const width = [...pad(n), ...fc.map(p => p.upper - p.lower)]
|
|
7162
|
+
const axisCommon = { axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: label, fontSize: 11 }, splitLine: { lineStyle: { color: border, type: [3, 3] } } }
|
|
7163
|
+
return {
|
|
7164
|
+
color: [color, fcColor],
|
|
7165
|
+
backgroundColor: 'transparent',
|
|
7166
|
+
textStyle: { color: text, fontFamily: 'inherit' },
|
|
7167
|
+
animationDuration: 300,
|
|
7168
|
+
grid: { left: 4, right: 8, top: 36, bottom: 4, containLabel: true },
|
|
7169
|
+
legend: { top: 0, left: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, itemGap: 16, textStyle: { color: label, fontSize: 12 }, data: [f.measure, t('pro.fc.forecast'), t('pro.fc.band')] },
|
|
7170
|
+
tooltip: {
|
|
7171
|
+
trigger: 'axis', confine: true, backgroundColor: surface, borderColor: border, borderWidth: 1, padding: [8, 10], textStyle: { color: text, fontSize: 12 },
|
|
7172
|
+
formatter: (ps) => {
|
|
7173
|
+
const i = ps[0]?.dataIndex ?? 0
|
|
7174
|
+
const rows = [escapeHtml(cats[i])]
|
|
7175
|
+
if (i < n) rows.push(`${escapeHtml(f.measure)}: ${formatNumber(hist[i].y)}`)
|
|
7176
|
+
else { const p = fc[i - n]; rows.push(`${escapeHtml(t('pro.fc.forecast'))}: ${formatNumber(p.value)}`, `95%: ${formatNumber(p.lower)} ~ ${formatNumber(p.upper)}`) }
|
|
7177
|
+
return rows.join('<br/>')
|
|
7178
|
+
},
|
|
7179
|
+
},
|
|
7180
|
+
xAxis: { type: 'category', data: cats, boundaryGap: false, ...axisCommon, axisLine: { show: true, lineStyle: { color: border } }, axisLabel: { color: label, fontSize: 11, hideOverlap: true }, splitLine: { show: false } },
|
|
7181
|
+
yAxis: { type: 'value', scale: true, ...axisCommon, axisLabel: { color: label, fontSize: 11, formatter: (v) => formatNumber(v) } },
|
|
7182
|
+
dataZoom: cats.length > 60 ? [{ type: 'inside', startValue: Math.max(0, cats.length - 60) }] : undefined,
|
|
7183
|
+
series: [
|
|
7184
|
+
{ name: f.measure, type: 'line', data: histData, smooth: 0.3, showSymbol: false, symbol: 'circle', lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { opacity: 0.06 } },
|
|
7185
|
+
{ name: t('pro.fc.forecast'), type: 'line', data: fcData, smooth: 0.3, showSymbol: false, symbol: 'circle', lineStyle: { width: 2, type: 'dashed', color: fcColor }, itemStyle: { color: fcColor } },
|
|
7186
|
+
{ name: 'lower', type: 'line', data: lower, stack: 'band', stackStrategy: 'all', symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
|
|
7187
|
+
{ name: t('pro.fc.band'), type: 'line', data: width, stack: 'band', stackStrategy: 'all', symbol: 'none', lineStyle: { opacity: 0 }, itemStyle: { color: fcColor }, areaStyle: { color: fcColor, opacity: 0.16 }, silent: true },
|
|
7188
|
+
],
|
|
7189
|
+
}
|
|
7190
|
+
}
|
|
7191
|
+
|
|
7192
|
+
function ForecastChart({ t, forecast }) {
|
|
7193
|
+
const ref = useRef(null)
|
|
7194
|
+
const inst = useRef(null)
|
|
7195
|
+
const [error, setError] = useState(null)
|
|
7196
|
+
useEffect(() => {
|
|
7197
|
+
let disposed = false
|
|
7198
|
+
let observer
|
|
7199
|
+
loadEcharts().then((echarts) => {
|
|
7200
|
+
if (disposed || !ref.current) return
|
|
7201
|
+
inst.current = echarts.init(ref.current, null, { renderer: 'canvas' })
|
|
7202
|
+
observer = new ResizeObserver(() => inst.current?.resize())
|
|
7203
|
+
observer.observe(ref.current)
|
|
7204
|
+
try { inst.current.setOption(proForecastOption(forecast, ref.current, t), true) } catch (e) { setError(e.message) }
|
|
7205
|
+
}, (e) => setError(e.message))
|
|
7206
|
+
return () => { disposed = true; observer?.disconnect(); inst.current?.dispose(); inst.current = null }
|
|
7207
|
+
}, [forecast])
|
|
7208
|
+
return h('div', { style: { position: 'relative' } },
|
|
7209
|
+
error && h('div', { className: 'n2s-notice err' }, error),
|
|
7210
|
+
h('div', { ref, className: 'n2s-chart', role: 'img', 'aria-label': t('pro.fc.title') }))
|
|
7211
|
+
}
|
|
7212
|
+
|
|
7213
|
+
/** Forecast summary + chart + table, shared by the result panel and the tool view. */
|
|
7214
|
+
function ForecastView({ t, f, onClose, onHorizon }) {
|
|
7215
|
+
const [showTable, setShowTable] = useState(false)
|
|
7216
|
+
const method = f.method === 'holt-winters' ? t('pro.fc.hw') : t('pro.fc.holt')
|
|
7217
|
+
return h('div', { className: 'n2s-pro-fc' },
|
|
7218
|
+
h('div', { className: 'n2s-row', style: { gap: 8, marginBottom: 6 } },
|
|
7219
|
+
h('span', { className: 'n2s-pro-fc-title' }, Icon.spark(13), fmt(t, 'pro.fc.heading', { n: f.horizon })),
|
|
7220
|
+
h('span', { className: 'n2s-badge' }, method),
|
|
7221
|
+
f.filled > 0 && h('span', { className: 'n2s-badge warn', title: t('pro.fc.filledHint') }, fmt(t, 'pro.fc.filled', { n: f.filled })),
|
|
7222
|
+
h('span', { className: 'n2s-grow' }),
|
|
7223
|
+
onHorizon && h(Dropdown, { label: t('pro.fc.horizon'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, fmt(t, 'pro.fc.nDays', { n: f.horizon }), Icon.chev(10)) }, items: [7, 14, 30].map(n => ({ label: fmt(t, 'pro.fc.nDays', { n }), checked: f.horizon === n, onClick: () => onHorizon(n) })) }),
|
|
7224
|
+
h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showTable, onClick: () => setShowTable(v => !v) }, Icon.table(13), t('result.data')),
|
|
7225
|
+
onClose && h('button', { type: 'button', className: 'n2s-iconbtn', title: t('pro.close'), 'aria-label': t('pro.close'), onClick: onClose }, Icon.close())),
|
|
7226
|
+
h(ForecastChart, { t, forecast: f }),
|
|
7227
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('pro.fc.note')),
|
|
7228
|
+
showTable && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
|
|
7229
|
+
h('thead', null, h('tr', null, [f.x, t('pro.fc.forecast'), t('pro.fc.lower'), t('pro.fc.upper')].map((c, i) => h('th', { key: i, className: i ? 'num' : undefined }, c)))),
|
|
7230
|
+
h('tbody', null, (f.forecast ?? []).map(p => h('tr', { key: p.x }, h('td', null, p.x), h('td', { className: 'num' }, formatNumber(p.value)), h('td', { className: 'num' }, formatNumber(p.lower)), h('td', { className: 'num' }, formatNumber(p.upper))))))))
|
|
7231
|
+
}
|
|
7232
|
+
|
|
7233
|
+
/** Can this result be forecast? Time column + exactly one numeric measure, no series split. */
|
|
7234
|
+
function proForecastable(result) {
|
|
7235
|
+
if (!result?.columns || !result.rows || result.rows.length < 3 || result.chart?.series) return false
|
|
7236
|
+
const time = result.columns.some(c => c.kind === 'time')
|
|
7237
|
+
const nums = result.columns.filter(c => c.kind === 'number')
|
|
7238
|
+
const ys = result.chart?.y?.length ? result.chart.y : nums.map(c => c.name)
|
|
7239
|
+
return time && ys.filter(y => nums.some(c => c.name === y)).length === 1
|
|
7240
|
+
}
|
|
7241
|
+
|
|
7242
|
+
function ForecastPanel({ t, result }) {
|
|
7243
|
+
const get = () => proForecastStore.get()[result?.resultId]
|
|
7244
|
+
const horizon = useSyncExternalStore(proForecastStore.subscribe, get, get)
|
|
7245
|
+
const fc = useAsync(() => (horizon ? call('forecast', { resultId: result.resultId, horizon }) : null), [result?.resultId, horizon])
|
|
7246
|
+
if (!horizon) return null
|
|
7247
|
+
const close = () => proForecastStore.set({ [result.resultId]: 0 })
|
|
7248
|
+
if (fc.error) return h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, fc.error.message, ' ', h('button', { type: 'button', className: 'n2s-link', onClick: close }, t('pro.close')))
|
|
7249
|
+
if (!fc.value) return h('div', { className: 'n2s-pro-fc' }, h('span', { className: 'n2s-spin n2s-muted' }), ' ', h('span', { className: 'n2s-muted n2s-small' }, t('pro.fc.running')))
|
|
7250
|
+
return h(ForecastView, { t, f: fc.value, onClose: close, onHorizon: (n) => proForecastStore.set({ [result.resultId]: n }) })
|
|
7251
|
+
}
|
|
7252
|
+
|
|
7253
|
+
extend.resultMenu.push(({ t, result }) => (proForecastable(result) ? [
|
|
7254
|
+
{ divider: true },
|
|
7255
|
+
{ label: fmt(t, 'pro.fc.menu', { n: 7 }), icon: Icon.spark(14), onClick: () => proForecastStore.set({ [result.resultId]: 7 }) },
|
|
7256
|
+
] : []))
|
|
7257
|
+
extend.resultPanels.push(ForecastPanel)
|
|
7258
|
+
|
|
7259
|
+
function makeForecastToolView(ctx) {
|
|
7260
|
+
return function Nl2SqlForecastView(props) {
|
|
7261
|
+
const t = useMemo(() => ctx.locale.bind(NS), [])
|
|
7262
|
+
setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
|
|
7263
|
+
const block = props.block
|
|
7264
|
+
const settled = props.phase === 'result'
|
|
7265
|
+
const f = settled ? block.meta?.forecast : undefined
|
|
7266
|
+
if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('pro.fc.running'))))
|
|
7267
|
+
if (block.isError || !f) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-notice err' }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n') || t('tool.failed')))
|
|
7268
|
+
return h('div', { className: 'n2s-toolcard' }, f.title && h('div', { style: { fontWeight: 500, marginBottom: 6 } }, f.title), h(ForecastView, { t, f }))
|
|
7269
|
+
}
|
|
7270
|
+
}
|
|
7271
|
+
if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_forecast', make: makeForecastToolView })
|
|
7272
|
+
|
|
7273
|
+
// ---- goals
|
|
7274
|
+
|
|
7275
|
+
const proStatusClass = { 'on-track': 'ok', achieved: 'ok', 'at-risk': 'warn', missed: 'err', error: 'err' }
|
|
7276
|
+
const proPct = (v) => (v === null || v === undefined || !Number.isFinite(v) ? '—' : `${Math.round(v * 100)}%`)
|
|
7277
|
+
|
|
7278
|
+
function GoalBar({ g }) {
|
|
7279
|
+
const w = Math.max(0, Math.min(100, (g.pct ?? 0) * 100))
|
|
7280
|
+
const pw = Math.max(0, Math.min(100, (g.projectedPct ?? 0) * 100))
|
|
7281
|
+
const tick = Math.max(0, Math.min(100, (g.elapsedPct ?? 0) * 100))
|
|
7282
|
+
return h('span', { className: `n2s-pro-bar ${proStatusClass[g.status] ?? ''}`, 'aria-hidden': true },
|
|
7283
|
+
h('span', { className: 'proj', style: { width: `${pw}%` } }),
|
|
7284
|
+
h('span', { className: 'cur', style: { width: `${w}%` } }),
|
|
7285
|
+
g.direction !== 'down' && h('span', { className: 'tick', style: { left: `${tick}%` } }))
|
|
7286
|
+
}
|
|
7287
|
+
|
|
7288
|
+
/** Goal chips in the dashboard bar: goals tied to this dashboard or to its data sources. */
|
|
7289
|
+
function GoalChips({ t, boardId }) {
|
|
7290
|
+
const list = useAsync(() => (boardId ? call('goalProgress', { dashboardId: boardId }) : []), [boardId])
|
|
7291
|
+
const goals = list.value ?? []
|
|
7292
|
+
if (!goals.length) return null
|
|
7293
|
+
return h('div', { className: 'n2s-pro-chips', role: 'list', 'aria-label': t('pro.goals.title') }, goals.slice(0, 4).map(g => h('span', {
|
|
7294
|
+
key: g.id, role: 'listitem', className: 'n2s-pro-chip',
|
|
7295
|
+
title: g.error ?? `${g.name} · ${g.metric}\n${t('pro.goals.actual')} ${formatNumber(g.actual)} / ${formatNumber(g.target)} (${proPct(g.pct)})\n${t('pro.goals.projected')} ${formatNumber(g.projected)} (${proPct(g.projectedPct)})\n${t(`pro.st.${g.status}`)}`,
|
|
7296
|
+
},
|
|
7297
|
+
h('span', { className: 'n2s-ellipsis', style: { maxWidth: 90 } }, g.name),
|
|
7298
|
+
g.error ? h('span', { className: 'n2s-statusdot err' }, t('pro.st.error')) : h(React.Fragment, null, h(GoalBar, { g }), h('span', { className: `n2s-pro-pct ${proStatusClass[g.status] ?? ''}` }, proPct(g.pct))))))
|
|
7299
|
+
}
|
|
7300
|
+
extend.dashboardBar.push(GoalChips)
|
|
7301
|
+
|
|
7302
|
+
function useMetrics(datasourceId) {
|
|
7303
|
+
const k = useAsync(() => (datasourceId ? call('getKnowledge', { datasourceId }) : null), [datasourceId])
|
|
7304
|
+
return k.value?.metrics ?? []
|
|
7305
|
+
}
|
|
7306
|
+
|
|
7307
|
+
function GoalForm({ t, initial, ds, datasources, onDone, onCancel }) {
|
|
7308
|
+
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id ?? datasources[0]?.id ?? '', metric: '', period: 'month', target: '', direction: 'up', dashboardId: '', ...(initial ?? {}) }))
|
|
7309
|
+
const metrics = useMetrics(form.datasourceId)
|
|
7310
|
+
const boards = useAsync(() => call('listDashboards'), [])
|
|
7311
|
+
const [error, setError] = useState(null)
|
|
7312
|
+
const [busy, run] = useBusy()
|
|
7313
|
+
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
|
7314
|
+
const save = () => run(async () => {
|
|
7315
|
+
setError(null)
|
|
7316
|
+
try { await call('saveGoal', { ...form, target: Number(form.target), dashboardId: form.dashboardId || null }); onDone() } catch (e) { setError(e.message) }
|
|
7317
|
+
})
|
|
7318
|
+
return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
|
|
7319
|
+
h('div', { className: 'n2s-form' },
|
|
7320
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pro.goals.namePh') })),
|
|
7321
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
|
|
7322
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('al.metric')), metrics.length ? h('select', { className: 'n2s-select', value: form.metric, onChange: set('metric') }, h('option', { value: '' }, '—'), metrics.map(m => h('option', { key: m.id, value: m.name }, m.name))) : h('div', { className: 'n2s-faint n2s-small' }, t('al.noMetrics'))),
|
|
7323
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.period')), h('select', { className: 'n2s-select', value: form.period, onChange: set('period') }, ['month', 'quarter', 'year'].map(p => h('option', { key: p, value: p }, t(`pro.period.${p}`))))),
|
|
7324
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.target')), h('input', { className: 'n2s-input', type: 'number', value: form.target, onChange: set('target') })),
|
|
7325
|
+
h('div', { className: 'n2s-field' }, h('span', null, t('pro.goals.direction')), h('div', { className: 'n2s-seg' }, ['up', 'down'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.direction === v, onClick: () => setForm(f => ({ ...f, direction: v })) }, t(`pro.dir.${v}`))))),
|
|
7326
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.dashboard')), h('select', { className: 'n2s-select', value: form.dashboardId ?? '', onChange: set('dashboardId') }, h('option', { value: '' }, t('pro.goals.anyBoard')), (boards.value?.dashboards ?? []).map(b => h('option', { key: b.id, value: b.id }, b.name))))),
|
|
7327
|
+
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
7328
|
+
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
7329
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7330
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
function GoalPushSettings({ t }) {
|
|
7334
|
+
const cfg = useAsync(() => call('getGoalPush'), [])
|
|
7335
|
+
const [busy, run] = useBusy()
|
|
7336
|
+
const v = cfg.value
|
|
7337
|
+
if (!v) return null
|
|
7338
|
+
const save = (patch) => run(async () => { try { await call('saveGoalPush', patch); cfg.reload() } catch (e) { notifyError(e) } })
|
|
7339
|
+
return h('div', { className: 'n2s-section' }, h('h2', null, t('pro.goals.weekly')),
|
|
7340
|
+
h('div', { className: 'n2s-row', style: { gap: 10, flexWrap: 'wrap' } },
|
|
7341
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(v.enabled), disabled: busy, onChange: e => save({ enabled: e.target.checked }) }), t('pro.goals.weeklyOn')),
|
|
7342
|
+
h('select', { className: 'n2s-select inline', 'aria-label': t('pro.weekday'), value: v.weekday, onChange: e => save({ weekday: Number(e.target.value) }) }, [1, 2, 3, 4, 5, 6, 0].map(d => h('option', { key: d, value: d }, t(`pro.wd.${d}`)))),
|
|
7343
|
+
h('input', { className: 'n2s-input', type: 'time', 'aria-label': t('rep.time'), style: { width: 110 }, value: v.time, onChange: e => save({ time: e.target.value }) }),
|
|
7344
|
+
h('div', { style: { minWidth: 200 } }, h(ChannelPicker, { t, value: v.channelId ?? '', noneLabel: t('al.noPush'), label: t('rep.push'), onChange: (id) => save({ channelId: id || null }) })),
|
|
7345
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: !v.channelId || busy, onClick: () => run(async () => { try { await call('sendGoalReport'); notify(t('pro.sent'), 'ok') } catch (e) { notifyError(e) } }) }, Icon.send(13), t('pro.sendNow'))),
|
|
7346
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('pro.goals.weeklyHint')))
|
|
7347
|
+
}
|
|
7348
|
+
|
|
7349
|
+
function GoalsSection({ t, ds, datasources }) {
|
|
7350
|
+
const list = useAsync(() => call('goalProgress', {}), [])
|
|
7351
|
+
const [editing, setEditing] = useState(null)
|
|
7352
|
+
const goals = list.value ?? []
|
|
7353
|
+
return h('div', null,
|
|
7354
|
+
h(SectionHead, { title: t('pro.goals.title'), desc: t('pro.goals.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, '+ ', t('pro.goals.add'))),
|
|
7355
|
+
editing && h(GoalForm, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
|
|
7356
|
+
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
7357
|
+
list.loading && !list.value && h('span', { className: 'n2s-spin n2s-muted' }),
|
|
7358
|
+
!list.loading && goals.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pro.goals.empty')), h('div', null, t('pro.goals.emptyHint'))),
|
|
7359
|
+
goals.length > 0 && h('div', { className: 'n2s-list2' }, goals.map(g => h('div', { key: g.id, className: 'n2s-list2-row' },
|
|
7360
|
+
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
7361
|
+
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, g.name), h('span', { className: `n2s-statusdot ${proStatusClass[g.status] ?? ''}` }, t(`pro.st.${g.status}`))),
|
|
7362
|
+
h('div', { className: 'n2s-list2-sub' }, joinList([g.metric, t(`pro.period.${g.period}`), `${t(`pro.dir.${g.direction}`)} ${formatNumber(g.target)}`, g.datasourceName ?? null, g.periodStart && `${g.periodStart} ~ ${g.periodEnd}`], ' · ')),
|
|
7363
|
+
g.error ? h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, g.error)
|
|
7364
|
+
: h('div', { className: 'n2s-row', style: { gap: 8, marginTop: 6 } }, h(GoalBar, { g }),
|
|
7365
|
+
h('span', { className: 'n2s-small' }, `${formatCompact(g.actual)} / ${formatCompact(g.target)} (${proPct(g.pct)})`),
|
|
7366
|
+
h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'pro.goals.proj', { v: formatCompact(g.projected), p: proPct(g.projectedPct) }), g.weekdayAware ? ` · ${t('pro.goals.weekdayAware')}` : ''))),
|
|
7367
|
+
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
7368
|
+
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing({ id: g.id, name: g.name, datasourceId: g.datasourceId, metric: g.metric, period: g.period, target: g.target, direction: g.direction, dashboardId: g.dashboardId ?? '' }) },
|
|
7369
|
+
{ divider: true },
|
|
7370
|
+
{ label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pro.deleteConfirm', { name: g.name }), danger: true })) { try { await call('deleteGoal', { id: g.id }) } catch (e) { notifyError(e) } list.reload() } } },
|
|
7371
|
+
] })))),
|
|
7372
|
+
h(GoalPushSettings, { t }))
|
|
7373
|
+
}
|
|
7374
|
+
extend.adminSections.push({ id: 'goals', label: (t) => t('pro.goals.title'), icon: () => svg(15, [h('circle', { key: 1, cx: 8, cy: 8, r: 6 }), h('circle', { key: 2, cx: 8, cy: 8, r: 3 }), h('circle', { key: 3, cx: 8, cy: 8, r: .6 })]), Component: GoalsSection })
|
|
7375
|
+
|
|
7376
|
+
// ---- daily digest
|
|
7377
|
+
|
|
7378
|
+
function DigestForm({ t, initial, ds, datasources, onDone, onCancel }) {
|
|
7379
|
+
const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id ?? datasources[0]?.id ?? '', source: initial?.dashboardId ? 'dashboard' : 'metrics', metrics: [], dashboardId: '', channelId: '', time: '09:00', language: uiLang, threshold: 30, enabled: true, ...(initial ?? {}) }))
|
|
7380
|
+
const metrics = useMetrics(form.datasourceId)
|
|
7381
|
+
const boards = useAsync(() => call('listDashboards'), [])
|
|
7382
|
+
const [error, setError] = useState(null)
|
|
7383
|
+
const [busy, run] = useBusy()
|
|
7384
|
+
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
|
7385
|
+
const payload = () => ({ id: form.id, name: form.name, datasourceId: form.datasourceId, metrics: form.source === 'metrics' ? form.metrics : [], dashboardId: form.source === 'dashboard' ? form.dashboardId || null : null, channelId: form.channelId || null, time: form.time, language: form.language, threshold: Number(form.threshold), enabled: form.enabled })
|
|
7386
|
+
const save = () => run(async () => {
|
|
7387
|
+
setError(null)
|
|
7388
|
+
try { await call('saveDigest', payload()); onDone() } catch (e) { setError(e.message) }
|
|
7389
|
+
})
|
|
7390
|
+
const toggle = (name) => setForm(f => ({ ...f, metrics: f.metrics.includes(name) ? f.metrics.filter(x => x !== name) : [...f.metrics, name] }))
|
|
7391
|
+
return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
|
|
7392
|
+
h('div', { className: 'n2s-form' },
|
|
7393
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pro.dg.namePh') })),
|
|
7394
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
|
|
7395
|
+
h('div', { className: 'n2s-field full' }, h('span', null, t('pro.dg.source')), h('div', { className: 'n2s-seg' }, ['metrics', 'dashboard'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.source === v, onClick: () => setForm(f => ({ ...f, source: v })) }, t(`pro.dg.src.${v}`))))),
|
|
7396
|
+
form.source === 'metrics'
|
|
7397
|
+
? h('div', { className: 'n2s-field full' }, h('span', null, t('pro.dg.metrics')), metrics.length ? h('div', { className: 'n2s-chips' }, metrics.map(m => h('button', { key: m.id, type: 'button', className: 'n2s-chip', 'aria-pressed': form.metrics.includes(m.name), onClick: () => toggle(m.name) }, m.name))) : h('div', { className: 'n2s-faint n2s-small' }, t('al.noMetrics')))
|
|
7398
|
+
: h('label', { className: 'n2s-field full' }, h('span', null, t('pro.dg.dashboard')), h('select', { className: 'n2s-select', value: form.dashboardId ?? '', onChange: set('dashboardId') }, h('option', { value: '' }, '—'), (boards.value?.dashboards ?? []).map(b => h('option', { key: b.id, value: b.id }, b.name)))),
|
|
7399
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.time, onChange: set('time') })),
|
|
7400
|
+
h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-pro-dg-ch', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-pro-dg-ch', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
|
|
7401
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.language')), h('select', { className: 'n2s-select', value: form.language, onChange: set('language') }, h('option', { value: 'zh' }, '中文'), h('option', { value: 'en' }, 'English'))),
|
|
7402
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.threshold')), h('input', { className: 'n2s-input', type: 'number', min: 1, max: 500, value: form.threshold, onChange: set('threshold') }))),
|
|
7403
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('pro.dg.hint')),
|
|
7404
|
+
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
|
|
7405
|
+
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
7406
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7407
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
7408
|
+
}
|
|
7409
|
+
|
|
7410
|
+
function DigestPreview({ t, run }) {
|
|
7411
|
+
if (run.busy) return h('div', { className: 'n2s-notice', style: { marginTop: 10 } }, h('span', { className: 'n2s-spin' }), ' ', t('pro.dg.previewing'))
|
|
7412
|
+
if (run.error) return h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, run.error)
|
|
7413
|
+
return h('div', { className: 'n2s-pro-preview' },
|
|
7414
|
+
h('div', { style: { fontWeight: 500, marginBottom: 6 } }, run.title),
|
|
7415
|
+
h(MdPreview, { text: run.text }),
|
|
7416
|
+
(run.results ?? []).length > 0 && h('div', { className: 'n2s-pro-preview-charts' }, run.results.map(r => h(ResultView, { key: r.resultId, result: r, t, compact: true, hideSql: true, onPinned: { hidden: true } }))))
|
|
7417
|
+
}
|
|
7418
|
+
|
|
7419
|
+
function DigestsSection({ t, ds, datasources }) {
|
|
7420
|
+
const list = useAsync(() => call('listDigests'), [])
|
|
7421
|
+
const [editing, setEditing] = useState(null)
|
|
7422
|
+
const [preview, setPreview] = useState(null)
|
|
7423
|
+
const digests = list.value ?? []
|
|
7424
|
+
const runPreview = async (d) => {
|
|
7425
|
+
setPreview({ id: d.id, busy: true })
|
|
7426
|
+
try { setPreview({ ...(await call('runDigest', { id: d.id })), runId: undefined, id: d.id }) } catch (e) { setPreview({ id: d.id, error: e.message }) }
|
|
7427
|
+
}
|
|
7428
|
+
const act = async (fn, ok) => { try { await fn(); if (ok) notify(ok, 'ok') } catch (e) { notifyError(e) } list.reload() }
|
|
7429
|
+
return h('div', null,
|
|
7430
|
+
h(SectionHead, { title: t('pro.dg.title'), desc: t('pro.dg.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, '+ ', t('pro.dg.add'))),
|
|
7431
|
+
editing && h(DigestForm, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
|
|
7432
|
+
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
7433
|
+
!list.loading && digests.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pro.dg.empty')), h('div', null, t('pro.dg.emptyHint'))),
|
|
7434
|
+
digests.length > 0 && h('div', { className: 'n2s-list2' }, digests.map(d => h(React.Fragment, { key: d.id },
|
|
7435
|
+
h('div', { className: 'n2s-list2-row' },
|
|
7436
|
+
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
7437
|
+
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, d.name), !d.enabled && h('span', { className: 'n2s-badge' }, t('rep.status.paused')), d.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), d.channelName)),
|
|
7438
|
+
h('div', { className: 'n2s-list2-sub' }, joinList([d.dashboardId ? t('pro.dg.src.dashboard') : joinList(d.metrics), fmt(t, 'pro.dg.dailyAt', { time: d.time }), d.datasourceName], ' · ')),
|
|
7439
|
+
d.lastError && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, d.lastError)),
|
|
7440
|
+
h('div', { className: 'n2s-list2-side' }, d.lastRunAt ? h('span', { className: `n2s-statusdot ${d.lastStatus === 'ok' ? 'ok' : 'err'}` }, new Date(d.lastRunAt).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })) : h('span', { className: 'n2s-faint n2s-small' }, t('al.never'))),
|
|
7441
|
+
h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
|
|
7442
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: preview?.id === d.id && preview.busy, onClick: () => runPreview(d) }, t('pro.dg.preview')),
|
|
7443
|
+
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
7444
|
+
{ label: t('pro.sendNow'), icon: Icon.send(14), disabled: !d.channelId, onClick: () => act(() => call('runDigest', { id: d.id, send: true }), t('pro.sent')) },
|
|
7445
|
+
{ label: d.enabled ? t('rep.pause') : t('rep.resume'), icon: Icon.refresh(14), onClick: () => act(() => call('saveDigest', { id: d.id, enabled: !d.enabled })) },
|
|
7446
|
+
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing({ ...d, source: d.dashboardId ? 'dashboard' : 'metrics', channelId: d.channelId ?? '', dashboardId: d.dashboardId ?? '' }) },
|
|
7447
|
+
{ divider: true },
|
|
7448
|
+
{ label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pro.deleteConfirm', { name: d.name }), danger: true })) act(() => call('deleteDigest', { id: d.id })) } },
|
|
7449
|
+
] }))),
|
|
7450
|
+
preview?.id === d.id && h('div', { style: { padding: '0 12px 12px' } }, h(DigestPreview, { t, run: preview }), !preview.busy && h('button', { type: 'button', className: 'n2s-btn small ghost', style: { marginTop: 6 }, onClick: () => setPreview(null) }, t('pro.close')))))))
|
|
7451
|
+
}
|
|
7452
|
+
extend.adminSections.push({ id: 'digests', label: (t) => t('pro.dg.title'), icon: () => Icon.spark(15), Component: DigestsSection })
|
|
7453
|
+
// ------------------------------------------------- presentation (feature "present")
|
|
7454
|
+
// Dashboard menu: export PPT / long image, send the long image, presentation mode.
|
|
7455
|
+
// Result menu: export PPT. Admin section: Lark Bitable sync.
|
|
7456
|
+
|
|
7457
|
+
async function preDownload(t, method, payload, doneKey) {
|
|
7458
|
+
notify(t('pre.working'), 'info', 2500)
|
|
7459
|
+
try { const r = await call(method, payload); downloadBase64(r); if (doneKey) notify(t(doneKey), 'ok') } catch (e) { notifyError(e) }
|
|
7460
|
+
}
|
|
7461
|
+
|
|
7462
|
+
/** Pick an image-capable channel and send the dashboard long image. */
|
|
7463
|
+
function PreSendImageDialog({ t, boardId, onClose }) {
|
|
7464
|
+
const channels = useChannels().filter(c => c.type === 'lark_app' || c.type === 'telegram_bot')
|
|
7465
|
+
const [channelId, setChannelId] = useState('')
|
|
7466
|
+
const [busy, run] = useBusy()
|
|
7467
|
+
const [error, setError] = useState(null)
|
|
7468
|
+
useEffect(() => { if (!channelId && channels[0]) setChannelId(channels[0].id) }, [channels.length])
|
|
7469
|
+
const send = () => run(async () => {
|
|
7470
|
+
setError(null)
|
|
7471
|
+
try { const r = await call('sendDashboardImage', { dashboardId: boardId, channelId }); notify(fmt(t, 'pre.imageSent', { channel: r.channel }), 'ok'); onClose() } catch (e) { setError(e.message) }
|
|
7472
|
+
})
|
|
7473
|
+
return h(Modal, { label: t('pre.sendImage'), onClose, width: 460 },
|
|
7474
|
+
h('div', { className: 'n2s-modal-title' }, t('pre.sendImage')),
|
|
7475
|
+
h('div', { className: 'n2s-pre-dlg' },
|
|
7476
|
+
h('p', { className: 'n2s-muted n2s-small', style: { marginTop: 0 } }, t('pre.sendImageHint')),
|
|
7477
|
+
channels.length
|
|
7478
|
+
? h('select', { className: 'n2s-select', 'aria-label': t('pre.channel'), value: channelId, onChange: e => setChannelId(e.target.value) }, channels.map(c => h('option', { key: c.id, value: c.id }, c.name)))
|
|
7479
|
+
: h('div', { className: 'n2s-notice warn' }, t('pre.noImageChannel')),
|
|
7480
|
+
error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error)),
|
|
7481
|
+
h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', gap: 8, marginTop: 16 } },
|
|
7482
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
|
|
7483
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy || !channelId, onClick: send }, busy ? h('span', { className: 'n2s-spin' }) : Icon.send(13), t('pre.send'))))
|
|
7484
|
+
}
|
|
7485
|
+
|
|
7486
|
+
// ------------------------------------------------------ presentation mode
|
|
7487
|
+
|
|
7488
|
+
const PRE_REFRESH = [60, 300, 900]
|
|
7489
|
+
const PRE_ROTATE = [0, 30, 60, 120, 300]
|
|
7490
|
+
|
|
7491
|
+
function PreWidget({ t, widget, filters, tick, onLoaded }) {
|
|
7492
|
+
const [state, setState] = useState({ busy: true })
|
|
7493
|
+
useEffect(() => {
|
|
7494
|
+
let alive = true
|
|
7495
|
+
setState(s => ({ ...s, busy: true }))
|
|
7496
|
+
call('runWidget', { id: widget.id, ...(filters ? { filters } : {}), fresh: tick > 0 }).then(
|
|
7497
|
+
r => { if (alive) { setState({ result: { ...r, title: '' } }); onLoaded?.() } },
|
|
7498
|
+
e => { if (alive) { setState({ error: e.message }); onLoaded?.() } })
|
|
7499
|
+
return () => { alive = false }
|
|
7500
|
+
}, [widget.id, tick, filters])
|
|
7501
|
+
const width = ['third', 'half', 'full'].includes(widget.width) ? widget.width : 'half'
|
|
7502
|
+
return h('section', { className: `n2s-pre-card w-${width}`, 'aria-label': widget.title },
|
|
7503
|
+
h('div', { className: 'n2s-pre-card-title' }, widget.title, state.busy && state.result ? h('span', { className: 'n2s-spin', style: { marginLeft: 8 } }) : null),
|
|
7504
|
+
state.error ? h('div', { className: 'n2s-notice err' }, state.error)
|
|
7505
|
+
: state.result ? h(ResultView, { result: state.result, t, compact: true, embedded: true, hideSql: true, onPinned: HIDDEN_PIN })
|
|
7506
|
+
: h('div', { className: 'n2s-skel', style: { height: 240 } }))
|
|
7507
|
+
}
|
|
7508
|
+
|
|
7509
|
+
function PresentationMode({ t, boardId, filters, onClose }) {
|
|
7510
|
+
const root = useRef(null)
|
|
7511
|
+
const boards = useAsync(() => call('listDashboards'), [])
|
|
7512
|
+
const list = boards.value?.dashboards ?? []
|
|
7513
|
+
const [current, setCurrent] = useState(boardId)
|
|
7514
|
+
const board = list.find(b => b.id === current)
|
|
7515
|
+
const widgets = useAsync(() => call('listDashboard', { dashboardId: current }), [current])
|
|
7516
|
+
const [refresh, setRefresh] = useState(() => { try { return Number(localStorage.getItem('nl2sql:preRefresh')) || 300 } catch { return 300 } })
|
|
7517
|
+
const [rotate, setRotate] = useState(0)
|
|
7518
|
+
const [tick, setTick] = useState(0)
|
|
7519
|
+
const [now, setNow] = useState(Date.now())
|
|
7520
|
+
const [lastAt, setLastAt] = useState(null)
|
|
7521
|
+
const [fs, setFs] = useState(false)
|
|
7522
|
+
// The opening board keeps the dashboard's current (unsaved) filters; other boards use their saved ones.
|
|
7523
|
+
const boardFilters = current === boardId ? filters : undefined
|
|
7524
|
+
const go = useCallback((delta) => {
|
|
7525
|
+
if (list.length < 2) return
|
|
7526
|
+
const i = Math.max(0, list.findIndex(b => b.id === current))
|
|
7527
|
+
setCurrent(list[(i + delta + list.length) % list.length].id)
|
|
7528
|
+
setLastAt(null)
|
|
7529
|
+
}, [list, current])
|
|
7530
|
+
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
|
|
7531
|
+
useEffect(() => { const id = setInterval(() => setTick(x => x + 1), refresh * 1000); return () => clearInterval(id) }, [refresh])
|
|
7532
|
+
useEffect(() => { if (!rotate || list.length < 2) return; const id = setInterval(() => go(1), rotate * 1000); return () => clearInterval(id) }, [rotate, go, list.length])
|
|
7533
|
+
useEffect(() => {
|
|
7534
|
+
const onKey = (e) => {
|
|
7535
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
|
7536
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); go(1) }
|
|
7537
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1) }
|
|
7538
|
+
}
|
|
7539
|
+
document.addEventListener('keydown', onKey, true)
|
|
7540
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
7541
|
+
}, [go, onClose])
|
|
7542
|
+
useEffect(() => {
|
|
7543
|
+
const el = root.current
|
|
7544
|
+
const prev = document.activeElement
|
|
7545
|
+
el?.focus()
|
|
7546
|
+
try { if (el?.requestFullscreen && !document.fullscreenElement) el.requestFullscreen().then(() => setFs(true), () => {}) } catch {}
|
|
7547
|
+
// Leaving browser fullscreen with Esc closes the overlay too.
|
|
7548
|
+
const onFs = () => { if (!document.fullscreenElement) { setFs(was => { if (was) onClose(); return false }) } }
|
|
7549
|
+
document.addEventListener('fullscreenchange', onFs)
|
|
7550
|
+
const overflow = document.body.style.overflow
|
|
7551
|
+
document.body.style.overflow = 'hidden'
|
|
7552
|
+
return () => {
|
|
7553
|
+
document.removeEventListener('fullscreenchange', onFs)
|
|
7554
|
+
document.body.style.overflow = overflow
|
|
7555
|
+
try { if (document.fullscreenElement) document.exitFullscreen?.() } catch {}
|
|
7556
|
+
prev?.focus?.()
|
|
7557
|
+
}
|
|
7558
|
+
}, [])
|
|
7559
|
+
const setRefreshSaved = (v) => { setRefresh(v); try { localStorage.setItem('nl2sql:preRefresh', String(v)) } catch {} }
|
|
7560
|
+
const items = widgets.value ?? []
|
|
7561
|
+
const clock = new Date(now)
|
|
7562
|
+
return ReactDOM.createPortal(h('div', { ref: root, className: 'n2s-pre', role: 'dialog', 'aria-modal': true, 'aria-label': t('pre.mode'), tabIndex: -1 },
|
|
7563
|
+
h('header', { className: 'n2s-pre-head' },
|
|
7564
|
+
h('div', { className: 'n2s-pre-titles' },
|
|
7565
|
+
h('h1', null, board?.name ?? ''),
|
|
7566
|
+
h('div', { className: 'n2s-pre-sub', role: 'status', 'aria-live': 'polite' },
|
|
7567
|
+
lastAt ? fmt(t, 'pre.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : t('pre.loading'),
|
|
7568
|
+
list.length > 1 ? ` · ${list.findIndex(b => b.id === current) + 1}/${list.length}` : '')),
|
|
7569
|
+
h('div', { className: 'n2s-pre-clock', 'aria-label': t('pre.clock') },
|
|
7570
|
+
h('div', { className: 'time' }, clock.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })),
|
|
7571
|
+
h('div', { className: 'date' }, clock.toLocaleDateString(uiLocale(), { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }))),
|
|
7572
|
+
h('div', { className: 'n2s-pre-ctl' },
|
|
7573
|
+
h('label', null, h('span', null, t('pre.refresh')), h('select', { value: refresh, onChange: e => setRefreshSaved(Number(e.target.value)) }, PRE_REFRESH.map(v => h('option', { key: v, value: v }, fmt(t, 'dash.autoEvery', { min: v / 60 }))))),
|
|
7574
|
+
list.length > 1 && h('label', null, h('span', null, t('pre.rotate')), h('select', { value: rotate, onChange: e => setRotate(Number(e.target.value)) }, PRE_ROTATE.map(v => h('option', { key: v, value: v }, v ? fmt(t, 'pre.everySec', { s: v }) : t('pre.rotateOff'))))),
|
|
7575
|
+
list.length > 1 && h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.prev'), title: t('pre.prev'), onClick: () => go(-1) }, '←'),
|
|
7576
|
+
list.length > 1 && h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.next'), title: t('pre.next'), onClick: () => go(1) }, '→'),
|
|
7577
|
+
h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('dash.refresh'), title: t('dash.refresh'), onClick: () => setTick(x => x + 1) }, Icon.refresh(16)),
|
|
7578
|
+
h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.exit'), title: t('pre.exitHint'), onClick: onClose }, Icon.close(16)))),
|
|
7579
|
+
widgets.error && h('div', { className: 'n2s-notice err' }, widgets.error.message),
|
|
7580
|
+
!widgets.loading && items.length === 0 && h('div', { className: 'n2s-pre-empty' }, t('dash.empty')),
|
|
7581
|
+
h('main', { className: 'n2s-pre-grid', key: current },
|
|
7582
|
+
items.map(w => h(PreWidget, { key: w.id, t, widget: w, filters: boardFilters, tick, onLoaded: () => setLastAt(Date.now()) })))), document.body)
|
|
7583
|
+
}
|
|
7584
|
+
|
|
7585
|
+
// ------------------------------------------------------------ menus
|
|
7586
|
+
|
|
7587
|
+
const preState = createStore({ present: null, sendImage: null })
|
|
7588
|
+
function PreLayer({ t }) {
|
|
7589
|
+
const s = useSyncExternalStore(preState.subscribe, preState.get, preState.get)
|
|
7590
|
+
return h(React.Fragment, null,
|
|
7591
|
+
s.present && h(PresentationMode, { t, boardId: s.present.boardId, filters: s.present.filters, onClose: () => preState.set({ present: null }) }),
|
|
7592
|
+
s.sendImage && h(PreSendImageDialog, { t, boardId: s.sendImage.boardId, onClose: () => preState.set({ sendImage: null }) }))
|
|
7593
|
+
}
|
|
7594
|
+
extend.dashboardBar.push(PreLayer)
|
|
7595
|
+
|
|
7596
|
+
extend.dashboardMenu.push(({ t, boardId, widgets, filters }) => [
|
|
7597
|
+
{ divider: true },
|
|
7598
|
+
{ label: t('pre.exportPpt'), icon: Icon.download(14), disabled: !widgets?.length, onClick: () => preDownload(t, 'exportDashboardPptx', { dashboardId: boardId, filters }, 'pre.exported') },
|
|
7599
|
+
{ label: t('pre.exportImage'), icon: Icon.download(14), disabled: !widgets?.length, onClick: () => preDownload(t, 'exportDashboardImage', { dashboardId: boardId, filters, width: 1200 }, 'pre.exported') },
|
|
7600
|
+
{ label: t('pre.sendImage'), icon: Icon.send(14), disabled: !widgets?.length, onClick: () => preState.set({ sendImage: { boardId } }) },
|
|
7601
|
+
{ label: t('pre.mode'), icon: Icon.expand(14), onClick: () => preState.set({ present: { boardId, filters } }) },
|
|
7602
|
+
])
|
|
7603
|
+
|
|
7604
|
+
extend.resultMenu.push(({ t, result }) => (result?.resultId ? [
|
|
7605
|
+
{ label: t('pre.exportPpt'), icon: Icon.download(14), onClick: () => preDownload(t, 'exportResultsPptx', { resultIds: [result.resultId], title: result.title }, 'pre.exported') },
|
|
7606
|
+
] : []))
|
|
7607
|
+
|
|
7608
|
+
// ------------------------------------------------------- Bitable sync admin
|
|
7609
|
+
|
|
7610
|
+
function PreBitableForm({ t, initial, datasources, onDone, onCancel }) {
|
|
7611
|
+
const channels = useChannels().filter(c => c.type === 'lark_app')
|
|
7612
|
+
const boards = useAsync(() => call('listDashboards'), [])
|
|
7613
|
+
const allWidgets = useAsync(async () => {
|
|
7614
|
+
const list = boards.value?.dashboards ?? []
|
|
7615
|
+
const per = await Promise.all(list.map(b => call('listDashboard', { dashboardId: b.id }).then(ws => ws.map(w => ({ ...w, board: b.name }))).catch(() => [])))
|
|
7616
|
+
return per.flat()
|
|
7617
|
+
}, [boards.value])
|
|
7618
|
+
const [form, setForm] = useState(() => ({
|
|
7619
|
+
name: '', channelId: '', appToken: '', tableId: '', mode: 'replace', schedule: { time: '08:00', enabled: false },
|
|
7620
|
+
kind: initial?.source?.sql ? 'sql' : 'widget', widgetId: initial?.source?.widgetId ?? '', datasourceId: initial?.source?.datasourceId ?? datasources[0]?.id ?? '', sql: initial?.source?.sql ?? '',
|
|
7621
|
+
...(initial ?? {}),
|
|
7622
|
+
}))
|
|
7623
|
+
useEffect(() => { if (!form.channelId && channels[0]) setForm(f => ({ ...f, channelId: channels[0].id })) }, [channels.length])
|
|
7624
|
+
const [error, setError] = useState(null)
|
|
7625
|
+
const [busy, run] = useBusy()
|
|
7626
|
+
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
|
|
7627
|
+
// Paste a Bitable link: …/base/<app_token>?table=<table_id>
|
|
7628
|
+
const pasteLink = (e) => {
|
|
7629
|
+
const m = String(e.target.value).match(/\/base\/([A-Za-z0-9]+)(?:[^#]*[?&]table=(tbl[A-Za-z0-9]+))?/)
|
|
7630
|
+
if (m) { e.preventDefault(); setForm(f => ({ ...f, appToken: m[1], tableId: m[2] ?? f.tableId })) } else set('appToken')(e)
|
|
7631
|
+
}
|
|
7632
|
+
const save = () => run(async () => {
|
|
7633
|
+
setError(null)
|
|
7634
|
+
const source = form.kind === 'sql' ? { datasourceId: form.datasourceId, sql: form.sql } : { widgetId: form.widgetId }
|
|
7635
|
+
try { await call('saveBitableSync', { id: form.id, name: form.name, channelId: form.channelId, appToken: form.appToken, tableId: form.tableId, mode: form.mode, schedule: form.schedule, source }); onDone() } catch (e) { setError(e) }
|
|
7636
|
+
})
|
|
7637
|
+
const err = (field) => error?.field === field ? h('span', { className: 'n2s-small', style: { color: 'var(--n2s-err)' } }, error.message) : null
|
|
7638
|
+
return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
|
|
7639
|
+
h('div', { className: 'n2s-form' },
|
|
7640
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pre.bt.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pre.bt.namePh') })),
|
|
7641
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('pre.bt.channel')),
|
|
7642
|
+
channels.length ? h('select', { className: 'n2s-select', value: form.channelId, onChange: set('channelId') }, channels.map(c => h('option', { key: c.id, value: c.id }, c.name))) : h('span', { className: 'n2s-small n2s-muted' }, t('pre.bt.noChannel')), err('channelId')),
|
|
7643
|
+
h('label', { className: 'n2s-field' }, h('span', null, 'app_token'), h('input', { className: 'n2s-input n2s-mono', value: form.appToken, onChange: pasteLink, placeholder: t('pre.bt.appTokenPh') }), err('appToken')),
|
|
7644
|
+
h('label', { className: 'n2s-field' }, h('span', null, 'table_id'), h('input', { className: 'n2s-input n2s-mono', value: form.tableId, onChange: set('tableId'), placeholder: 'tbl…' }), err('tableId')),
|
|
7645
|
+
h('div', { className: 'n2s-field full' }, h('span', null, t('pre.bt.source')), h('div', { className: 'n2s-seg' }, ['widget', 'sql'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`pre.bt.src.${v}`))))),
|
|
7646
|
+
form.kind === 'widget'
|
|
7647
|
+
? h('label', { className: 'n2s-field full' }, h('span', null, t('pre.bt.widget')), h('select', { className: 'n2s-select', value: form.widgetId, onChange: set('widgetId') }, h('option', { value: '' }, '—'), (allWidgets.value ?? []).map(w => h('option', { key: w.id, value: w.id }, `${w.board} · ${w.title}`))), err('source'))
|
|
7648
|
+
: h(React.Fragment, null,
|
|
7649
|
+
h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
|
|
7650
|
+
h('label', { className: 'n2s-field full' }, h('span', null, 'SQL'), h('textarea', { className: 'n2s-textarea n2s-mono', spellCheck: false, value: form.sql, onChange: set('sql'), placeholder: 'SELECT …' }), err('sql'))),
|
|
7651
|
+
h('div', { className: 'n2s-field' }, h('span', null, t('pre.bt.mode')), h('div', { className: 'n2s-seg' }, ['replace', 'append'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.mode === v, onClick: () => setForm(f => ({ ...f, mode: v })) }, t(`pre.bt.mode.${v}`)))),
|
|
7652
|
+
h('span', { className: 'n2s-faint n2s-small' }, t(`pre.bt.modeHint.${form.mode}`))),
|
|
7653
|
+
h('div', { className: 'n2s-field' }, h('span', null, t('pre.bt.schedule')),
|
|
7654
|
+
h('div', { className: 'n2s-row' },
|
|
7655
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.schedule.enabled, onChange: e => setForm(f => ({ ...f, schedule: { ...f.schedule, enabled: e.target.checked } })) }), t('pre.bt.daily')),
|
|
7656
|
+
h('input', { className: 'n2s-input', type: 'time', 'aria-label': t('rep.time'), style: { width: 120 }, value: form.schedule.time, disabled: !form.schedule.enabled, onChange: e => setForm(f => ({ ...f, schedule: { ...f.schedule, time: e.target.value } })) })))),
|
|
7657
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('pre.bt.permHint')),
|
|
7658
|
+
error && !error.field && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error.message),
|
|
7659
|
+
h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
|
|
7660
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7661
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
function PreBitableSection({ t, datasources = [] }) {
|
|
7665
|
+
const list = useAsync(() => call('listBitableSyncs'), [])
|
|
7666
|
+
const [editing, setEditing] = useState(null)
|
|
7667
|
+
const [running, setRunning] = useState(null)
|
|
7668
|
+
const syncs = list.value ?? []
|
|
7669
|
+
const runNow = async (s) => {
|
|
7670
|
+
if (running) return
|
|
7671
|
+
setRunning(s.id)
|
|
7672
|
+
try { const r = await call('runBitableSync', { id: s.id }); notify(fmt(t, 'pre.bt.ran', { created: r.created, deleted: r.deleted }), 'ok') } catch (e) { notifyError(e) } finally { setRunning(null); list.reload() }
|
|
7673
|
+
}
|
|
7674
|
+
return h('div', null,
|
|
7675
|
+
h(SectionHead, { title: t('pre.bt.title'), desc: t('pre.bt.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('pre.bt.add'))),
|
|
7676
|
+
editing && h(PreBitableForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
|
|
7677
|
+
list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
|
|
7678
|
+
!list.loading && !syncs.length && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pre.bt.empty')), h('div', null, t('pre.bt.emptyHint'))),
|
|
7679
|
+
syncs.length > 0 && h('div', { className: 'n2s-list2' }, syncs.map(s => h('div', { key: s.id, className: 'n2s-list2-row' },
|
|
7680
|
+
h('span', { className: 'n2s-tile', 'aria-hidden': true }, Icon.table(16)),
|
|
7681
|
+
h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
|
|
7682
|
+
h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, s.name), h('span', { className: 'n2s-badge' }, t(`pre.bt.mode.${s.mode}`)), s.schedule?.enabled && h('span', { className: 'n2s-badge blue' }, fmt(t, 'pre.bt.dailyAt', { time: s.schedule.time }))),
|
|
7683
|
+
h('div', { className: 'n2s-list2-sub' }, joinList([s.source?.widgetId ? `${t('pre.bt.src.widget')}${colon('')}${s.sourceLabel ?? '?'}` : `SQL · ${s.sourceLabel ?? ''}`, s.channelName ?? t('pre.bt.channelMissing'), `${s.appToken} / ${s.tableId}`], ' · ')),
|
|
7684
|
+
s.lastError && s.lastStatus === 'error' && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, s.lastError)),
|
|
7685
|
+
h('div', { className: 'n2s-list2-side' },
|
|
7686
|
+
h('span', { className: `n2s-statusdot ${s.lastStatus === 'error' ? 'err' : s.lastStatus === 'ok' ? 'ok' : ''}` }, s.lastStatus === 'ok' ? fmt(t, 'pre.bt.lastOk', { n: s.lastRows ?? 0 }) : s.lastStatus === 'error' ? t('pre.bt.lastErr') : t('pre.bt.never')),
|
|
7687
|
+
s.lastRunAt ? h('span', { className: 'n2s-faint n2s-small' }, new Date(s.lastRunAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })) : null),
|
|
7688
|
+
h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
|
|
7689
|
+
h('button', { type: 'button', className: 'n2s-btn small', disabled: Boolean(running), onClick: () => runNow(s) }, running === s.id ? h('span', { className: 'n2s-spin' }) : null, t('pre.bt.run')),
|
|
7690
|
+
h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
|
|
7691
|
+
{ label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
|
|
7692
|
+
{ divider: true },
|
|
7693
|
+
{ label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pre.bt.deleteConfirm', { name: s.name }), danger: true })) { try { await call('deleteBitableSync', { id: s.id }) } catch (e) { notifyError(e) } list.reload() } } },
|
|
7694
|
+
] }))))))
|
|
7695
|
+
}
|
|
7696
|
+
|
|
7697
|
+
extend.adminSections.push({ id: 'bitable', label: (t) => t('pre.bt.nav'), icon: () => Icon.table(15), Component: PreBitableSection })
|
|
7698
|
+
// ------------------------------------------------------------ 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.
|
|
7701
|
+
|
|
7702
|
+
const MCP_PKG = '@hedeqiang/dsh-nl2sql'
|
|
7703
|
+
|
|
7704
|
+
/** Config snippets for a token (placeholder when the token value is no longer known). */
|
|
7705
|
+
function mcpSnippets(url, token) {
|
|
7706
|
+
const tok = token || '<YOUR_TOKEN>'
|
|
7707
|
+
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),
|
|
7711
|
+
}
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7714
|
+
function McpCopy({ t, text, label }) {
|
|
7715
|
+
const [done, setDone] = useState(false)
|
|
7716
|
+
return h('button', {
|
|
7717
|
+
type: 'button', className: 'n2s-btn small', 'aria-label': label ?? t('mcp.copy'),
|
|
7718
|
+
onClick: async () => {
|
|
7719
|
+
try { await navigator.clipboard?.writeText(text); setDone(true); setTimeout(() => setDone(false), 1500) } catch (e) { notifyError(e) }
|
|
7720
|
+
},
|
|
7721
|
+
}, done ? Icon.check(13) : Icon.copy(13), ' ', done ? t('mcp.copied') : (label ?? t('mcp.copy')))
|
|
7722
|
+
}
|
|
7723
|
+
|
|
7724
|
+
function McpSnippets({ t, url, token }) {
|
|
7725
|
+
const [tab, setTab] = useState('claude')
|
|
7726
|
+
const snip = mcpSnippets(url, token)
|
|
7727
|
+
const where = { claude: 'mcp.where.claude', cursor: 'mcp.where.cursor', vscode: 'mcp.where.vscode' }
|
|
7728
|
+
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])),
|
|
7731
|
+
h('div', { className: 'n2s-mcp-code' },
|
|
7732
|
+
h('pre', { className: 'n2s-code', 'data-snippet': tab }, snip[tab]),
|
|
7733
|
+
h('div', { className: 'n2s-mcp-codeact' }, h(McpCopy, { t, text: snip[tab] }))),
|
|
7734
|
+
!token && h('div', { className: 'n2s-faint n2s-small' }, t('mcp.placeholderHint')))
|
|
7735
|
+
}
|
|
7736
|
+
|
|
7737
|
+
function McpScopeLabel({ t, scope }) {
|
|
7738
|
+
if (scope.type === 'member') return h('span', null, h('span', { className: 'n2s-badge' }, t('mcp.scope.member')), ' ', scope.memberName ?? scope.memberId, scope.missing ? h('span', { className: 'n2s-badge warn' }, t('mcp.memberMissing')) : scope.disabled ? h('span', { className: 'n2s-badge warn' }, t('mcp.memberDisabled')) : null)
|
|
7739
|
+
if (scope.type === 'role') return h('span', null, h('span', { className: 'n2s-badge' }, t(`mcp.role.${scope.role}`)), ' ', scope.datasourceNames ? joinList(scope.datasourceNames) : t('mcp.allDs'), scope.rowRules?.length ? h('span', { className: 'n2s-faint' }, paren(fmt(t, 'mcp.rowRulesN', { n: scope.rowRules.length }))) : null)
|
|
7740
|
+
return h('span', { className: 'n2s-badge ok' }, t('mcp.scope.owner'))
|
|
7741
|
+
}
|
|
7742
|
+
|
|
7743
|
+
function McpTokenForm({ t, datasources, onCreated, onCancel }) {
|
|
7744
|
+
const users = useAsync(() => call('listUsers').catch(() => []), [])
|
|
7745
|
+
const [form, setForm] = useState({ name: '', type: 'owner', memberId: '', role: 'analyst', datasourceIds: [], rowColumn: '', rowValues: '' })
|
|
7746
|
+
const [busy, setBusy] = useState(false)
|
|
7747
|
+
const [err, setErr] = useState(null)
|
|
7748
|
+
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
|
|
7749
|
+
const members = (users.value ?? []).filter(u => !u.disabled)
|
|
7750
|
+
const submit = async () => {
|
|
7751
|
+
setErr(null)
|
|
7752
|
+
const scope = form.type === 'member' ? { memberId: form.memberId }
|
|
7753
|
+
: form.type === 'role' ? { role: form.role, datasourceIds: form.datasourceIds, ...(form.rowColumn.trim() && form.rowValues.trim() ? { rowRules: [{ column: form.rowColumn.trim(), values: form.rowValues }] } : {}) }
|
|
7754
|
+
: { kind: 'owner' }
|
|
7755
|
+
if (!form.name.trim()) return setErr(t('mcp.err.name'))
|
|
7756
|
+
if (form.type === 'member' && !form.memberId) return setErr(t('mcp.err.member'))
|
|
7757
|
+
if (form.type === 'role' && !form.datasourceIds.length) return setErr(t('mcp.err.ds'))
|
|
7758
|
+
setBusy(true)
|
|
7759
|
+
try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope })) } catch (e) { setErr(e.message) }
|
|
7760
|
+
setBusy(false)
|
|
7761
|
+
}
|
|
7762
|
+
return h('div', { className: 'n2s-card n2s-mcp-form' },
|
|
7763
|
+
h('div', { className: 'n2s-sechead-title', style: { marginBottom: 10 } }, t('mcp.newToken')),
|
|
7764
|
+
h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.tokenName')),
|
|
7765
|
+
h('input', { className: 'n2s-input', value: form.name, maxLength: 60, placeholder: t('mcp.tokenNamePh'), onChange: e => set('name', e.target.value) })),
|
|
7766
|
+
h('div', { className: 'n2s-field', role: 'group', 'aria-label': t('mcp.scope') },
|
|
7767
|
+
h('span', { className: 'n2s-flabel' }, t('mcp.scope')),
|
|
7768
|
+
h('div', { className: 'n2s-seg', role: 'radiogroup' }, ['owner', 'member', 'role'].map(k => h('button', { key: k, type: 'button', role: 'radio', 'aria-checked': form.type === k, 'aria-pressed': form.type === k, onClick: () => set('type', k) }, t(`mcp.scope.${k}`)))),
|
|
7769
|
+
h('span', { className: 'n2s-faint n2s-small' }, t(`mcp.scopeHint.${form.type}`))),
|
|
7770
|
+
form.type === 'member' && h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.member')),
|
|
7771
|
+
members.length
|
|
7772
|
+
? h('select', { className: 'n2s-select', value: form.memberId, onChange: e => set('memberId', e.target.value) },
|
|
7773
|
+
h('option', { value: '' }, t('mcp.pickMember')),
|
|
7774
|
+
members.map(u => h('option', { key: u.id, value: u.id }, `${u.name} · ${t(`mcp.role.${u.role}`)}`)))
|
|
7775
|
+
: h('span', { className: 'n2s-faint n2s-small' }, t('mcp.noMembers'))),
|
|
7776
|
+
form.type === 'role' && h(React.Fragment, null,
|
|
7777
|
+
h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.role')),
|
|
7778
|
+
h('select', { className: 'n2s-select', value: form.role, onChange: e => set('role', e.target.value) }, ['analyst', 'viewer', 'admin'].map(r => h('option', { key: r, value: r }, t(`mcp.role.${r}`))))),
|
|
7779
|
+
h('div', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.datasources')),
|
|
7780
|
+
h('div', { className: 'n2s-chips' }, (datasources ?? []).map(d => h('button', { key: d.id, type: 'button', className: 'n2s-chip', 'aria-pressed': form.datasourceIds.includes(d.id), onClick: () => set('datasourceIds', form.datasourceIds.includes(d.id) ? form.datasourceIds.filter(x => x !== d.id) : [...form.datasourceIds, d.id]) }, d.name)))),
|
|
7781
|
+
h('div', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.rowRule')),
|
|
7782
|
+
h('div', { className: 'n2s-row' },
|
|
7783
|
+
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
|
+
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
|
+
h('span', { className: 'n2s-faint n2s-small' }, t('mcp.rowRuleHint')))),
|
|
7786
|
+
err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
|
|
7787
|
+
h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
|
|
7788
|
+
h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
|
|
7789
|
+
h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: submit }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.create'))))
|
|
7790
|
+
}
|
|
7791
|
+
|
|
7792
|
+
function McpSection({ t, datasources }) {
|
|
7793
|
+
const st = useAsync(() => call('mcpStatus'), [])
|
|
7794
|
+
const [port, setPort] = useState('')
|
|
7795
|
+
const [busy, setBusy] = useState(false)
|
|
7796
|
+
const [creating, setCreating] = useState(false)
|
|
7797
|
+
const [fresh, setFresh] = useState(null) // { name, token } shown once
|
|
7798
|
+
useEffect(() => { if (st.value) setPort(String(st.value.port)) }, [st.value?.port])
|
|
7799
|
+
const s = st.value
|
|
7800
|
+
const when = (ms) => (ms ? new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : t('mcp.never'))
|
|
7801
|
+
const save = async (patch) => {
|
|
7802
|
+
setBusy(true)
|
|
7803
|
+
try { await call('saveMcpSettings', patch); st.reload() } catch (e) { notifyError(e) }
|
|
7804
|
+
setBusy(false)
|
|
7805
|
+
}
|
|
7806
|
+
const revoke = async (tok) => {
|
|
7807
|
+
if (!(await confirmDialog({ title: fmt(t, 'mcp.revokeConfirm', { name: tok.name }), message: t('mcp.revokeHint'), confirmLabel: t('mcp.revoke'), danger: true }))) return
|
|
7808
|
+
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
|
+
}
|
|
7810
|
+
if (!s) return st.error ? h('div', { className: 'n2s-notice err' }, st.error.message) : h('div', { className: 'n2s-skel', style: { height: 200 } })
|
|
7811
|
+
const portDirty = port !== String(s.port)
|
|
7812
|
+
return h('div', { className: 'n2s-mcp' },
|
|
7813
|
+
h(SectionHead, { title: t('mcp.title'), desc: t('mcp.desc') }),
|
|
7814
|
+
h('div', { className: 'n2s-card n2s-mcp-server' },
|
|
7815
|
+
h('div', { className: 'n2s-row', style: { flexWrap: 'wrap', gap: 14 } },
|
|
7816
|
+
h('label', { className: 'n2s-switch' },
|
|
7817
|
+
h('input', { type: 'checkbox', role: 'switch', checked: s.enabled, disabled: busy, onChange: e => save({ enabled: e.target.checked }) }),
|
|
7818
|
+
h('span', null, s.enabled ? t('mcp.enabled') : t('mcp.disabled'))),
|
|
7819
|
+
h('span', { className: `n2s-statusdot ${s.running ? 'ok' : s.error ? 'err' : ''}`, 'data-mcp-status': s.running ? 'running' : 'stopped' }, s.running ? t('mcp.running') : t('mcp.stopped')),
|
|
7820
|
+
h('span', { className: 'n2s-grow' }),
|
|
7821
|
+
h('label', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-flabel' }, t('mcp.port')),
|
|
7822
|
+
h('input', { className: 'n2s-input n2s-mono', style: { width: 96 }, inputMode: 'numeric', value: port, 'aria-label': t('mcp.port'), onChange: e => setPort(e.target.value.replace(/\D/g, '').slice(0, 5)) }),
|
|
7823
|
+
portDirty && h('button', { type: 'button', className: 'n2s-btn small', disabled: busy || !port, onClick: () => save({ port: Number(port) }) }, t('mcp.applyPort')))),
|
|
7824
|
+
s.error && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 10 } }, s.error),
|
|
7825
|
+
h('div', { className: 'n2s-mcp-endpoint' },
|
|
7826
|
+
h('span', { className: 'n2s-flabel' }, t('mcp.endpoint')),
|
|
7827
|
+
h('code', { className: 'n2s-mono', 'data-mcp-url': true }, s.url),
|
|
7828
|
+
h(McpCopy, { t, text: s.url })),
|
|
7829
|
+
h('div', { className: 'n2s-faint n2s-small' }, t('mcp.localOnly'))),
|
|
7830
|
+
fresh && h('div', { className: 'n2s-card n2s-mcp-fresh', role: 'status' },
|
|
7831
|
+
h('div', { className: 'n2s-sechead-title' }, fmt(t, 'mcp.freshTitle', { name: fresh.name })),
|
|
7832
|
+
h('div', { className: 'n2s-notice warn', style: { margin: '8px 0' } }, t('mcp.freshHint')),
|
|
7833
|
+
h('div', { className: 'n2s-mcp-endpoint' }, h('code', { className: 'n2s-mono', 'data-mcp-token': true }, fresh.token), h(McpCopy, { t, text: fresh.token })),
|
|
7834
|
+
h(McpSnippets, { t, url: s.url, token: fresh.token }),
|
|
7835
|
+
h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 8 } }, h('button', { type: 'button', className: 'n2s-btn small', onClick: () => setFresh(null) }, t('mcp.done')))),
|
|
7836
|
+
h('div', { className: 'n2s-row', style: { margin: '18px 0 8px' } },
|
|
7837
|
+
h('div', { className: 'n2s-sechead-title n2s-grow' }, t('mcp.tokens')),
|
|
7838
|
+
!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() } }),
|
|
7840
|
+
s.tokens.length === 0 && !creating
|
|
7841
|
+
? h('div', { className: 'n2s-empty' }, h('h3', null, t('mcp.noTokens')), h('div', null, t('mcp.noTokensHint')))
|
|
7842
|
+
: 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)))),
|
|
7844
|
+
h('tbody', null, s.tokens.map(tok => h('tr', { key: tok.id, 'data-token-id': tok.id },
|
|
7845
|
+
h('td', null, h('div', null, tok.name), h('div', { className: 'n2s-faint n2s-small n2s-mono' }, `${tok.prefix}…`)),
|
|
7846
|
+
h('td', null, h(McpScopeLabel, { t, scope: tok.scope })),
|
|
7847
|
+
h('td', { className: 'n2s-small' }, when(tok.createdAt)),
|
|
7848
|
+
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')))))))),
|
|
7850
|
+
h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.setup')),
|
|
7851
|
+
h('div', { className: 'n2s-card' }, h(McpSnippets, { t, url: s.url, token: null }),
|
|
7852
|
+
h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('mcp.docsHint'))))
|
|
7853
|
+
}
|
|
7854
|
+
|
|
7855
|
+
extend.adminSections.push({ id: 'mcp', label: (t) => t('mcp.nav'), icon: () => Icon.code(15), Component: McpSection })
|
|
5891
7856
|
// ------------------------------------------------------------ apply
|
|
5892
7857
|
|
|
5893
7858
|
return {
|
|
@@ -5902,6 +7867,9 @@ window.__ModuleLoader__.load({
|
|
|
5902
7867
|
}, 'nl2sql: styles')
|
|
5903
7868
|
ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
|
|
5904
7869
|
const t = ctx.locale.bind(NS)
|
|
7870
|
+
// One feedback layer (toasts + confirm / prompt dialogs) for the page and every
|
|
7871
|
+
// tool view or reply action in conversations, mounted once outside the page.
|
|
7872
|
+
ctx.effect(() => mountFeedbackLayer(ctx), 'nl2sql: feedback layer')
|
|
5905
7873
|
const Page = makePage(ctx)
|
|
5906
7874
|
const PanelIcon = ({ size }) => Icon.db(size ?? 16)
|
|
5907
7875
|
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))
|
|
@@ -5910,6 +7878,7 @@ window.__ModuleLoader__.load({
|
|
|
5910
7878
|
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_query', locale: NS }, makeToolView(ctx, 'query')))
|
|
5911
7879
|
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_chart', locale: NS }, makeToolView(ctx, 'chart')))
|
|
5912
7880
|
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_overview', locale: NS }, makeOverviewToolView(ctx)))
|
|
7881
|
+
for (const v of extend.toolViews) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: v.key, locale: NS }, v.make(ctx)))
|
|
5913
7882
|
ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'nl2sql-send', order: 20, locale: NS }, makeReplyAction(ctx)))
|
|
5914
7883
|
},
|
|
5915
7884
|
}
|