dsh-db-mcp 0.6.4

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/client.js ADDED
@@ -0,0 +1,2939 @@
1
+ /**
2
+ * dsh-db-mcp — Client half.
3
+ *
4
+ * A plain browser bundle loaded through window.__ModuleLoader__ (same shape as
5
+ * the skill's UI template): no imports of Harness client packages, no iframes,
6
+ * no DOM work outside the component tree, styles from --dsw-alias-* tokens only.
7
+ *
8
+ * The panel owns the data-source registry: create, edit, test, delete, and an
9
+ * importer that reads DBeaver's workspace files. Querying is never done here —
10
+ * the agent reaches these sources through the Python MCP server (P3).
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-db-mcp',
14
+ factory(require) {
15
+ 'use strict'
16
+
17
+ const React = require('react')
18
+ const h = React.createElement
19
+
20
+ const NS = 'db-mcp'
21
+ /** Top-level tabs of the panel. `sources` also hosts the create/edit/import/structure views. */
22
+ const TABS = [{ id: 'list' }, { id: 'create' }, { id: 'import' }, { id: 'runtime' }, { id: 'audit' }]
23
+
24
+ /** Tab labels stay literal so the i18n check can see every key. */
25
+ function tabLabel(t, id) {
26
+ if (id === 'create') return t('tab.create')
27
+ if (id === 'import') return t('tab.import')
28
+ if (id === 'runtime') return t('tab.runtime')
29
+ if (id === 'audit') return t('tab.audit')
30
+ return t('tab.list')
31
+ }
32
+
33
+ /** The tab that owns a view; the form and the schema browser live under a tab. */
34
+ function tabOfMode(mode) {
35
+ if (mode === 'create' || mode === 'edit') return 'create'
36
+ if (mode === 'import') return 'import'
37
+ if (mode === 'runtime') return 'runtime'
38
+ if (mode === 'audit') return 'audit'
39
+ return 'list'
40
+ }
41
+ const API = '/plugins/db-mcp/api'
42
+ const KINDS = ['sqlite', 'postgres', 'mysql', 'oracle']
43
+ const DEFAULT_PORTS = { postgres: '5432', mysql: '3306', oracle: '1521' }
44
+
45
+ /** Set by apply(); bind() reads the active locale at call time, so only the
46
+ * re-render subscription has to live on the component side. */
47
+ let localeService = null
48
+ let layoutService = null
49
+
50
+ const DICT = {
51
+ en: {
52
+ 'panel.title': 'Database',
53
+ 'panel.subtitle': 'Manage the database connections this agent can reach through MCP tools.',
54
+ 'action.refresh': 'Refresh',
55
+ 'action.restartMcp': 'Restart MCP',
56
+ 'action.restartMcpHint': 'Restart the Python MCP server and re-publish its tools',
57
+ 'action.new': 'New data source',
58
+ 'action.import': 'Import from DBeaver',
59
+ 'action.save': 'Save',
60
+ 'action.saving': 'Saving…',
61
+ 'action.edit': 'Edit',
62
+ 'action.test': 'Test',
63
+ 'action.testing': 'Testing…',
64
+ 'action.delete': 'Delete',
65
+ 'action.deleteConfirm': 'Delete for real',
66
+ 'action.makeDefault': 'Make default',
67
+ 'action.importSelected': 'Import selected',
68
+ 'action.importing': 'Importing…',
69
+ 'state.connected': 'connected',
70
+ 'state.failed': 'failed',
71
+ 'state.loading': 'checking…',
72
+ 'python.missing': 'no python on PATH',
73
+ 'loading': 'Checking the Host…',
74
+ 'error.title': 'Host API unreachable',
75
+ 'error.retry': 'Retry',
76
+ 'notice.saved': 'Saved {name}.',
77
+ 'notice.deleted': 'Removed {name}.',
78
+ 'notice.default': '{name} is now the default source.',
79
+ 'notice.imported': 'Imported {n} data source(s).',
80
+ 'notice.importedSkipped': 'Imported {n} data source(s); skipped {m} already configured.',
81
+ 'notice.probeOk': '{name}: connection OK ({ms} ms).',
82
+ 'notice.probeFail': '{name}: {message}',
83
+ 'notice.mcpReady': 'MCP server is up with {count} tool(s).',
84
+ 'sources.count': '{n} configured',
85
+ 'sources.empty.title': 'No data source yet',
86
+ 'sources.empty.body': 'Add one here, or import the connections DBeaver already keeps on this machine. The agent queries real data through MCP tools, never through this page.',
87
+ 'sources.readonly': 'read-only',
88
+ 'sources.readwrite': 'writable',
89
+ 'sources.default': 'default',
90
+ 'sources.hasPassword': 'password saved',
91
+ 'sources.noPassword': 'no password',
92
+ 'sources.neverProbed': 'never tested',
93
+ 'form.edit': 'Edit {name}',
94
+ 'form.name': 'Display name',
95
+ 'form.id': 'Identifier',
96
+ 'form.idHint': 'lowercase letters, digits and dashes; used by MCP tools and never changes',
97
+ 'form.paste': 'Connection string',
98
+ 'form.pasteHintShort':
99
+ 'Paste a JDBC URL, a DSN or the path of a .db file. Type, host, port, database and the identifier are derived from it.',
100
+ 'form.savePassword': 'Save password',
101
+ 'form.more': 'More settings',
102
+ 'form.pasteHintEdit': 'Paste a new connection string to overwrite the fields below; the identifier stays fixed.',
103
+ 'form.pastePlaceholder': 'postgresql://user:pass@localhost:5432/app',
104
+ 'form.parseOk': 'recognized',
105
+ 'form.parseBad': 'not recognized',
106
+ 'form.optional': 'optional',
107
+ 'form.sidHint': 'only for the old SID style; wins over the service name when filled',
108
+ 'form.untitled': 'Untitled source',
109
+ 'form.kind': 'Type',
110
+ 'form.file': 'SQLite file',
111
+ 'form.fileHint': 'absolute path, for example C:\\data\\app.db',
112
+ 'form.host': 'Host',
113
+ 'form.port': 'Port',
114
+ 'form.database': 'Database',
115
+ 'form.user': 'User',
116
+ 'form.schema': 'Default schema',
117
+ 'form.schemaHint': "Empty uses the connection user's own schema.",
118
+ 'form.charset': 'Charset',
119
+ 'form.sslMode': 'SSL mode',
120
+ 'form.serviceName': 'Service name',
121
+ 'form.sid': 'SID',
122
+ 'form.password': 'Password',
123
+ 'form.passwordKeep': 'leave blank to keep the stored password',
124
+ 'form.passwordEmpty': 'no password stored yet',
125
+ 'form.readOnly': 'Read-only (recommended)',
126
+ 'form.readOnlyHint': 'blocks writes for this source unless the global write switch is on',
127
+ 'form.invalid': 'Fill the required fields first.',
128
+ 'import.body': 'Reads data-sources.json from the DBeaver workspace and decrypts credentials-config.json when it exists.',
129
+ 'import.empty': 'No DBeaver connection found on this machine.',
130
+ 'import.file': 'File',
131
+ 'import.credentialsFound': 'found and decrypted',
132
+ 'import.already': 'already added',
133
+ 'import.alreadyAs': 'already added as {name}',
134
+ 'import.credentialsMissing': 'not present — the password has to be typed in manually later',
135
+ 'diag.title': 'Runtime diagnostics',
136
+ 'diag.hostApi': 'Host API',
137
+ 'diag.node': 'Node',
138
+ 'diag.plugin': 'Plugin',
139
+ 'diag.python': 'Python',
140
+ 'diag.runtimeDir': 'Runtime dir',
141
+ 'diag.registry': 'Registry file',
142
+ 'diag.sources': 'Data sources',
143
+ 'diag.mcp': 'MCP server',
144
+ 'diag.mcpPending': 'not wired yet',
145
+ 'diag.mcpLive': '{count} tool(s) live',
146
+ 'diag.mcpFailed': 'failed:',
147
+ 'diag.uptime': 'Host uptime',
148
+ 'diag.tip': 'Tip',
149
+ 'diag.restartNeeded': 'Restart the DSH desktop app once — the plugin entry is imported at process start, so the tool bridge only activates after a restart.',
150
+ 'diag.write': 'Global write access',
151
+ 'diag.writeOn': 'on — a source must still allow writes itself',
152
+ 'diag.writeOff': 'off — every write statement is refused',
153
+ 'diag.venv': 'Python venv',
154
+ 'diag.packages': 'Python packages',
155
+ 'diag.venvMissing': 'not created yet',
156
+ 'diag.venvHint': 'One click creates <runtime>/.venv and installs server/requirements.txt with that interpreter.',
157
+ 'action.installDeps': 'Install / repair deps',
158
+ 'action.installing': 'Installing…',
159
+ 'notice.depsOk': 'Dependencies are installed.',
160
+ 'notice.depsFail': 'Dependency install failed — see the diagnostics output.',
161
+ 'notice.writeOn': 'Global writes are on. Sources still stay read-only until you clear their own flag.',
162
+ 'notice.writeOff': 'Global writes are off. Every write statement is refused.',
163
+ 'audit.title': 'Audit log',
164
+ 'audit.empty': 'No tool call has been recorded yet.',
165
+ 'audit.file': 'Log file',
166
+ 'audit.ok': 'ok',
167
+ 'audit.err': 'failed',
168
+ 'audit.hint': 'One line per MCP tool call: tool, source, duration and outcome. Query results are never logged.',
169
+ 'audit.rows': '{n} row(s)',
170
+ 'action.schema': 'Structure',
171
+ 'action.backToSources': 'Data sources',
172
+ 'tab.list': 'Data source list',
173
+ 'tab.create': 'New data source',
174
+ 'tab.import': 'Import from DBeaver',
175
+ 'tab.runtime': 'Runtime',
176
+ 'tab.audit': 'Audit log',
177
+ 'header.backToChat': 'Back to chat',
178
+ 'form.group': 'Group',
179
+ 'form.groupHint': 'Sources sharing a group are listed together',
180
+ 'group.unassigned': 'Ungrouped',
181
+ 'group.count': '{n}',
182
+ 'group.new': 'New group',
183
+ 'group.newPlaceholder': 'Group name',
184
+ 'group.add': 'Add group',
185
+ 'group.rename': 'Rename',
186
+ 'group.save': 'Save',
187
+ 'group.cancel': 'Cancel',
188
+ 'group.delete': 'Delete',
189
+ 'group.deleteConfirm': 'Delete group?',
190
+ 'group.selectAll': 'Select every source in this group',
191
+ 'group.dragHint': 'Drag onto a group to move it',
192
+ 'group.empty': 'Drop a source here',
193
+ 'bulk.selected': '{n} selected',
194
+ 'bulk.test': 'Test selected',
195
+ 'bulk.readonly': 'Make read-only',
196
+ 'bulk.readwrite': 'Allow writes',
197
+ 'bulk.group': 'Move to group',
198
+ 'bulk.groupPlaceholder': 'group name (empty clears it)',
199
+ 'bulk.apply': 'Apply',
200
+ 'bulk.delete': 'Delete selected',
201
+ 'bulk.deleteConfirm': 'Confirm delete {n}',
202
+ 'bulk.clear': 'Clear',
203
+ 'notice.bulkDone': 'Updated {n} data source(s).',
204
+ 'notice.bulkGrouped': 'Moved {n} data source(s) into “{group}”.',
205
+ 'notice.groupSaved': 'Group “{name}” saved.',
206
+ 'notice.groupDeleted': 'Group “{name}” deleted.',
207
+ 'notice.moved': 'Moved into “{group}”.',
208
+ 'schema.title': 'Structure of {name}',
209
+ 'schema.tables': 'Tables ({n})',
210
+ 'schema.pick': 'Pick a table on the left to see its columns.',
211
+ 'schema.empty': 'This source reports no table.',
212
+ 'schema.loading': 'Reading the catalog…',
213
+ 'schema.error': 'Could not read the catalog.',
214
+ 'schema.column': 'Column',
215
+ 'schema.type': 'Type',
216
+ 'schema.nullable': 'Null',
217
+ 'schema.default': 'Default',
218
+ 'schema.pk': 'PK',
219
+ 'schema.yes': 'yes',
220
+ 'schema.no': 'no',
221
+ 'schema.columns': '{n} column(s)',
222
+ 'schema.readonlyNote': 'Read-only view: this page never runs SQL.',
223
+ },
224
+ zh: {
225
+ 'panel.title': '数据库',
226
+ 'panel.subtitle': '管理 AI 可以通过 MCP 工具访问的数据库连接。',
227
+ 'action.refresh': '刷新',
228
+ 'action.restartMcp': '重启 MCP',
229
+ 'action.restartMcpHint': '重启 Python MCP 服务并重新发布工具',
230
+ 'action.new': '新建数据源',
231
+ 'action.import': '从 DBeaver 导入',
232
+ 'action.save': '保存',
233
+ 'action.saving': '保存中…',
234
+ 'action.edit': '编辑',
235
+ 'action.test': '测试',
236
+ 'action.testing': '测试中…',
237
+ 'action.delete': '删除',
238
+ 'action.deleteConfirm': '确认删除',
239
+ 'action.makeDefault': '设为默认',
240
+ 'action.importSelected': '导入所选',
241
+ 'action.importing': '导入中…',
242
+ 'state.connected': '已连接',
243
+ 'state.failed': '失败',
244
+ 'state.loading': '检查中…',
245
+ 'python.missing': 'PATH 上未找到 python',
246
+ 'loading': '正在检查 Host…',
247
+ 'error.title': '无法访问 Host 接口',
248
+ 'error.retry': '重试',
249
+ 'notice.saved': '已保存 {name}。',
250
+ 'notice.deleted': '已删除 {name}。',
251
+ 'notice.default': '{name} 已设为默认数据源。',
252
+ 'notice.imported': '已导入 {n} 个数据源。',
253
+ 'notice.importedSkipped': '已导入 {n} 个数据源,跳过 {m} 个已存在的。',
254
+ 'notice.probeOk': '{name}:连接正常({ms} ms)。',
255
+ 'notice.probeFail': '{name}:{message}',
256
+ 'notice.mcpReady': 'MCP 服务已就绪,共 {count} 个工具。',
257
+ 'sources.count': '已配置 {n} 个',
258
+ 'sources.empty.title': '还没有数据源',
259
+ 'sources.empty.body': '可以在这里新增,也可以直接导入这台机器上 DBeaver 已有的连接。AI 通过 MCP 工具查询真实数据,不会在这个页面里执行 SQL。',
260
+ 'sources.readonly': '只读',
261
+ 'sources.readwrite': '可写',
262
+ 'sources.default': '默认',
263
+ 'sources.hasPassword': '已存密码',
264
+ 'sources.noPassword': '无密码',
265
+ 'sources.neverProbed': '尚未测试',
266
+ 'form.edit': '编辑 {name}',
267
+ 'form.name': '显示名称',
268
+ 'form.id': '标识符',
269
+ 'form.idHint': '小写字母、数字和短横线,供 MCP 工具引用,创建后不可修改',
270
+ 'form.paste': '连接串',
271
+ 'form.pasteHintShort': '粘贴 JDBC URL、DSN 或 .db 文件路径,类型、主机、端口、库名和标识符都会自动带出。',
272
+ 'form.savePassword': '保存密码',
273
+ 'form.more': '更多设置',
274
+ 'form.pasteHintEdit': '粘贴新的连接串会覆盖下面的字段;标识符保持不变。',
275
+ 'form.pastePlaceholder': 'postgresql://用户:密码@localhost:5432/库名',
276
+ 'form.parseOk': '已识别',
277
+ 'form.parseBad': '无法识别',
278
+ 'form.optional': '可选',
279
+ 'form.sidHint': '仅老式 SID 连接需要;填了会优先用 SID',
280
+ 'form.untitled': '未命名数据源',
281
+ 'form.kind': '类型',
282
+ 'form.file': 'SQLite 文件',
283
+ 'form.fileHint': '绝对路径,例如 C:\\data\\app.db',
284
+ 'form.host': '主机',
285
+ 'form.port': '端口',
286
+ 'form.database': '数据库',
287
+ 'form.user': '用户',
288
+ 'form.schema': '默认模式',
289
+ 'form.schemaHint': '不填则用连接用户自己的模式(Oracle 里模式就是用户)',
290
+ 'form.charset': '字符集',
291
+ 'form.sslMode': 'SSL 模式',
292
+ 'form.serviceName': '服务名',
293
+ 'form.sid': 'SID',
294
+ 'form.password': '口令',
295
+ 'form.passwordKeep': '留空表示保留已存口令',
296
+ 'form.passwordEmpty': '尚未保存口令',
297
+ 'form.readOnly': '只读(推荐)',
298
+ 'form.readOnlyHint': '未打开全局写开关时,此数据源拒绝写操作',
299
+ 'form.invalid': '请先填写必填字段。',
300
+ 'import.body': '读取 DBeaver 工作区里的 data-sources.json;存在 credentials-config.json 时一并解密口令。',
301
+ 'import.empty': '这台机器上没有找到 DBeaver 连接。',
302
+ 'import.file': '文件',
303
+ 'import.credentialsFound': '已找到并解密',
304
+ 'import.already': '已添加',
305
+ 'import.alreadyAs': '已作为 {name} 添加',
306
+ 'import.credentialsMissing': '不存在——口令需要稍后手动填写',
307
+ 'diag.title': '运行时诊断',
308
+ 'diag.hostApi': 'Host 接口',
309
+ 'diag.node': 'Node',
310
+ 'diag.plugin': '插件',
311
+ 'diag.python': 'Python',
312
+ 'diag.runtimeDir': '运行时目录',
313
+ 'diag.registry': '注册表文件',
314
+ 'diag.sources': '数据源数量',
315
+ 'diag.mcp': 'MCP 服务名',
316
+ 'diag.mcpPending': '尚未接入',
317
+ 'diag.mcpLive': '{count} 个工具已就绪',
318
+ 'diag.mcpFailed': '启动失败:',
319
+ 'diag.uptime': 'Host 已运行',
320
+ 'diag.tip': '提示',
321
+ 'diag.restartNeeded': '需要重启一次 DSH 桌面应用:插件入口只在进程启动时导入,重启后工具桥接才会生效。',
322
+ 'diag.write': '全局写权限',
323
+ 'diag.writeOn': '已开启(单个数据源仍须自己允许写)',
324
+ 'diag.writeOff': '已关闭(所有写语句都会被拒绝)',
325
+ 'diag.venv': 'Python 虚拟环境',
326
+ 'diag.packages': 'Python 依赖',
327
+ 'diag.venvMissing': '尚未创建',
328
+ 'diag.venvHint': '一键在 <运行时目录>/.venv 建虚拟环境,并用它安装 server/requirements.txt。',
329
+ 'action.installDeps': '安装 / 修复依赖',
330
+ 'action.installing': '正在安装…',
331
+ 'notice.depsOk': '依赖已安装。',
332
+ 'notice.depsFail': '依赖安装失败,见诊断信息。',
333
+ 'notice.writeOn': '全局写权限已开启。数据源仍保持只读,直到你取消它自己的只读勾选。',
334
+ 'notice.writeOff': '全局写权限已关闭。所有写语句都会被拒绝。',
335
+ 'audit.title': '审计日志',
336
+ 'audit.empty': '还没有工具调用记录。',
337
+ 'audit.file': '日志文件',
338
+ 'audit.ok': '成功',
339
+ 'audit.err': '失败',
340
+ 'audit.hint': '每次 MCP 工具调用记一行:工具、数据源、耗时与结果。查询结果不会被记录。',
341
+ 'audit.rows': '{n} 行',
342
+ 'action.schema': '结构',
343
+ 'action.backToSources': '数据源',
344
+ 'tab.list': '数据源列表',
345
+ 'tab.create': '新建数据源',
346
+ 'tab.import': '从 DBeaver 导入',
347
+ 'tab.runtime': '运行时诊断',
348
+ 'tab.audit': '审计日志',
349
+ 'header.backToChat': '返回会话',
350
+ 'form.group': '分组',
351
+ 'form.groupHint': '同一分组的数据源会列在一起',
352
+ 'group.unassigned': '未分组',
353
+ 'group.count': '{n} 个',
354
+ 'group.new': '新建分组',
355
+ 'group.newPlaceholder': '分组名称',
356
+ 'group.add': '新建',
357
+ 'group.rename': '重命名',
358
+ 'group.save': '保存',
359
+ 'group.cancel': '取消',
360
+ 'group.delete': '删除',
361
+ 'group.deleteConfirm': '确认删除分组?',
362
+ 'group.selectAll': '全选该分组的数据源',
363
+ 'group.dragHint': '拖到分组上即可移动',
364
+ 'group.empty': '把数据源拖到这里',
365
+ 'bulk.selected': '已选 {n} 个',
366
+ 'bulk.test': '批量测试',
367
+ 'bulk.readonly': '设为只读',
368
+ 'bulk.readwrite': '允许写',
369
+ 'bulk.group': '移动到分组',
370
+ 'bulk.groupPlaceholder': '分组名(留空表示清除)',
371
+ 'bulk.apply': '应用',
372
+ 'bulk.delete': '删除所选',
373
+ 'bulk.deleteConfirm': '确认删除 {n} 个',
374
+ 'bulk.clear': '清空',
375
+ 'notice.bulkDone': '已更新 {n} 个数据源。',
376
+ 'notice.bulkGrouped': '已把 {n} 个数据源移动到「{group}」。',
377
+ 'notice.groupSaved': '已保存分组「{name}」。',
378
+ 'notice.groupDeleted': '已删除分组「{name}」。',
379
+ 'notice.moved': '已移动到「{group}」。',
380
+ 'schema.title': '{name} 的结构',
381
+ 'schema.tables': '表({n})',
382
+ 'schema.pick': '在左侧选一张表查看它的列。',
383
+ 'schema.empty': '这个数据源没有返回任何表。',
384
+ 'schema.loading': '正在读取结构…',
385
+ 'schema.error': '读取结构失败。',
386
+ 'schema.column': '列名',
387
+ 'schema.type': '类型',
388
+ 'schema.nullable': '可空',
389
+ 'schema.default': '默认值',
390
+ 'schema.pk': '主键',
391
+ 'schema.yes': '是',
392
+ 'schema.no': '否',
393
+ 'schema.columns': '{n} 列',
394
+ 'schema.readonlyNote': '只读视图:这个页面不会执行任何 SQL。',
395
+ },
396
+ }
397
+
398
+ const CSS = `
399
+ .dbmcp-page {
400
+ height: 100%;
401
+ overflow: auto;
402
+ box-sizing: border-box;
403
+ padding: 24px 28px 40px;
404
+ color: var(--dsw-alias-label-primary);
405
+ background: var(--dsw-alias-bg-base);
406
+ font: inherit;
407
+ }
408
+ .dbmcp-head h1 {
409
+ margin: 0;
410
+ font-size: 18px;
411
+ font-weight: 600;
412
+ line-height: 1.4;
413
+ letter-spacing: 0.01em;
414
+ }
415
+ .dbmcp-masthead {
416
+ display: flex;
417
+ align-items: baseline;
418
+ flex-wrap: wrap;
419
+ gap: 10px;
420
+ margin: 0 0 4px;
421
+ }
422
+ .dbmcp-masthead h1 {
423
+ margin: 0;
424
+ font-size: 19px;
425
+ font-weight: 600;
426
+ letter-spacing: 0.01em;
427
+ color: var(--dsw-alias-label-primary);
428
+ }
429
+ .dbmcp-masthead-spacer {
430
+ flex: 1 1 auto;
431
+ }
432
+ .dbmcp-back {
433
+ appearance: none;
434
+ display: inline-flex;
435
+ align-items: center;
436
+ gap: 4px;
437
+ border: 0;
438
+ background: none;
439
+ padding: 2px 6px 2px 0;
440
+ font: inherit;
441
+ font-size: 13px;
442
+ color: var(--dsw-alias-label-secondary);
443
+ cursor: pointer;
444
+ border-radius: 6px;
445
+ }
446
+ .dbmcp-back:hover {
447
+ color: var(--dsw-alias-label-primary);
448
+ }
449
+ .dbmcp-back-caret {
450
+ font-size: 15px;
451
+ line-height: 1;
452
+ }
453
+ .dbmcp-tabs {
454
+ display: flex;
455
+ align-items: stretch;
456
+ gap: 22px;
457
+ margin: 14px 0 0;
458
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
459
+ }
460
+ .dbmcp-tab {
461
+ appearance: none;
462
+ border: 0;
463
+ background: none;
464
+ padding: 0 0 9px;
465
+ margin-bottom: -1px;
466
+ font: inherit;
467
+ font-size: 13px;
468
+ line-height: 1.4;
469
+ color: var(--dsw-alias-label-secondary);
470
+ cursor: pointer;
471
+ border-bottom: 2px solid transparent;
472
+ white-space: nowrap;
473
+ }
474
+ .dbmcp-tab:hover {
475
+ color: var(--dsw-alias-label-primary);
476
+ }
477
+ .dbmcp-tab.is-active {
478
+ color: var(--dsw-alias-label-primary);
479
+ font-weight: 500;
480
+ border-bottom-color: var(--dsw-alias-brand-primary);
481
+ }
482
+ .dbmcp-sub {
483
+ margin: 6px 0 0;
484
+ font-size: 13px;
485
+ line-height: 1.6;
486
+ color: var(--dsw-alias-label-secondary);
487
+ }
488
+ .dbmcp-toolbar {
489
+ display: flex;
490
+ align-items: center;
491
+ flex-wrap: wrap;
492
+ gap: 10px;
493
+ margin: 16px 0 18px;
494
+ }
495
+ .dbmcp-crumbs {
496
+ display: flex;
497
+ align-items: center;
498
+ flex-wrap: wrap;
499
+ gap: 8px;
500
+ margin: 14px 0 0;
501
+ font-size: 13px;
502
+ }
503
+ .dbmcp-crumb {
504
+ appearance: none;
505
+ border: 0;
506
+ background: none;
507
+ padding: 4px 2px;
508
+ font: inherit;
509
+ color: var(--dsw-alias-brand-primary);
510
+ cursor: pointer;
511
+ border-radius: 4px;
512
+ border-bottom: 2px solid transparent;
513
+ }
514
+ .dbmcp-crumb:hover {
515
+ text-decoration: underline;
516
+ }
517
+ .dbmcp-crumb.is-current {
518
+ color: var(--dsw-alias-label-primary);
519
+ cursor: default;
520
+ font-weight: 500;
521
+ text-decoration: none;
522
+ border-bottom-color: var(--dsw-alias-brand-primary);
523
+ }
524
+ .dbmcp-crumb-sep {
525
+ color: var(--dsw-alias-label-secondary);
526
+ }
527
+ .dbmcp-title-switch {
528
+ appearance: none;
529
+ border: 0;
530
+ background: none;
531
+ padding: 0;
532
+ font: inherit;
533
+ color: inherit;
534
+ cursor: pointer;
535
+ border-radius: 4px;
536
+ }
537
+ .dbmcp-title-switch:hover {
538
+ color: var(--dsw-alias-brand-primary);
539
+ }
540
+ .dbmcp-schema-head {
541
+ margin-bottom: 10px;
542
+ }
543
+ .dbmcp-btn {
544
+ appearance: none;
545
+ border: 1px solid var(--dsw-alias-border-l1);
546
+ border-radius: 8px;
547
+ background: var(--dsw-alias-bg-layer-2);
548
+ color: var(--dsw-alias-label-primary);
549
+ font: inherit;
550
+ font-size: 13px;
551
+ padding: 6px 14px;
552
+ cursor: pointer;
553
+ }
554
+ .dbmcp-btn:hover {
555
+ border-color: var(--dsw-alias-border-l2);
556
+ }
557
+ .dbmcp-btn:disabled {
558
+ opacity: 0.6;
559
+ cursor: default;
560
+ }
561
+ .dbmcp-btn.is-primary {
562
+ border-color: var(--dsw-alias-brand-primary);
563
+ color: var(--dsw-alias-brand-primary);
564
+ }
565
+ .dbmcp-btn.is-danger {
566
+ border-color: var(--dsw-alias-state-error-primary);
567
+ color: var(--dsw-alias-state-error-primary);
568
+ }
569
+ .dbmcp-btn.is-small {
570
+ font-size: 12px;
571
+ padding: 3px 9px;
572
+ border-radius: 7px;
573
+ }
574
+ .dbmcp-pill {
575
+ display: inline-flex;
576
+ align-items: center;
577
+ gap: 6px;
578
+ border: 1px solid var(--dsw-alias-border-l1);
579
+ border-radius: 999px;
580
+ padding: 2px 9px;
581
+ font-size: 12px;
582
+ color: var(--dsw-alias-label-secondary);
583
+ }
584
+ .dbmcp-grid {
585
+ display: grid;
586
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
587
+ gap: 16px;
588
+ align-items: start;
589
+ }
590
+ .dbmcp-grid.is-single {
591
+ grid-template-columns: minmax(0, 1fr);
592
+ }
593
+ .dbmcp-card {
594
+ border: 1px solid var(--dsw-alias-border-l1);
595
+ border-radius: 12px;
596
+ background: var(--dsw-alias-bg-layer-1);
597
+ padding: 16px 18px 18px;
598
+ min-width: 0;
599
+ }
600
+ .dbmcp-audit-list {
601
+ display: flex;
602
+ flex-direction: column;
603
+ gap: 6px;
604
+ }
605
+ .dbmcp-audit {
606
+ display: flex;
607
+ flex-wrap: wrap;
608
+ align-items: center;
609
+ gap: 8px;
610
+ padding: 8px 0;
611
+ border-top: 1px solid var(--dsw-alias-border-l1);
612
+ font-size: 13px;
613
+ min-width: 0;
614
+ }
615
+ .dbmcp-audit:first-child {
616
+ border-top: none;
617
+ padding-top: 0;
618
+ }
619
+ .dbmcp-audit-time {
620
+ color: var(--dsw-alias-label-secondary);
621
+ font-variant-numeric: tabular-nums;
622
+ font-size: 12px;
623
+ }
624
+ .dbmcp-audit-tool {
625
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
626
+ font-size: 12px;
627
+ color: var(--dsw-alias-label-primary);
628
+ }
629
+ .dbmcp-audit-ok {
630
+ font-size: 12px;
631
+ }
632
+ .dbmcp-audit-ok.is-ok {
633
+ color: var(--dsw-alias-state-success-primary);
634
+ }
635
+ .dbmcp-audit-ok.is-error {
636
+ color: var(--dsw-alias-state-error-primary);
637
+ }
638
+ .dbmcp-audit-sql {
639
+ display: block;
640
+ width: 100%;
641
+ white-space: pre-wrap;
642
+ overflow-wrap: anywhere;
643
+ font-size: 12px;
644
+ color: var(--dsw-alias-label-secondary);
645
+ }
646
+ .dbmcp-audit-sql.is-error {
647
+ color: var(--dsw-alias-state-error-primary);
648
+ }
649
+ .dbmcp-schema-wrap {
650
+ display: flex;
651
+ flex-direction: column;
652
+ gap: 12px;
653
+ }
654
+ .dbmcp-schema {
655
+ display: grid;
656
+ grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
657
+ gap: 18px;
658
+ align-items: start;
659
+ }
660
+ .dbmcp-schema-side {
661
+ display: flex;
662
+ flex-direction: column;
663
+ gap: 8px;
664
+ min-width: 0;
665
+ }
666
+ .dbmcp-schema-main {
667
+ min-width: 0;
668
+ }
669
+ .dbmcp-schema-head {
670
+ display: flex;
671
+ align-items: baseline;
672
+ gap: 10px;
673
+ font-size: 12px;
674
+ font-weight: 600;
675
+ color: var(--dsw-alias-label-secondary);
676
+ }
677
+ .dbmcp-schema-tables {
678
+ list-style: none;
679
+ margin: 0;
680
+ padding: 0;
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: 2px;
684
+ max-height: 420px;
685
+ overflow: auto;
686
+ }
687
+ .dbmcp-schema-table {
688
+ display: flex;
689
+ align-items: center;
690
+ justify-content: space-between;
691
+ gap: 8px;
692
+ width: 100%;
693
+ padding: 6px 8px;
694
+ border: 1px solid transparent;
695
+ border-radius: 6px;
696
+ background: transparent;
697
+ color: var(--dsw-alias-label-primary);
698
+ font: inherit;
699
+ font-size: 13px;
700
+ text-align: left;
701
+ cursor: pointer;
702
+ }
703
+ .dbmcp-schema-table:hover {
704
+ background: var(--dsw-alias-bg-layer-2);
705
+ }
706
+ .dbmcp-schema-table.is-active {
707
+ border-color: var(--dsw-alias-brand-primary);
708
+ background: var(--dsw-alias-bg-layer-2);
709
+ }
710
+ .dbmcp-schema-tablegrid {
711
+ width: 100%;
712
+ border-collapse: collapse;
713
+ font-size: 13px;
714
+ margin-top: 6px;
715
+ }
716
+ .dbmcp-schema-tablegrid th,
717
+ .dbmcp-schema-tablegrid td {
718
+ text-align: left;
719
+ padding: 6px 8px;
720
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
721
+ vertical-align: top;
722
+ }
723
+ .dbmcp-schema-tablegrid th {
724
+ font-weight: 600;
725
+ font-size: 12px;
726
+ color: var(--dsw-alias-label-secondary);
727
+ }
728
+ .dbmcp-schema-note {
729
+ margin-top: 10px;
730
+ }
731
+ .dbmcp-mono {
732
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
733
+ font-size: 12px;
734
+ }
735
+ .dbmcp-card-head {
736
+ display: flex;
737
+ align-items: center;
738
+ justify-content: space-between;
739
+ gap: 10px;
740
+ margin-bottom: 12px;
741
+ }
742
+ .dbmcp-card-title {
743
+ margin: 0;
744
+ font-size: 13px;
745
+ font-weight: 600;
746
+ }
747
+ .dbmcp-empty {
748
+ padding: 26px 10px;
749
+ text-align: center;
750
+ color: var(--dsw-alias-label-secondary);
751
+ font-size: 13px;
752
+ line-height: 1.7;
753
+ }
754
+ .dbmcp-empty strong {
755
+ display: block;
756
+ margin-bottom: 6px;
757
+ color: var(--dsw-alias-label-primary);
758
+ font-weight: 600;
759
+ }
760
+ .dbmcp-empty p {
761
+ margin: 0 auto;
762
+ max-width: 34em;
763
+ }
764
+ .dbmcp-rows {
765
+ display: grid;
766
+ grid-template-columns: 104px minmax(0, 1fr);
767
+ row-gap: 10px;
768
+ column-gap: 12px;
769
+ font-size: 13px;
770
+ line-height: 1.5;
771
+ }
772
+ .dbmcp-k {
773
+ color: var(--dsw-alias-label-secondary);
774
+ }
775
+ .dbmcp-v {
776
+ color: var(--dsw-alias-label-primary);
777
+ word-break: break-all;
778
+ }
779
+ .dbmcp-dot {
780
+ display: inline-block;
781
+ width: 6px;
782
+ height: 6px;
783
+ border-radius: 50%;
784
+ margin-right: 6px;
785
+ background: var(--dsw-alias-state-idle-primary);
786
+ }
787
+ .dbmcp-dot.is-ok {
788
+ background: var(--dsw-alias-state-success-primary);
789
+ }
790
+ .dbmcp-dot.is-error {
791
+ background: var(--dsw-alias-state-error-primary);
792
+ }
793
+ .dbmcp-error {
794
+ border: 1px solid var(--dsw-alias-state-error-primary);
795
+ border-radius: 12px;
796
+ padding: 16px 18px;
797
+ color: var(--dsw-alias-label-primary);
798
+ font-size: 13px;
799
+ line-height: 1.6;
800
+ }
801
+ .dbmcp-error code {
802
+ display: block;
803
+ margin: 8px 0 12px;
804
+ color: var(--dsw-alias-label-secondary);
805
+ font-size: 12px;
806
+ word-break: break-all;
807
+ }
808
+ .dbmcp-list {
809
+ display: flex;
810
+ flex-direction: column;
811
+ gap: 8px;
812
+ margin: 0;
813
+ padding: 0;
814
+ list-style: none;
815
+ }
816
+ .dbmcp-item {
817
+ display: flex;
818
+ align-items: flex-start;
819
+ justify-content: space-between;
820
+ gap: 12px;
821
+ border: 1px solid var(--dsw-alias-border-l1);
822
+ border-radius: 10px;
823
+ background: var(--dsw-alias-bg-layer-2);
824
+ padding: 10px 12px;
825
+ font-size: 13px;
826
+ }
827
+ .dbmcp-item.is-selected {
828
+ border-color: var(--dsw-alias-brand-primary);
829
+ background: var(--dsw-alias-bg-layer-1);
830
+ }
831
+ .dbmcp-item-pick {
832
+ display: flex;
833
+ align-items: center;
834
+ padding-top: 2px;
835
+ cursor: pointer;
836
+ }
837
+ .dbmcp-item-pick input {
838
+ width: 14px;
839
+ height: 14px;
840
+ accent-color: var(--dsw-alias-brand-primary);
841
+ cursor: pointer;
842
+ }
843
+ .dbmcp-groups {
844
+ display: flex;
845
+ flex-direction: column;
846
+ gap: 16px;
847
+ }
848
+ .dbmcp-group {
849
+ display: flex;
850
+ flex-direction: column;
851
+ gap: 8px;
852
+ }
853
+ .dbmcp-group-head {
854
+ display: flex;
855
+ align-items: baseline;
856
+ gap: 8px;
857
+ padding: 0 2px;
858
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
859
+ padding-bottom: 6px;
860
+ }
861
+ .dbmcp-group-name {
862
+ font-size: 12px;
863
+ font-weight: 600;
864
+ color: var(--dsw-alias-label-primary);
865
+ letter-spacing: 0.02em;
866
+ }
867
+ .dbmcp-card-head > .dbmcp-pill {
868
+ margin-left: auto;
869
+ }
870
+ .dbmcp-card-actions {
871
+ display: flex;
872
+ align-items: center;
873
+ gap: 6px;
874
+ margin-left: 4px;
875
+ }
876
+ .dbmcp-group-actions {
877
+ display: flex;
878
+ align-items: center;
879
+ gap: 4px;
880
+ margin-left: auto;
881
+ }
882
+ .dbmcp-group-edit {
883
+ display: flex;
884
+ align-items: center;
885
+ gap: 6px;
886
+ flex: 0 1 320px;
887
+ }
888
+ .dbmcp-group.is-over {
889
+ outline: 1px dashed var(--dsw-alias-brand-primary);
890
+ outline-offset: 3px;
891
+ border-radius: 6px;
892
+ }
893
+ .dbmcp-item.is-dragging {
894
+ opacity: 0.55;
895
+ }
896
+ .dbmcp-item.is-taken {
897
+ opacity: 0.6;
898
+ }
899
+ .dbmcp-item {
900
+ cursor: grab;
901
+ }
902
+ .dbmcp-item.is-selected {
903
+ cursor: grab;
904
+ }
905
+ .dbmcp-group-create {
906
+ display: flex;
907
+ align-items: center;
908
+ gap: 6px;
909
+ margin-bottom: 12px;
910
+ }
911
+ .dbmcp-group-create .dbmcp-input {
912
+ max-width: 220px;
913
+ }
914
+ .dbmcp-group-pick {
915
+ width: 14px;
916
+ height: 14px;
917
+ accent-color: var(--dsw-alias-brand-primary);
918
+ cursor: pointer;
919
+ }
920
+ .dbmcp-bulk {
921
+ display: flex;
922
+ align-items: center;
923
+ flex-wrap: wrap;
924
+ gap: 8px;
925
+ margin-bottom: 12px;
926
+ padding: 8px 10px;
927
+ border: 1px solid var(--dsw-alias-brand-primary);
928
+ border-radius: 10px;
929
+ background: var(--dsw-alias-bg-layer-1);
930
+ }
931
+ .dbmcp-bulk-count {
932
+ font-size: 12px;
933
+ font-weight: 600;
934
+ color: var(--dsw-alias-brand-primary);
935
+ }
936
+ .dbmcp-bulk-group {
937
+ display: flex;
938
+ align-items: center;
939
+ gap: 6px;
940
+ flex: 1 1 220px;
941
+ min-width: 180px;
942
+ }
943
+ .dbmcp-bulk-group .dbmcp-input {
944
+ min-width: 120px;
945
+ }
946
+ .dbmcp-item-main {
947
+ min-width: 0;
948
+ flex: 1 1 auto;
949
+ }
950
+ .dbmcp-item-name {
951
+ display: flex;
952
+ align-items: center;
953
+ gap: 8px;
954
+ font-weight: 600;
955
+ flex-wrap: wrap;
956
+ }
957
+ .dbmcp-item-target {
958
+ color: var(--dsw-alias-label-secondary);
959
+ font-size: 12px;
960
+ word-break: break-all;
961
+ margin-top: 2px;
962
+ }
963
+ .dbmcp-item-actions {
964
+ display: flex;
965
+ align-items: center;
966
+ gap: 6px;
967
+ flex-wrap: wrap;
968
+ justify-content: flex-end;
969
+ }
970
+ .dbmcp-probe {
971
+ margin-top: 6px;
972
+ font-size: 12px;
973
+ color: var(--dsw-alias-label-secondary);
974
+ }
975
+ .dbmcp-probe.is-ok {
976
+ color: var(--dsw-alias-state-success-primary);
977
+ }
978
+ .dbmcp-probe.is-error {
979
+ color: var(--dsw-alias-state-error-primary);
980
+ }
981
+ .dbmcp-notice {
982
+ display: flex;
983
+ align-items: center;
984
+ justify-content: space-between;
985
+ gap: 12px;
986
+ border: 1px solid var(--dsw-alias-border-l1);
987
+ border-radius: 10px;
988
+ padding: 9px 12px;
989
+ margin-bottom: 12px;
990
+ font-size: 13px;
991
+ background: var(--dsw-alias-bg-layer-2);
992
+ }
993
+ .dbmcp-notice.is-error {
994
+ border-color: var(--dsw-alias-state-error-primary);
995
+ }
996
+ .dbmcp-form {
997
+ display: grid;
998
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
999
+ gap: 12px 16px;
1000
+ align-items: start;
1001
+ font-size: 13px;
1002
+ }
1003
+ .dbmcp-form.is-compact {
1004
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
1005
+ gap: 14px 18px;
1006
+ max-width: 860px;
1007
+ }
1008
+ .dbmcp-form.is-compact .dbmcp-paste .dbmcp-check {
1009
+ flex: none;
1010
+ white-space: nowrap;
1011
+ font-size: 12px;
1012
+ color: var(--dsw-alias-label-secondary);
1013
+ }
1014
+ .dbmcp-cell {
1015
+ display: flex;
1016
+ flex-direction: column;
1017
+ gap: 4px;
1018
+ min-width: 0;
1019
+ }
1020
+ .dbmcp-cell.dbmcp-wide {
1021
+ grid-column: 1 / -1;
1022
+ }
1023
+ .dbmcp-label {
1024
+ color: var(--dsw-alias-label-secondary);
1025
+ font-size: 12px;
1026
+ line-height: 1.4;
1027
+ }
1028
+ .dbmcp-paste {
1029
+ display: flex;
1030
+ align-items: center;
1031
+ gap: 8px;
1032
+ }
1033
+ .dbmcp-paste .dbmcp-input {
1034
+ flex: 1 1 auto;
1035
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1036
+ font-size: 12px;
1037
+ }
1038
+ .dbmcp-parse {
1039
+ flex: none;
1040
+ font-size: 12px;
1041
+ white-space: nowrap;
1042
+ }
1043
+ .dbmcp-parse.is-ok {
1044
+ color: var(--dsw-alias-state-success-primary);
1045
+ }
1046
+ .dbmcp-parse.is-bad {
1047
+ color: var(--dsw-alias-state-warn-primary);
1048
+ }
1049
+ .dbmcp-advanced {
1050
+ grid-column: 1 / -1;
1051
+ border: 1px solid var(--dsw-alias-border-l1);
1052
+ border-radius: 8px;
1053
+ background: var(--dsw-alias-bg-layer-2);
1054
+ padding: 8px 12px 12px;
1055
+ }
1056
+ .dbmcp-advanced > summary {
1057
+ cursor: pointer;
1058
+ font-size: 12px;
1059
+ color: var(--dsw-alias-label-secondary);
1060
+ padding: 2px 0;
1061
+ }
1062
+ .dbmcp-advanced[open] > summary {
1063
+ margin-bottom: 10px;
1064
+ }
1065
+ .dbmcp-input,
1066
+ .dbmcp-select {
1067
+ width: 100%;
1068
+ box-sizing: border-box;
1069
+ border: 1px solid var(--dsw-alias-border-l1);
1070
+ border-radius: 8px;
1071
+ background: var(--dsw-alias-bg-base);
1072
+ color: var(--dsw-alias-label-primary);
1073
+ font: inherit;
1074
+ font-size: 13px;
1075
+ padding: 6px 9px;
1076
+ }
1077
+ .dbmcp-input:focus,
1078
+ .dbmcp-select:focus {
1079
+ outline: none;
1080
+ border-color: var(--dsw-alias-brand-primary);
1081
+ }
1082
+ .dbmcp-hint {
1083
+ font-size: 12px;
1084
+ color: var(--dsw-alias-label-secondary);
1085
+ line-height: 1.4;
1086
+ }
1087
+ .dbmcp-check {
1088
+ display: flex;
1089
+ align-items: center;
1090
+ gap: 8px;
1091
+ font-size: 13px;
1092
+ }
1093
+ .dbmcp-form-foot {
1094
+ grid-column: 1 / -1;
1095
+ display: flex;
1096
+ align-items: center;
1097
+ gap: 10px;
1098
+ margin-top: 8px;
1099
+ }
1100
+ .dbmcp-scan {
1101
+ display: flex;
1102
+ flex-direction: column;
1103
+ gap: 14px;
1104
+ }
1105
+ .dbmcp-scan-file {
1106
+ font-size: 12px;
1107
+ color: var(--dsw-alias-label-secondary);
1108
+ word-break: break-all;
1109
+ }
1110
+ `
1111
+
1112
+ /** Re-render on locale switch / late dictionary registration. */
1113
+ function useLocale() {
1114
+ const service = localeService
1115
+ const subscribe = React.useCallback(
1116
+ (onChange) => (typeof service?.subscribe === 'function' ? service.subscribe(onChange) : () => {}),
1117
+ [service],
1118
+ )
1119
+ const getSnapshot = React.useCallback(
1120
+ () => (typeof service?.getSnapshot === 'function' ? service.getSnapshot() : 0),
1121
+ [service],
1122
+ )
1123
+ React.useSyncExternalStore(subscribe, getSnapshot)
1124
+ return service
1125
+ }
1126
+
1127
+ function fmt(text, values) {
1128
+ return String(text).replace(/\{(\w+)\}/g, (all, key) => (values && values[key] !== undefined ? String(values[key]) : all))
1129
+ }
1130
+
1131
+ async function apiGet(path) {
1132
+ const response = await fetch(API + path, { credentials: 'same-origin', headers: { accept: 'application/json' } })
1133
+ const data = await response.json().catch(() => null)
1134
+ if (!response.ok || !data) {
1135
+ const payload = data || null
1136
+ const message = (payload && (payload.error || payload.message)) || `HTTP ${response.status}`
1137
+ const error = new Error(String(message))
1138
+ error.payload = payload
1139
+ throw error
1140
+ }
1141
+ return data
1142
+ }
1143
+
1144
+ async function apiPost(path, body) {
1145
+ const response = await fetch(API + path, {
1146
+ method: 'POST',
1147
+ credentials: 'same-origin',
1148
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1149
+ body: JSON.stringify(body || {}),
1150
+ })
1151
+ const data = await response.json().catch(() => null)
1152
+ if (!response.ok || !data) {
1153
+ const message = (data && data.error) || `HTTP ${response.status}`
1154
+ const error = new Error(message)
1155
+ error.payload = data
1156
+ throw error
1157
+ }
1158
+ return data
1159
+ }
1160
+
1161
+ /** Sidebar icon: a stacked-database glyph drawn with currentColor. */
1162
+ function SidebarIcon(props) {
1163
+ const size = props && typeof props.size === 'number' ? props.size : 20
1164
+ return h(
1165
+ 'svg',
1166
+ {
1167
+ width: size,
1168
+ height: size,
1169
+ viewBox: '0 0 24 24',
1170
+ fill: 'none',
1171
+ stroke: 'currentColor',
1172
+ strokeWidth: 1.6,
1173
+ strokeLinecap: 'round',
1174
+ strokeLinejoin: 'round',
1175
+ 'aria-hidden': 'true',
1176
+ focusable: 'false',
1177
+ },
1178
+ h('ellipse', { cx: 12, cy: 5.5, rx: 7.5, ry: 3 }),
1179
+ h('path', { d: 'M4.5 5.5v6c0 1.66 3.36 3 7.5 3s7.5-1.34 7.5-3v-6' }),
1180
+ h('path', { d: 'M4.5 11.5v6c0 1.66 3.36 3 7.5 3s7.5-1.34 7.5-3v-6' }),
1181
+ )
1182
+ }
1183
+
1184
+ function Card(props) {
1185
+ return h(
1186
+ 'section',
1187
+ { className: 'dbmcp-card' },
1188
+ h(
1189
+ 'header',
1190
+ { className: 'dbmcp-card-head' },
1191
+ props.title == null ? null : h('h2', { className: 'dbmcp-card-title' }, props.title),
1192
+ props.aside == null ? null : h('span', { className: 'dbmcp-pill' }, props.aside),
1193
+ props.actions == null ? null : h('div', { className: 'dbmcp-card-actions' }, props.actions),
1194
+ ),
1195
+ props.children,
1196
+ )
1197
+ }
1198
+
1199
+ /** Two grid cells: label + value. */
1200
+ function Row(props) {
1201
+ return [
1202
+ h('span', { key: 'k', className: 'dbmcp-k' }, props.label),
1203
+ h('span', { key: 'v', className: 'dbmcp-v' }, props.children),
1204
+ ]
1205
+ }
1206
+
1207
+ function Field(props) {
1208
+ return h(
1209
+ 'div',
1210
+ { className: 'dbmcp-cell' + (props.wide ? ' dbmcp-wide' : '') },
1211
+ h('label', { className: 'dbmcp-label' }, props.label),
1212
+ props.children,
1213
+ props.hint ? h('div', { className: 'dbmcp-hint' }, props.hint) : null,
1214
+ )
1215
+ }
1216
+
1217
+ function TextInput(props) {
1218
+ return h('input', {
1219
+ className: 'dbmcp-input',
1220
+ type: props.type || 'text',
1221
+ value: props.value == null ? '' : props.value,
1222
+ placeholder: props.placeholder || '',
1223
+ autoComplete: props.autoComplete || 'off',
1224
+ autoFocus: props.autoFocus === true,
1225
+ spellCheck: false,
1226
+ disabled: props.disabled === true,
1227
+ title: props.title || undefined,
1228
+ onChange: (event) => props.onChange(event.target.value),
1229
+ })
1230
+ }
1231
+
1232
+ function stripQuotes(value) {
1233
+ return String(value == null ? '' : value).trim().replace(/^["']|["']$/g, '')
1234
+ }
1235
+
1236
+ function slugId(value) {
1237
+ return String(value || '')
1238
+ .toLowerCase()
1239
+ .replace(/[^a-z0-9]+/g, '-')
1240
+ .replace(/^-+|-+$/g, '')
1241
+ .slice(0, 32)
1242
+ }
1243
+
1244
+ function baseConfig(kind) {
1245
+ return {
1246
+ file: '',
1247
+ host: kind === 'sqlite' ? '' : 'localhost',
1248
+ port: DEFAULT_PORTS[kind] || '',
1249
+ database: '',
1250
+ user: '',
1251
+ schema: '',
1252
+ serviceName: '',
1253
+ sid: '',
1254
+ charset: kind === 'mysql' ? 'utf8mb4' : '',
1255
+ sslMode: '',
1256
+ }
1257
+ }
1258
+
1259
+ function fileStem(file) {
1260
+ const name = String(file || '').split(/[\\/]/).pop() || ''
1261
+ return name.replace(/\.(db|sqlite3?|db3)$/i, '')
1262
+ }
1263
+
1264
+ function decodePart(value) {
1265
+ try {
1266
+ return decodeURIComponent(value || '')
1267
+ } catch (error) {
1268
+ return String(value || '')
1269
+ }
1270
+ }
1271
+
1272
+ function parseQueryString(search) {
1273
+ const out = {}
1274
+ const text = String(search || '').replace(/^\?/, '')
1275
+ if (!text) return out
1276
+ text.split('&').forEach((pair) => {
1277
+ if (!pair) return
1278
+ const index = pair.indexOf('=')
1279
+ const key = decodePart(index < 0 ? pair : pair.slice(0, index)).trim().toLowerCase()
1280
+ if (!key) return
1281
+ out[key] = index < 0 ? '' : decodePart(pair.slice(index + 1))
1282
+ })
1283
+ return out
1284
+ }
1285
+
1286
+ function fromUrl(kind, body) {
1287
+ let url
1288
+ try {
1289
+ url = new URL('db:' + (body.startsWith('//') ? body : '//' + body))
1290
+ } catch (error) {
1291
+ return { ok: false, code: 'url' }
1292
+ }
1293
+ const query = parseQueryString(url.search)
1294
+ const database = decodePart(url.pathname.replace(/^\/+/, ''))
1295
+ const config = baseConfig(kind)
1296
+ config.host = url.hostname || 'localhost'
1297
+ config.port = url.port || DEFAULT_PORTS[kind] || ''
1298
+ config.user = decodePart(url.username) || query.user || query.username || ''
1299
+ if (kind === 'oracle') {
1300
+ config.serviceName = database || query.servicename || query.service || ''
1301
+ config.sid = query.sid || ''
1302
+ } else {
1303
+ config.database = database || query.database || query.dbname || ''
1304
+ }
1305
+ if (query.sslmode) config.sslMode = query.sslmode
1306
+ if (query.charset || query.characterencoding) config.charset = query.charset || query.characterencoding
1307
+ if (query.schema || query.currentschema || query.searchpath) {
1308
+ config.schema = query.schema || query.currentschema || query.searchpath
1309
+ }
1310
+ const password = decodePart(url.password)
1311
+ const name = kind === 'oracle' ? config.serviceName || config.sid : config.database || config.host
1312
+ return { ok: true, kind, config, password, name: name || kind }
1313
+ }
1314
+
1315
+ function parseTns(text) {
1316
+ const config = baseConfig('oracle')
1317
+ const host = /\(\s*HOST\s*=\s*([^)]+)\)/i.exec(text)
1318
+ const port = /\(\s*PORT\s*=\s*([^)]+)\)/i.exec(text)
1319
+ const service = /\(\s*SERVICE_NAME\s*=\s*([^)]+)\)/i.exec(text)
1320
+ const sid = /\(\s*SID\s*=\s*([^)]+)\)/i.exec(text)
1321
+ config.host = host ? host[1].trim() : 'localhost'
1322
+ config.port = port ? port[1].trim() : '1521'
1323
+ config.serviceName = service ? service[1].trim() : ''
1324
+ config.sid = sid ? sid[1].trim() : ''
1325
+ if (!config.serviceName && !config.sid) return { ok: false, code: 'tns' }
1326
+ return { ok: true, kind: 'oracle', config, password: '', name: config.serviceName || config.sid }
1327
+ }
1328
+
1329
+ function parseOracleThin(text) {
1330
+ const body = String(text || '').replace(/^@/, '').trim()
1331
+ const config = baseConfig('oracle')
1332
+ if (body.startsWith('//')) {
1333
+ const match = /^\/\/([^/:]+)(?::(\d+))?\/(.+)$/.exec(body)
1334
+ if (!match) return { ok: false, code: 'url' }
1335
+ config.host = match[1]
1336
+ config.port = match[2] || '1521'
1337
+ config.serviceName = match[3].split(/[?#]/)[0]
1338
+ } else {
1339
+ const match = /^([^:]+)(?::(\d+))?:(.+)$/.exec(body)
1340
+ if (!match) return { ok: false, code: 'url' }
1341
+ config.host = match[1]
1342
+ config.port = match[2] || '1521'
1343
+ const target = match[3].split(/[?#]/)[0]
1344
+ if (target.includes('/') || target.includes('.')) config.serviceName = target
1345
+ else config.sid = target
1346
+ }
1347
+ if (!config.serviceName && !config.sid) return { ok: false, code: 'url' }
1348
+ return { ok: true, kind: 'oracle', config, password: '', name: config.serviceName || config.sid }
1349
+ }
1350
+
1351
+ function parseSqliteFile(text) {
1352
+ let file = stripQuotes(String(text || '').replace(/^file:/i, ''))
1353
+ if (/^\/+[A-Za-z]:/.test(file)) file = file.replace(/^\/+/, '')
1354
+ else if (/^\/{2,}/.test(file)) file = '/' + file.replace(/^\/+/, '')
1355
+ if (!file || file === '/') return { ok: false, code: 'file' }
1356
+ const config = baseConfig('sqlite')
1357
+ config.file = file
1358
+ return { ok: true, kind: 'sqlite', config, password: '', name: fileStem(file) || 'sqlite' }
1359
+ }
1360
+
1361
+ /**
1362
+ * Accepts a DSN, a JDBC URL, an Oracle TNS descriptor, or a SQLite file path.
1363
+ * Returns { ok, kind, config, password, name } so the form can fill itself.
1364
+ */
1365
+ function parseConnectionText(input) {
1366
+ const raw = stripQuotes(input)
1367
+ if (!raw) return { ok: false, code: 'empty' }
1368
+ if (/^\(\s*DESCRIPTION/i.test(raw)) return parseTns(raw)
1369
+
1370
+ const lower = raw.toLowerCase()
1371
+ if (lower.startsWith('jdbc:')) {
1372
+ const rest = raw.slice(5)
1373
+ const restLower = rest.toLowerCase()
1374
+ if (restLower.startsWith('sqlite:')) return parseSqliteFile(rest.slice('sqlite:'.length))
1375
+ if (restLower.startsWith('oracle:thin:@')) return parseOracleThin(rest.slice('oracle:thin:@'.length))
1376
+ if (restLower.startsWith('postgresql://')) return fromUrl('postgres', rest.slice('postgresql:'.length))
1377
+ if (restLower.startsWith('mysql://')) return fromUrl('mysql', rest.slice('mysql:'.length))
1378
+ if (restLower.startsWith('mariadb://')) return fromUrl('mysql', rest.slice('mariadb:'.length))
1379
+ return { ok: false, code: 'jdbc' }
1380
+ }
1381
+
1382
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i.exec(raw)
1383
+ if (scheme) {
1384
+ const name = scheme[1].toLowerCase()
1385
+ const body = '//' + scheme[2]
1386
+ if (name === 'sqlite' || name === 'sqlite3' || name === 'file') return parseSqliteFile(body.slice(2))
1387
+ if (name === 'oracle') return fromUrl('oracle', body)
1388
+ if (name === 'postgres' || name === 'postgresql' || name === 'psql') return fromUrl('postgres', body)
1389
+ if (name === 'mysql' || name === 'mariadb') return fromUrl('mysql', body)
1390
+ return { ok: false, code: 'scheme' }
1391
+ }
1392
+
1393
+ if (raw.startsWith('@') || /^[\w.-]+:\d{2,5}[:/]/.test(raw)) return parseOracleThin(raw)
1394
+ if (!raw.includes('://') && /\.(db|sqlite3?|db3)$/i.test(raw)) return parseSqliteFile(raw)
1395
+ if (/^([a-z]:[\\/]|\\\\|\/)/i.test(raw)) return parseSqliteFile(raw)
1396
+ return { ok: false, code: 'unrecognized' }
1397
+ }
1398
+
1399
+ /** Append -2, -3, ... until the id is free; the registry requires unique ids. */
1400
+ function uniqueId(base, taken) {
1401
+ const seed = slugId(base) || 'source'
1402
+ const used = new Set((taken || []).map((value) => String(value || '').toLowerCase()))
1403
+ if (!used.has(seed)) return seed
1404
+ for (let index = 2; index < 100; index += 1) {
1405
+ const candidate = `${seed.slice(0, 28)}-${index}`
1406
+ if (!used.has(candidate)) return candidate
1407
+ }
1408
+ return seed
1409
+ }
1410
+
1411
+ function formatUptime(ms) {
1412
+ const total = Math.max(0, Math.floor((ms || 0) / 1000))
1413
+ if (total < 60) return `${total}s`
1414
+ const minutes = Math.floor(total / 60)
1415
+ if (minutes < 60) return `${minutes}m`
1416
+ const hours = Math.floor(minutes / 60)
1417
+ return `${hours}h ${minutes % 60}m`
1418
+ }
1419
+
1420
+ function pythonLabel(t, python) {
1421
+ if (!python) return '-'
1422
+ if (python.ok) return `${python.command} ${python.version}`
1423
+ return `${t('state.failed')} · ${t('python.missing')}`
1424
+ }
1425
+
1426
+ function emptyDraft() {
1427
+ return {
1428
+ id: '',
1429
+ name: '',
1430
+ group: '',
1431
+ kind: 'sqlite',
1432
+ readOnly: true,
1433
+ config: baseConfig('sqlite'),
1434
+ password: '',
1435
+ hasSecret: false,
1436
+ nameTouched: false,
1437
+ idTouched: false,
1438
+ }
1439
+ }
1440
+
1441
+ function draftFromSource(source) {
1442
+ const config = (source && source.config) || {}
1443
+ const draft = emptyDraft()
1444
+ const fallback = baseConfig(source.kind || 'sqlite')
1445
+ draft.id = source.id
1446
+ draft.name = source.name || ''
1447
+ draft.group = (source && source.group) || ''
1448
+ draft.kind = source.kind || 'sqlite'
1449
+ draft.readOnly = source.readOnly !== false
1450
+ draft.hasSecret = source.hasSecret === true
1451
+ draft.config = {
1452
+ ...fallback,
1453
+ file: config.file || '',
1454
+ host: config.host || fallback.host,
1455
+ port: config.port == null ? fallback.port : String(config.port),
1456
+ database: config.database || '',
1457
+ user: config.user || '',
1458
+ schema: config.schema || '',
1459
+ serviceName: config.serviceName || '',
1460
+ sid: config.sid || '',
1461
+ charset: config.charset || fallback.charset,
1462
+ sslMode: config.sslMode || '',
1463
+ }
1464
+ return draft
1465
+ }
1466
+
1467
+ /** Render the current draft back to a connection string (never includes the password). */
1468
+ function canonicalUrl(draft) {
1469
+ const config = draft.config || {}
1470
+ if (draft.kind === 'sqlite') return config.file || ''
1471
+ const user = config.user ? `${config.user}@` : ''
1472
+ const port = config.port ? `:${config.port}` : ''
1473
+ const host = config.host || 'localhost'
1474
+ if (draft.kind === 'oracle') {
1475
+ // Service name and SID are different address forms: only the SID form
1476
+ // round-trips through `jdbc:oracle:thin:@host:port:sid`.
1477
+ if (config.serviceName) return `oracle://${user}${host}${port}/${config.serviceName}`
1478
+ if (config.sid && config.port) return `jdbc:oracle:thin:@${host}${port}:${config.sid}`
1479
+ return ''
1480
+ }
1481
+ return config.database ? `${draft.kind}://${user}${host}${port}/${config.database}` : ''
1482
+ }
1483
+
1484
+ function SourceForm(props) {
1485
+ const t = props.t
1486
+ const [draft, setDraft] = React.useState(props.draft)
1487
+ const [raw, setRaw] = React.useState(() => (props.editing ? canonicalUrl(props.draft) : ''))
1488
+ const [parsed, setParsed] = React.useState(null)
1489
+ const [saveSecret, setSaveSecret] = React.useState(true)
1490
+ const [busy, setBusy] = React.useState(false)
1491
+ const [error, setError] = React.useState(null)
1492
+ const editing = props.editing
1493
+
1494
+ const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes }))
1495
+ const patchConfig = (changes) => setDraft((prev) => ({ ...prev, config: { ...prev.config, ...changes } }))
1496
+
1497
+ const fillFrom = (result) => {
1498
+ setDraft((prev) => {
1499
+ const next = { ...prev, kind: result.kind, config: { ...prev.config, ...result.config } }
1500
+ if (result.password) next.password = result.password
1501
+ if (result.name && (!prev.nameTouched || !prev.name.trim())) next.name = result.name
1502
+ if (!editing && (!prev.idTouched || !prev.id.trim())) {
1503
+ next.id = uniqueId(result.name || result.kind, props.takenIds)
1504
+ }
1505
+ return next
1506
+ })
1507
+ }
1508
+
1509
+ const onRawChange = (value) => {
1510
+ setRaw(value)
1511
+ if (!value.trim()) {
1512
+ setParsed(null)
1513
+ return
1514
+ }
1515
+ const result = parseConnectionText(value)
1516
+ setParsed({ ok: result.ok, code: result.code || null })
1517
+ if (result.ok) fillFrom(result)
1518
+ }
1519
+
1520
+ const changeKind = (kind) => {
1521
+ setDraft((prev) => ({
1522
+ ...prev,
1523
+ kind,
1524
+ config: { ...baseConfig(kind), file: prev.config.file, user: prev.config.user, database: prev.kind === 'oracle' ? '' : prev.config.database },
1525
+ }))
1526
+ }
1527
+
1528
+ const submit = async () => {
1529
+ setError(null)
1530
+ const needsTarget =
1531
+ draft.kind === 'oracle' ? !draft.config.serviceName.trim() && !draft.config.sid.trim() : !draft.config.database.trim()
1532
+ const missing = draft.kind === 'sqlite' ? !draft.config.file.trim() : !draft.config.host.trim() || needsTarget
1533
+ if (missing) {
1534
+ setError(t('form.invalid'))
1535
+ return
1536
+ }
1537
+ setBusy(true)
1538
+ try {
1539
+ const body = {
1540
+ id: draft.id.trim(),
1541
+ name: draft.name.trim() || draft.id || t('form.untitled'),
1542
+ group: (draft.group || '').trim(),
1543
+ kind: draft.kind,
1544
+ readOnly: draft.readOnly,
1545
+ config: { ...draft.config, port: draft.config.port ? Number(draft.config.port) : undefined },
1546
+ password: saveSecret && draft.password !== '' ? draft.password : undefined,
1547
+ }
1548
+ if (!body.id) delete body.id
1549
+ const data = await apiPost('/datasources/save', body)
1550
+ props.onSaved(data.datasource)
1551
+ } catch (caught) {
1552
+ setError(String((caught && caught.message) || caught))
1553
+ } finally {
1554
+ setBusy(false)
1555
+ }
1556
+ }
1557
+
1558
+ const manual = []
1559
+ if (draft.kind === 'sqlite') {
1560
+ manual.push(
1561
+ h(
1562
+ Field,
1563
+ { key: 'file', wide: true, label: t('form.file'), hint: t('form.fileHint') },
1564
+ h(TextInput, { value: draft.config.file, onChange: (value) => patchConfig({ file: value }) }),
1565
+ ),
1566
+ )
1567
+ } else {
1568
+ manual.push(
1569
+ h(
1570
+ Field,
1571
+ { key: 'host', label: t('form.host') },
1572
+ h(TextInput, { value: draft.config.host, onChange: (value) => patchConfig({ host: value }) }),
1573
+ ),
1574
+ h(
1575
+ Field,
1576
+ { key: 'port', label: t('form.port') },
1577
+ h(TextInput, {
1578
+ value: draft.config.port,
1579
+ placeholder: DEFAULT_PORTS[draft.kind] || '',
1580
+ onChange: (value) => patchConfig({ port: value.replace(/[^\d]/g, '') }),
1581
+ }),
1582
+ ),
1583
+ )
1584
+ if (draft.kind === 'oracle') {
1585
+ manual.push(
1586
+ h(
1587
+ Field,
1588
+ { key: 'serviceName', label: t('form.serviceName') },
1589
+ h(TextInput, { value: draft.config.serviceName, onChange: (value) => patchConfig({ serviceName: value }) }),
1590
+ ),
1591
+ h(
1592
+ Field,
1593
+ { key: 'sid', label: t('form.sid'), hint: t('form.sidHint') },
1594
+ h(TextInput, { value: draft.config.sid, onChange: (value) => patchConfig({ sid: value }) }),
1595
+ ),
1596
+ )
1597
+ } else {
1598
+ manual.push(
1599
+ h(
1600
+ Field,
1601
+ { key: 'database', label: t('form.database') },
1602
+ h(TextInput, { value: draft.config.database, onChange: (value) => patchConfig({ database: value }) }),
1603
+ ),
1604
+ )
1605
+ }
1606
+ manual.push(
1607
+ h(
1608
+ Field,
1609
+ { key: 'schema', label: t('form.schema'), hint: t('form.schemaHint') },
1610
+ h(TextInput, { value: draft.config.schema, onChange: (value) => patchConfig({ schema: value }) }),
1611
+ ),
1612
+ )
1613
+ }
1614
+ if (draft.kind === 'postgres') {
1615
+ manual.push(
1616
+ h(
1617
+ Field,
1618
+ { key: 'sslMode', label: t('form.sslMode') },
1619
+ h(
1620
+ 'select',
1621
+ {
1622
+ className: 'dbmcp-select',
1623
+ value: draft.config.sslMode || '',
1624
+ onChange: (event) => patchConfig({ sslMode: event.target.value }),
1625
+ },
1626
+ h('option', { value: '' }, 'default'),
1627
+ h('option', { value: 'disable' }, 'disable'),
1628
+ h('option', { value: 'prefer' }, 'prefer'),
1629
+ h('option', { value: 'require' }, 'require'),
1630
+ ),
1631
+ ),
1632
+ )
1633
+ }
1634
+ if (draft.kind === 'mysql') {
1635
+ manual.push(
1636
+ h(
1637
+ Field,
1638
+ { key: 'charset', label: t('form.charset') },
1639
+ h(TextInput, { value: draft.config.charset, onChange: (value) => patchConfig({ charset: value }) }),
1640
+ ),
1641
+ )
1642
+ }
1643
+
1644
+ return h(
1645
+ 'div',
1646
+ null,
1647
+ error
1648
+ ? h(
1649
+ 'div',
1650
+ { className: 'dbmcp-notice is-error' },
1651
+ h('span', null, error),
1652
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setError(null) }, '×'),
1653
+ )
1654
+ : null,
1655
+ h(
1656
+ 'div',
1657
+ { className: 'dbmcp-form is-compact' },
1658
+ h(
1659
+ Field,
1660
+ {
1661
+ key: 'raw',
1662
+ wide: true,
1663
+ label: t('form.paste'),
1664
+ hint: editing ? t('form.pasteHintEdit') : t('form.pasteHintShort'),
1665
+ },
1666
+ h(
1667
+ 'div',
1668
+ { className: 'dbmcp-paste' },
1669
+ h(TextInput, {
1670
+ value: raw,
1671
+ onChange: onRawChange,
1672
+ placeholder: t('form.pastePlaceholder'),
1673
+ autoFocus: !editing,
1674
+ }),
1675
+ parsed
1676
+ ? h(
1677
+ 'span',
1678
+ { className: 'dbmcp-parse ' + (parsed.ok ? 'is-ok' : 'is-bad') },
1679
+ parsed.ok ? t('form.parseOk') : t('form.parseBad'),
1680
+ )
1681
+ : null,
1682
+ ),
1683
+ ),
1684
+ h(
1685
+ Field,
1686
+ { key: 'user', label: t('form.user') },
1687
+ h(TextInput, {
1688
+ value: draft.config.user,
1689
+ onChange: (value) => patchConfig({ user: value }),
1690
+ autoComplete: 'off',
1691
+ }),
1692
+ ),
1693
+ h(
1694
+ Field,
1695
+ { key: 'password', label: t('form.password'), hint: draft.hasSecret ? t('form.passwordKeep') : null },
1696
+ h(
1697
+ 'div',
1698
+ { className: 'dbmcp-paste' },
1699
+ h(TextInput, {
1700
+ type: 'password',
1701
+ value: draft.password,
1702
+ autoComplete: 'new-password',
1703
+ placeholder: draft.hasSecret ? '********' : t('form.passwordEmpty'),
1704
+ onChange: (value) => patch({ password: value }),
1705
+ }),
1706
+ h(
1707
+ 'label',
1708
+ { className: 'dbmcp-check' },
1709
+ h('input', {
1710
+ type: 'checkbox',
1711
+ checked: saveSecret,
1712
+ onChange: (event) => setSaveSecret(event.target.checked),
1713
+ }),
1714
+ h('span', null, t('form.savePassword')),
1715
+ ),
1716
+ ),
1717
+ ),
1718
+ h(
1719
+ 'details',
1720
+ { key: 'more', className: 'dbmcp-advanced' },
1721
+ h('summary', null, t('form.more')),
1722
+ h(
1723
+ 'div',
1724
+ { className: 'dbmcp-form' },
1725
+ h(
1726
+ Field,
1727
+ { key: 'name', label: t('form.name'), hint: t('form.optional') },
1728
+ h(TextInput, {
1729
+ value: draft.name,
1730
+ placeholder: draft.id || '',
1731
+ onChange: (value) => patch({ name: value, nameTouched: true }),
1732
+ }),
1733
+ ),
1734
+ h(
1735
+ Field,
1736
+ { key: 'group', label: t('form.group'), hint: t('form.groupHint') },
1737
+ h(TextInput, {
1738
+ value: draft.group || '',
1739
+ placeholder: t('group.unassigned'),
1740
+ onChange: (value) => patch({ group: value }),
1741
+ }),
1742
+ ),
1743
+ h(
1744
+ Field,
1745
+ { key: 'id', label: t('form.id'), hint: editing ? null : t('form.idHint') },
1746
+ h(TextInput, {
1747
+ value: draft.id,
1748
+ disabled: Boolean(editing),
1749
+ placeholder: 'prod-main',
1750
+ onChange: (value) =>
1751
+ patch({ id: value.toLowerCase().replace(/[^a-z0-9-]/g, ''), idTouched: true }),
1752
+ }),
1753
+ ),
1754
+ h(
1755
+ Field,
1756
+ { key: 'kind', label: t('form.kind') },
1757
+ h(
1758
+ 'select',
1759
+ {
1760
+ className: 'dbmcp-select',
1761
+ value: draft.kind,
1762
+ disabled: Boolean(editing),
1763
+ onChange: (event) => changeKind(event.target.value),
1764
+ },
1765
+ KINDS.map((kind) => h('option', { key: kind, value: kind }, kind)),
1766
+ ),
1767
+ ),
1768
+ manual,
1769
+ h(
1770
+ Field,
1771
+ { key: 'readOnly', label: t('form.readOnly'), hint: t('form.readOnlyHint') },
1772
+ h(
1773
+ 'label',
1774
+ { className: 'dbmcp-check' },
1775
+ h('input', {
1776
+ type: 'checkbox',
1777
+ checked: draft.readOnly,
1778
+ onChange: (event) => patch({ readOnly: event.target.checked }),
1779
+ }),
1780
+ h('span', null, draft.readOnly ? t('sources.readonly') : t('sources.readwrite')),
1781
+ ),
1782
+ ),
1783
+ ),
1784
+ ),
1785
+ h(
1786
+ 'div',
1787
+ { className: 'dbmcp-form-foot' },
1788
+ h(
1789
+ 'button',
1790
+ { type: 'button', className: 'dbmcp-btn is-primary', disabled: busy, onClick: submit },
1791
+ busy ? t('action.saving') : t('action.save'),
1792
+ ),
1793
+ ),
1794
+ ),
1795
+ )
1796
+ }
1797
+
1798
+ function ImportView(props) {
1799
+ const t = props.t
1800
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: null })
1801
+ const [selected, setSelected] = React.useState([])
1802
+ const [busy, setBusy] = React.useState(false)
1803
+
1804
+ React.useEffect(() => {
1805
+ let alive = true
1806
+ apiGet('/dbeaver/scan')
1807
+ .then((data) => {
1808
+ if (alive) setState({ status: 'ready', data, error: null })
1809
+ })
1810
+ .catch((error) => {
1811
+ if (alive) setState({ status: 'error', data: null, error: String((error && error.message) || error) })
1812
+ })
1813
+ return () => {
1814
+ alive = false
1815
+ }
1816
+ }, [])
1817
+
1818
+ const existing = props.existingIds || []
1819
+ const all = state.data ? state.data.sources : []
1820
+
1821
+ const toggle = (id) =>
1822
+ setSelected((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
1823
+
1824
+ const runImport = async () => {
1825
+ setBusy(true)
1826
+ try {
1827
+ const data = await apiPost('/dbeaver/import', { ids: selected })
1828
+ props.onImported(data)
1829
+ } catch (error) {
1830
+ setState((prev) => ({ ...prev, error: String((error && error.message) || error) }))
1831
+ } finally {
1832
+ setBusy(false)
1833
+ }
1834
+ }
1835
+
1836
+ if (state.status === 'loading') return h('div', { className: 'dbmcp-empty' }, t('loading'))
1837
+ if (state.status === 'error') {
1838
+ return h('div', { className: 'dbmcp-error' }, h('div', null, state.error))
1839
+ }
1840
+
1841
+ return h(
1842
+ 'div',
1843
+ { className: 'dbmcp-scan' },
1844
+ h('p', { className: 'dbmcp-sub' }, t('import.body')),
1845
+ all.length === 0
1846
+ ? h(
1847
+ 'div',
1848
+ { className: 'dbmcp-empty' },
1849
+ h('strong', null, t('import.empty')),
1850
+ h(
1851
+ 'p',
1852
+ null,
1853
+ state.data.roots.join(' · '),
1854
+ ),
1855
+ )
1856
+ : all.map((source) =>
1857
+ h(
1858
+ 'div',
1859
+ { key: source.file },
1860
+ h(Card, { title: t('import.file'), aside: source.credentialsFound ? t('import.credentialsFound') : t('import.credentialsMissing') },
1861
+ h('div', { className: 'dbmcp-scan-file' }, source.file),
1862
+ h(
1863
+ 'ul',
1864
+ { className: 'dbmcp-list', style: { marginTop: '10px' } },
1865
+ source.connections.map((item) => {
1866
+ const duplicate = item.alreadyImported === true
1867
+ const taken = duplicate || existing.includes(item.id)
1868
+ return h(
1869
+ 'li',
1870
+ { className: 'dbmcp-item' + (duplicate ? ' is-taken' : ''), key: item.id },
1871
+ h(
1872
+ 'div',
1873
+ { className: 'dbmcp-item-main' },
1874
+ h(
1875
+ 'div',
1876
+ { className: 'dbmcp-item-name' },
1877
+ item.importable
1878
+ ? h('input', {
1879
+ type: 'checkbox',
1880
+ checked: selected.includes(item.id),
1881
+ disabled: taken,
1882
+ title: duplicate ? t('import.already') : undefined,
1883
+ onChange: () => toggle(item.id),
1884
+ })
1885
+ : null,
1886
+ h('span', null, item.name || item.id),
1887
+ item.kind ? h('span', { className: 'dbmcp-pill' }, item.kind) : null,
1888
+ duplicate ? h('span', { className: 'dbmcp-pill' }, t('import.already')) : null,
1889
+ ),
1890
+ h(
1891
+ 'div',
1892
+ { className: 'dbmcp-item-target' },
1893
+ duplicate
1894
+ ? t('import.alreadyAs').replace('{name}', item.duplicateName || item.duplicateId || '')
1895
+ : [item.importable ? item.target : item.reason, item.user].filter(Boolean).join(' · '),
1896
+ ),
1897
+ duplicate
1898
+ ? null
1899
+ : h(
1900
+ 'div',
1901
+ { className: 'dbmcp-item-target' },
1902
+ item.importable ? (item.hasPassword ? t('sources.hasPassword') : t('sources.noPassword')) : '',
1903
+ ),
1904
+ ),
1905
+ )
1906
+ }),
1907
+ ),
1908
+ ),
1909
+ ),
1910
+ ),
1911
+ h(
1912
+ 'div',
1913
+ { className: 'dbmcp-toolbar' },
1914
+ h(
1915
+ 'button',
1916
+ { type: 'button', className: 'dbmcp-btn is-primary', disabled: busy || selected.length === 0, onClick: runImport },
1917
+ busy ? t('action.importing') : `${t('action.importSelected')} (${selected.length})`,
1918
+ ),
1919
+ ),
1920
+ )
1921
+ }
1922
+
1923
+ /** P7: read-only structure browser. Only lists tables and columns. */
1924
+ function SchemaView(props) {
1925
+ const { t, source, onBack } = props
1926
+ const [tables, setTables] = React.useState(null)
1927
+ const [picked, setPicked] = React.useState(null)
1928
+ const [detail, setDetail] = React.useState(null)
1929
+ const [error, setError] = React.useState(null)
1930
+ const [busy, setBusy] = React.useState(true)
1931
+
1932
+ React.useEffect(() => {
1933
+ let alive = true
1934
+ setBusy(true)
1935
+ ;(async () => {
1936
+ try {
1937
+ const data = await apiGet(`/schema?datasource=${encodeURIComponent(source.id)}`)
1938
+ if (!alive) return
1939
+ if (!data.ok) {
1940
+ const failure = data.error || {}
1941
+ setError(failure.hint ? `${failure.message || t('schema.error')} — ${failure.hint}` : failure.message || t('schema.error'))
1942
+ setTables([])
1943
+ } else {
1944
+ setTables(data.tables || [])
1945
+ setError(null)
1946
+ }
1947
+ } catch (failure) {
1948
+ if (!alive) return
1949
+ const payload = (failure && failure.payload) || {}
1950
+ setError(payload.hint ? `${payload.error || failure.message} — ${payload.hint}` : String((failure && failure.message) || failure))
1951
+ setTables([])
1952
+ } finally {
1953
+ if (alive) setBusy(false)
1954
+ }
1955
+ })()
1956
+ return () => {
1957
+ alive = false
1958
+ }
1959
+ }, [source.id])
1960
+
1961
+ React.useEffect(() => {
1962
+ if (!picked) {
1963
+ setDetail(null)
1964
+ return undefined
1965
+ }
1966
+ let alive = true
1967
+ setDetail({ loading: true })
1968
+ ;(async () => {
1969
+ try {
1970
+ const data = await apiGet(
1971
+ `/schema?datasource=${encodeURIComponent(source.id)}&table=${encodeURIComponent(picked)}`,
1972
+ )
1973
+ if (alive) setDetail(data)
1974
+ } catch (failure) {
1975
+ const payload = (failure && failure.payload) || {}
1976
+ if (alive) setDetail({ ok: false, error: { message: payload.error || String((failure && failure.message) || failure) } })
1977
+ }
1978
+ })()
1979
+ return () => {
1980
+ alive = false
1981
+ }
1982
+ }, [picked, source.id])
1983
+
1984
+ let content
1985
+ if (busy && !tables) {
1986
+ content = h('div', { className: 'dbmcp-empty' }, t('schema.loading'))
1987
+ } else if (error) {
1988
+ content = h('div', { className: 'dbmcp-error' }, h('div', null, t('schema.error')), h('code', null, error))
1989
+ } else if (!tables || tables.length === 0) {
1990
+ content = h('div', { className: 'dbmcp-empty' }, t('schema.empty'))
1991
+ } else {
1992
+ content = h(
1993
+ 'div',
1994
+ { className: 'dbmcp-schema' },
1995
+ h(
1996
+ 'div',
1997
+ { className: 'dbmcp-schema-side' },
1998
+ h('div', { className: 'dbmcp-schema-head' }, fmt(t('schema.tables'), { n: tables.length })),
1999
+ h(
2000
+ 'ul',
2001
+ { className: 'dbmcp-schema-tables' },
2002
+ tables.map((table) =>
2003
+ h(
2004
+ 'li',
2005
+ { key: `${table.schema || ''}.${table.name}` },
2006
+ h(
2007
+ 'button',
2008
+ {
2009
+ type: 'button',
2010
+ className: 'dbmcp-schema-table' + (picked === table.name ? ' is-active' : ''),
2011
+ onClick: () => setPicked(table.name),
2012
+ },
2013
+ h('span', null, table.name),
2014
+ table.type ? h('span', { className: 'dbmcp-pill' }, table.type) : null,
2015
+ ),
2016
+ ),
2017
+ ),
2018
+ ),
2019
+ ),
2020
+ h(
2021
+ 'div',
2022
+ { className: 'dbmcp-schema-main' },
2023
+ !picked
2024
+ ? h('div', { className: 'dbmcp-empty' }, t('schema.pick'))
2025
+ : detail && detail.loading
2026
+ ? h('div', { className: 'dbmcp-empty' }, t('schema.loading'))
2027
+ : detail && detail.ok === false
2028
+ ? h(
2029
+ 'div',
2030
+ { className: 'dbmcp-error' },
2031
+ h('code', null, (detail.error && detail.error.message) || t('schema.error')),
2032
+ )
2033
+ : detail && detail.ok
2034
+ ? h(
2035
+ 'div',
2036
+ null,
2037
+ h(
2038
+ 'div',
2039
+ { className: 'dbmcp-schema-head' },
2040
+ h('span', null, detail.table),
2041
+ h('span', { className: 'dbmcp-hint' }, fmt(t('schema.columns'), { n: (detail.columns || []).length })),
2042
+ ),
2043
+ h(
2044
+ 'table',
2045
+ { className: 'dbmcp-schema-tablegrid' },
2046
+ h(
2047
+ 'thead',
2048
+ null,
2049
+ h(
2050
+ 'tr',
2051
+ null,
2052
+ h('th', null, t('schema.column')),
2053
+ h('th', null, t('schema.type')),
2054
+ h('th', null, t('schema.nullable')),
2055
+ h('th', null, t('schema.default')),
2056
+ h('th', null, t('schema.pk')),
2057
+ ),
2058
+ ),
2059
+ h(
2060
+ 'tbody',
2061
+ null,
2062
+ (detail.columns || []).map((column) =>
2063
+ h(
2064
+ 'tr',
2065
+ { key: column.name },
2066
+ h('td', null, column.name),
2067
+ h('td', { className: 'dbmcp-mono' }, column.type || ''),
2068
+ h('td', null, column.nullable ? t('schema.yes') : t('schema.no')),
2069
+ h('td', { className: 'dbmcp-mono' }, column.default === null || column.default === undefined ? '' : String(column.default)),
2070
+ h('td', null, column.primaryKey ? '●' : ''),
2071
+ ),
2072
+ ),
2073
+ ),
2074
+ ),
2075
+ h('div', { className: 'dbmcp-hint dbmcp-schema-note' }, t('schema.readonlyNote')),
2076
+ )
2077
+ : null,
2078
+ ),
2079
+ )
2080
+ }
2081
+
2082
+ return h(
2083
+ 'div',
2084
+ { className: 'dbmcp-schema-wrap' },
2085
+ h('div', { className: 'dbmcp-schema-head' }, h('span', { className: 'dbmcp-hint' }, fmt(t('schema.title'), { name: source.name || source.id }))),
2086
+ content,
2087
+ )
2088
+ }
2089
+
2090
+ function crumbLabel(t, view) {
2091
+ if (view.mode === 'schema' && view.source) return view.source.name || view.source.id
2092
+ if (view.mode === 'create') return t('action.new')
2093
+ if (view.mode === 'edit' && view.source) return fmt(t('form.edit'), { name: view.source.name || view.source.id })
2094
+ if (view.mode === 'import') return t('action.import')
2095
+ return t('panel.title')
2096
+ }
2097
+
2098
+ function Breadcrumbs({ t, trail }) {
2099
+ const items = []
2100
+ trail.forEach((step, index) => {
2101
+ if (index > 0) items.push(h('span', { key: `sep-${index}`, className: 'dbmcp-crumb-sep' }, '/'))
2102
+ if (typeof step.onSelect === 'function') {
2103
+ items.push(
2104
+ h('button', { key: `step-${index}`, type: 'button', className: 'dbmcp-crumb', onClick: step.onSelect }, step.label),
2105
+ )
2106
+ } else {
2107
+ items.push(h('span', { key: `step-${index}`, className: 'dbmcp-crumb is-current' }, step.label))
2108
+ }
2109
+ })
2110
+ return h('nav', { className: 'dbmcp-crumbs', 'aria-label': t('panel.title') }, items)
2111
+ }
2112
+
2113
+ function MainPanel() {
2114
+ const service = useLocale()
2115
+ const t = React.useMemo(() => (service ? service.bind(NS) : (key) => key), [service])
2116
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: null })
2117
+ const [busy, setBusy] = React.useState(false)
2118
+ const [view, setView] = React.useState({ mode: 'list' })
2119
+ const tab = tabOfMode(view.mode)
2120
+ const [notice, setNotice] = React.useState(null)
2121
+ const [probing, setProbing] = React.useState(null)
2122
+ const [pendingDelete, setPendingDelete] = React.useState(null)
2123
+ const [selected, setSelected] = React.useState([])
2124
+ const [bulkGroup, setBulkGroup] = React.useState('')
2125
+ const [pendingBulkDelete, setPendingBulkDelete] = React.useState(false)
2126
+ const [groupDraft, setGroupDraft] = React.useState('')
2127
+ const [editingGroup, setEditingGroup] = React.useState(null)
2128
+ const [pendingGroupDelete, setPendingGroupDelete] = React.useState(null)
2129
+ const [draggingId, setDraggingId] = React.useState(null)
2130
+ const [dragOverGroup, setDragOverGroup] = React.useState(null)
2131
+
2132
+ /** Switching a tab only swaps the card below; the chrome never moves. */
2133
+ const selectTab = (id) => {
2134
+ setView({ mode: id })
2135
+ }
2136
+
2137
+ const backToChat = () => {
2138
+ const layout = layoutService
2139
+ if (!layout || typeof layout.selectPanel !== 'function') return
2140
+ try {
2141
+ layout.selectPanel(null)
2142
+ } catch (error) {
2143
+ /* older shells may not expose selectPanel; staying on the panel is fine */
2144
+ }
2145
+ }
2146
+ const [settings, setSettings] = React.useState(null)
2147
+ const [audit, setAudit] = React.useState({ entries: [], file: null })
2148
+ const [env, setEnv] = React.useState(null)
2149
+ const [installing, setInstalling] = React.useState(false)
2150
+
2151
+ const load = React.useCallback(async () => {
2152
+ setBusy(true)
2153
+ try {
2154
+ const data = await apiGet('/health')
2155
+ setState({ status: 'ready', data, error: null })
2156
+ if (data.settings) setSettings(data.settings)
2157
+ void apiGet('/audit?limit=12')
2158
+ .then((payload) => setAudit({ entries: payload.entries || [], file: payload.file || null }))
2159
+ .catch(() => {})
2160
+ void apiGet('/python/env')
2161
+ .then((payload) => setEnv(payload))
2162
+ .catch(() => {})
2163
+ } catch (error) {
2164
+ setState({ status: 'error', data: null, error: String((error && error.message) || error) })
2165
+ } finally {
2166
+ setBusy(false)
2167
+ }
2168
+ }, [])
2169
+
2170
+ React.useEffect(() => {
2171
+ load()
2172
+ }, [load])
2173
+
2174
+ const data = state.data
2175
+ const apiOk = state.status === 'ready' && data && data.ok === true
2176
+ const sources = (data && data.datasources) || []
2177
+
2178
+ const finish = (message, isError) => {
2179
+ setNotice({ message, isError: Boolean(isError) })
2180
+ setView({ mode: 'list' })
2181
+ load()
2182
+ }
2183
+
2184
+ const save = (source) => finish(fmt(t('notice.saved'), { name: source.name || source.id }), false)
2185
+
2186
+ const remove = async (source) => {
2187
+ if (pendingDelete !== source.id) {
2188
+ setPendingDelete(source.id)
2189
+ return
2190
+ }
2191
+ setPendingDelete(null)
2192
+ try {
2193
+ await apiPost('/datasources/delete', { id: source.id })
2194
+ finish(fmt(t('notice.deleted'), { name: source.name || source.id }), false)
2195
+ } catch (error) {
2196
+ setNotice({ message: String((error && error.message) || error), isError: true })
2197
+ }
2198
+ }
2199
+
2200
+ const toggleSelected = (id) => {
2201
+ setPendingBulkDelete(false)
2202
+ setSelected((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
2203
+ }
2204
+
2205
+ const runBulk = async (action, extra = {}) => {
2206
+ if (selected.length === 0) return
2207
+ setBusy(true)
2208
+ try {
2209
+ const payload = await apiPost('/datasources/bulk', { action, ids: selected, ...extra })
2210
+ const affected = Number(payload && payload.affected) || selected.length
2211
+ setSelected([])
2212
+ setPendingBulkDelete(false)
2213
+ setBulkGroup('')
2214
+ if (action === 'group') {
2215
+ const name = String(extra.group || '').trim() || t('group.unassigned')
2216
+ finish(t('notice.bulkGrouped').replace('{n}', String(affected)).replace('{group}', name), false)
2217
+ } else {
2218
+ finish(t('notice.bulkDone').replace('{n}', String(affected)), false)
2219
+ }
2220
+ } catch (error) {
2221
+ setNotice({ message: String((error && error.message) || error), isError: true })
2222
+ } finally {
2223
+ setBusy(false)
2224
+ }
2225
+ }
2226
+
2227
+ const bulkDelete = async () => {
2228
+ if (selected.length === 0) return
2229
+ if (!pendingBulkDelete) {
2230
+ setPendingBulkDelete(true)
2231
+ return
2232
+ }
2233
+ await runBulk('delete')
2234
+ }
2235
+
2236
+ const makeDefault = async (source) => {
2237
+ try {
2238
+ await apiPost('/datasources/save', {
2239
+ id: source.id,
2240
+ name: source.name,
2241
+ kind: source.kind,
2242
+ readOnly: source.readOnly,
2243
+ isDefault: true,
2244
+ config: source.config,
2245
+ })
2246
+ finish(fmt(t('notice.default'), { name: source.name || source.id }), false)
2247
+ } catch (error) {
2248
+ setNotice({ message: String((error && error.message) || error), isError: true })
2249
+ }
2250
+ }
2251
+
2252
+ const probe = async (source) => { setProbing(source.id)
2253
+ try {
2254
+ const result = await apiPost('/datasources/test', { id: source.id })
2255
+ const probeInfo = result.probe || {}
2256
+ setNotice({
2257
+ message: probeInfo.ok
2258
+ ? fmt(t('notice.probeOk'), { name: source.name, ms: probeInfo.latencyMs })
2259
+ : fmt(t('notice.probeFail'), { name: source.name, message: probeInfo.message }),
2260
+ isError: !probeInfo.ok,
2261
+ })
2262
+ load()
2263
+ } catch (error) {
2264
+ setNotice({ message: String((error && error.message) || error), isError: true })
2265
+ } finally {
2266
+ setProbing(null)
2267
+ }
2268
+ }
2269
+
2270
+ const toggleWrite = async (next) => {
2271
+ try {
2272
+ const payload = await apiPost('/settings', { allowWrite: next })
2273
+ setSettings(payload.settings || { allowWrite: next })
2274
+ setNotice({ message: next ? t('notice.writeOn') : t('notice.writeOff'), isError: false })
2275
+ load()
2276
+ } catch (error) {
2277
+ setNotice({ message: String((error && error.message) || error), isError: true })
2278
+ }
2279
+ }
2280
+
2281
+ const installDeps = async () => {
2282
+ setInstalling(true)
2283
+ setNotice(null)
2284
+ try {
2285
+ const payload = await apiPost('/python/setup', {})
2286
+ if (payload && payload.python) setEnv(payload.python)
2287
+ setNotice({ message: payload && payload.ok ? t('notice.depsOk') : t('notice.depsFail'), isError: !(payload && payload.ok) })
2288
+ load()
2289
+ } catch (error) {
2290
+ setNotice({ message: `${t('notice.depsFail')} ${String((error && error.message) || error)}`, isError: true })
2291
+ } finally {
2292
+ setInstalling(false)
2293
+ }
2294
+ }
2295
+
2296
+ /** Declared groups plus any group that a source already carries. */
2297
+ const groupNames = (() => {
2298
+ const seen = new Set()
2299
+ const declared = data && Array.isArray(data.groups) ? data.groups : []
2300
+ for (const name of declared) {
2301
+ const key = String(name || '').trim()
2302
+ if (key) seen.add(key)
2303
+ }
2304
+ for (const source of sources) {
2305
+ const key = String(source.group || '').trim()
2306
+ if (key) seen.add(key)
2307
+ }
2308
+ return [...seen].sort((a, b) => a.localeCompare(b))
2309
+ })()
2310
+
2311
+ const reportError = (error) => {
2312
+ setNotice({ message: String((error && error.message) || error), isError: true })
2313
+ }
2314
+
2315
+ const createGroup = async () => {
2316
+ const name = groupDraft.trim()
2317
+ if (!name) return
2318
+ try {
2319
+ await apiPost('/groups/save', { name })
2320
+ setGroupDraft('')
2321
+ setNotice({ message: t('notice.groupSaved').replace('{name}', name), isError: false })
2322
+ load()
2323
+ } catch (error) {
2324
+ reportError(error)
2325
+ }
2326
+ }
2327
+
2328
+ const renameGroup = async () => {
2329
+ if (!editingGroup) return
2330
+ const name = String(editingGroup.name || '').trim()
2331
+ if (!name) return
2332
+ try {
2333
+ await apiPost('/groups/save', { name, from: editingGroup.from })
2334
+ setEditingGroup(null)
2335
+ setNotice({ message: t('notice.groupSaved').replace('{name}', name), isError: false })
2336
+ load()
2337
+ } catch (error) {
2338
+ reportError(error)
2339
+ }
2340
+ }
2341
+
2342
+ const removeGroup = async (name) => {
2343
+ if (pendingGroupDelete !== name) {
2344
+ setPendingGroupDelete(name)
2345
+ return
2346
+ }
2347
+ setPendingGroupDelete(null)
2348
+ try {
2349
+ await apiPost('/groups/delete', { name })
2350
+ setNotice({ message: t('notice.groupDeleted').replace('{name}', name), isError: false })
2351
+ load()
2352
+ } catch (error) {
2353
+ reportError(error)
2354
+ }
2355
+ }
2356
+
2357
+ const moveToGroup = async (id, name) => {
2358
+ try {
2359
+ await apiPost('/datasources/bulk', { action: 'group', ids: [id], group: name })
2360
+ const label = name || t('group.unassigned')
2361
+ setNotice({ message: t('notice.moved').replace('{group}', label), isError: false })
2362
+ load()
2363
+ } catch (error) {
2364
+ reportError(error)
2365
+ }
2366
+ }
2367
+
2368
+ const toggleGroup = (items) => {
2369
+ const ids = items.map((item) => item.id)
2370
+ const allChecked = ids.length > 0 && ids.every((id) => selected.includes(id))
2371
+ setPendingBulkDelete(false)
2372
+ setSelected((prev) => (allChecked ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])]))
2373
+ }
2374
+
2375
+ const renderItem = (source) => {
2376
+ const probeInfo = source.lastProbe
2377
+ const checked = selected.includes(source.id)
2378
+ return h(
2379
+ 'li',
2380
+ {
2381
+ className: 'dbmcp-item' + (checked ? ' is-selected' : '') + (draggingId === source.id ? ' is-dragging' : ''),
2382
+ key: source.id,
2383
+ draggable: true,
2384
+ title: t('group.dragHint'),
2385
+ onDragStart: (event) => {
2386
+ setDraggingId(source.id)
2387
+ try {
2388
+ event.dataTransfer.setData('text/plain', source.id)
2389
+ event.dataTransfer.effectAllowed = 'move'
2390
+ } catch (error) {
2391
+ /* some shells hand out a read-only dataTransfer; draggingId still carries the id */
2392
+ }
2393
+ },
2394
+ onDragEnd: () => setDraggingId(null),
2395
+ },
2396
+ h(
2397
+ 'label',
2398
+ { className: 'dbmcp-item-pick', title: source.name || source.id },
2399
+ h('input', {
2400
+ type: 'checkbox',
2401
+ checked,
2402
+ onChange: () => toggleSelected(source.id),
2403
+ 'aria-label': source.name || source.id,
2404
+ }),
2405
+ ),
2406
+ h(
2407
+ 'div',
2408
+ { className: 'dbmcp-item-main', onClick: () => toggleSelected(source.id) },
2409
+ h(
2410
+ 'div',
2411
+ { className: 'dbmcp-item-name' },
2412
+ h('span', null, source.name || source.id),
2413
+ h('span', { className: 'dbmcp-pill' }, source.kind),
2414
+ source.isDefault ? h('span', { className: 'dbmcp-pill' }, t('sources.default')) : null,
2415
+ h('span', { className: 'dbmcp-pill' }, source.readOnly ? t('sources.readonly') : t('sources.readwrite')),
2416
+ ),
2417
+ h('div', { className: 'dbmcp-item-target' }, [source.target, source.id].filter(Boolean).join(' · ')),
2418
+ h(
2419
+ 'div',
2420
+ { className: 'dbmcp-probe ' + (probeInfo ? (probeInfo.ok ? 'is-ok' : 'is-error') : '') },
2421
+ probeInfo
2422
+ ? `${probeInfo.ok ? '●' : '○'} ${probeInfo.message}`
2423
+ : t('sources.neverProbed'),
2424
+ ),
2425
+ ),
2426
+ h(
2427
+ 'div',
2428
+ { className: 'dbmcp-item-actions', onClick: (event) => event.stopPropagation() },
2429
+ h(
2430
+ 'button',
2431
+ { type: 'button', className: 'dbmcp-btn is-small', disabled: probing === source.id, onClick: () => probe(source) },
2432
+ probing === source.id ? t('action.testing') : t('action.test'),
2433
+ ),
2434
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setView({ mode: 'schema', source }) }, t('action.schema')),
2435
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setView({ mode: 'edit', source }) }, t('action.edit')),
2436
+ source.isDefault
2437
+ ? null
2438
+ : h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => makeDefault(source) }, t('action.makeDefault')),
2439
+ h(
2440
+ 'button',
2441
+ {
2442
+ type: 'button',
2443
+ className: 'dbmcp-btn is-small is-danger',
2444
+ onClick: () => remove(source),
2445
+ onBlur: () => setPendingDelete((prev) => (prev === source.id ? null : prev)),
2446
+ },
2447
+ pendingDelete === source.id ? t('action.deleteConfirm') : t('action.delete'),
2448
+ ),
2449
+ ),
2450
+ )
2451
+ }
2452
+
2453
+ /** One group: a head with a select-all box and rename/delete, plus a drop zone. */
2454
+ const renderGroup = (name, items) => {
2455
+ const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id))
2456
+ const renaming = editingGroup !== null && editingGroup.from === name
2457
+ const dropTarget = name
2458
+ return h(
2459
+ 'section',
2460
+ {
2461
+ className: 'dbmcp-group' + (dragOverGroup === dropTarget ? ' is-over' : ''),
2462
+ key: name || '__none',
2463
+ onDragOver: (event) => {
2464
+ event.preventDefault()
2465
+ if (dragOverGroup !== dropTarget) setDragOverGroup(dropTarget)
2466
+ },
2467
+ onDragLeave: () => setDragOverGroup((prev) => (prev === dropTarget ? null : prev)),
2468
+ onDrop: (event) => {
2469
+ event.preventDefault()
2470
+ setDragOverGroup(null)
2471
+ let id = draggingId
2472
+ try {
2473
+ id = event.dataTransfer.getData('text/plain') || id
2474
+ } catch (error) {
2475
+ /* fall back to the id remembered on dragstart */
2476
+ }
2477
+ setDraggingId(null)
2478
+ if (id) void moveToGroup(id, name)
2479
+ },
2480
+ },
2481
+ h(
2482
+ 'header',
2483
+ { className: 'dbmcp-group-head' },
2484
+ h('input', {
2485
+ type: 'checkbox',
2486
+ className: 'dbmcp-group-pick',
2487
+ checked: allChecked,
2488
+ onChange: () => toggleGroup(items),
2489
+ title: t('group.selectAll'),
2490
+ 'aria-label': t('group.selectAll'),
2491
+ }),
2492
+ renaming
2493
+ ? h(
2494
+ 'form',
2495
+ {
2496
+ className: 'dbmcp-group-edit',
2497
+ onSubmit: (event) => {
2498
+ event.preventDefault()
2499
+ void renameGroup()
2500
+ },
2501
+ },
2502
+ h(TextInput, {
2503
+ value: editingGroup.name,
2504
+ onChange: (value) => setEditingGroup({ from: name, name: value }),
2505
+ autoFocus: true,
2506
+ }),
2507
+ h('button', { type: 'submit', className: 'dbmcp-btn is-small is-primary' }, t('group.save')),
2508
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setEditingGroup(null) }, t('group.cancel')),
2509
+ )
2510
+ : h('span', { className: 'dbmcp-group-name' }, name || t('group.unassigned')),
2511
+ h('span', { className: 'dbmcp-hint' }, t('group.count').replace('{n}', String(items.length))),
2512
+ name === '' || renaming
2513
+ ? null
2514
+ : h(
2515
+ 'span',
2516
+ { className: 'dbmcp-group-actions' },
2517
+ h(
2518
+ 'button',
2519
+ { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setEditingGroup({ from: name, name }) },
2520
+ t('group.rename'),
2521
+ ),
2522
+ h(
2523
+ 'button',
2524
+ {
2525
+ type: 'button',
2526
+ className: 'dbmcp-btn is-small is-danger',
2527
+ onClick: () => void removeGroup(name),
2528
+ onBlur: () => setPendingGroupDelete((prev) => (prev === name ? null : prev)),
2529
+ },
2530
+ pendingGroupDelete === name ? t('group.deleteConfirm') : t('group.delete'),
2531
+ ),
2532
+ ),
2533
+ ),
2534
+ items.length === 0
2535
+ ? h('div', { className: 'dbmcp-hint dbmcp-group-empty' }, t('group.empty'))
2536
+ : h('ul', { className: 'dbmcp-list' }, items.map(renderItem)),
2537
+ )
2538
+ }
2539
+
2540
+ const renderGroupCreate = () =>
2541
+ h(
2542
+ 'form',
2543
+ {
2544
+ className: 'dbmcp-group-create',
2545
+ onSubmit: (event) => {
2546
+ event.preventDefault()
2547
+ void createGroup()
2548
+ },
2549
+ },
2550
+ h(TextInput, {
2551
+ value: groupDraft,
2552
+ onChange: setGroupDraft,
2553
+ placeholder: t('group.newPlaceholder'),
2554
+ title: t('group.new'),
2555
+ }),
2556
+ h('button', { type: 'submit', className: 'dbmcp-btn is-small', disabled: !groupDraft.trim() }, t('group.add')),
2557
+ )
2558
+
2559
+ const renderList = () => {
2560
+ if (sources.length === 0 && groupNames.length === 0) {
2561
+ return h(
2562
+ 'div',
2563
+ { className: 'dbmcp-empty' },
2564
+ h('strong', null, t('sources.empty.title')),
2565
+ h('p', null, t('sources.empty.body')),
2566
+ )
2567
+ }
2568
+ const buckets = new Map()
2569
+ for (const name of groupNames) buckets.set(name, [])
2570
+ for (const source of sources) {
2571
+ const key = String(source.group || '').trim()
2572
+ if (!buckets.has(key)) buckets.set(key, [])
2573
+ buckets.get(key).push(source)
2574
+ }
2575
+ const order = groupNames.slice()
2576
+ if (buckets.has('')) order.push('')
2577
+ if (order.length === 1 && !order[0]) {
2578
+ return h('ul', { className: 'dbmcp-list' }, buckets.get('').map(renderItem))
2579
+ }
2580
+ return h(
2581
+ 'div',
2582
+ { className: 'dbmcp-groups' },
2583
+ order.map((name) => renderGroup(name, buckets.get(name) || [])),
2584
+ )
2585
+ }
2586
+
2587
+
2588
+ const renderBulk = () => {
2589
+ if (selected.length === 0) return null
2590
+ const count = selected.length
2591
+ return h(
2592
+ 'div',
2593
+ { className: 'dbmcp-bulk' },
2594
+ h('span', { className: 'dbmcp-bulk-count' }, t('bulk.selected').replace('{n}', String(count))),
2595
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', disabled: busy, onClick: () => runBulk('test') }, t('bulk.test')),
2596
+ h(
2597
+ 'button',
2598
+ { type: 'button', className: 'dbmcp-btn is-small', disabled: busy, onClick: () => runBulk('readOnly', { readOnly: true }) },
2599
+ t('bulk.readonly'),
2600
+ ),
2601
+ h(
2602
+ 'button',
2603
+ { type: 'button', className: 'dbmcp-btn is-small', disabled: busy, onClick: () => runBulk('readOnly', { readOnly: false }) },
2604
+ t('bulk.readwrite'),
2605
+ ),
2606
+ h(
2607
+ 'div',
2608
+ { className: 'dbmcp-bulk-group' },
2609
+ h(TextInput, {
2610
+ value: bulkGroup,
2611
+ onChange: setBulkGroup,
2612
+ placeholder: t('bulk.groupPlaceholder'),
2613
+ title: t('bulk.group'),
2614
+ }),
2615
+ h(
2616
+ 'button',
2617
+ { type: 'button', className: 'dbmcp-btn is-small', disabled: busy, onClick: () => runBulk('group', { group: bulkGroup }) },
2618
+ t('bulk.apply'),
2619
+ ),
2620
+ ),
2621
+ h(
2622
+ 'button',
2623
+ {
2624
+ type: 'button',
2625
+ className: 'dbmcp-btn is-small is-danger',
2626
+ disabled: busy,
2627
+ onClick: () => bulkDelete(),
2628
+ onBlur: () => setPendingBulkDelete(false),
2629
+ },
2630
+ pendingBulkDelete ? t('bulk.deleteConfirm').replace('{n}', String(count)) : t('bulk.delete'),
2631
+ ),
2632
+ h(
2633
+ 'button',
2634
+ { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setSelected([]) },
2635
+ t('bulk.clear'),
2636
+ ),
2637
+ )
2638
+ }
2639
+
2640
+ let body
2641
+ if (state.status === 'loading') {
2642
+ body = h('div', { className: 'dbmcp-empty' }, t('loading'))
2643
+ } else if (state.status === 'error') {
2644
+ body = h(
2645
+ 'div',
2646
+ { className: 'dbmcp-error' },
2647
+ h('div', null, t('error.title')),
2648
+ h('code', null, state.error),
2649
+ h('button', { type: 'button', className: 'dbmcp-btn', onClick: load }, t('error.retry')),
2650
+ )
2651
+ } else if (view.mode === 'create') {
2652
+ body = h(Card, { title: null }, h(SourceForm, {
2653
+ t,
2654
+ draft: emptyDraft(),
2655
+ editing: null,
2656
+ takenIds: sources.map((item) => item.id),
2657
+ onSaved: save,
2658
+ onCancel: () => setView({ mode: 'list' }),
2659
+ }))
2660
+ } else if (view.mode === 'edit') {
2661
+ body = h(Card, {
2662
+ title: fmt(t('form.edit'), { name: view.source.name || view.source.id }),
2663
+ children: h(SourceForm, {
2664
+ t,
2665
+ draft: draftFromSource(view.source),
2666
+ editing: view.source,
2667
+ takenIds: sources.map((item) => item.id),
2668
+ onSaved: save,
2669
+ onCancel: () => setView({ mode: 'list' }),
2670
+ }),
2671
+ })
2672
+ } else if (view.mode === 'schema') {
2673
+ body = h(Card, { title: fmt(t('schema.title'), { name: view.source.name || view.source.id }) },
2674
+ h(
2675
+ 'div',
2676
+ { className: 'dbmcp-schema-head' },
2677
+ h(
2678
+ 'button',
2679
+ { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setView({ mode: 'list' }) },
2680
+ `← ${t('action.backToSources')}`,
2681
+ ),
2682
+ ),
2683
+ h(SchemaView, {
2684
+ t,
2685
+ source: view.source,
2686
+ onBack: () => setView({ mode: 'list' }),
2687
+ }),
2688
+ )
2689
+ } else if (view.mode === 'import') {
2690
+ body = h(Card, { title: null }, h(ImportView, {
2691
+ t,
2692
+ existingIds: sources.map((item) => item.id),
2693
+ onImported: (payload) => {
2694
+ const count = (payload.imported || []).length
2695
+ const skippedCount = (payload.skipped || []).length
2696
+ const message =
2697
+ skippedCount > 0
2698
+ ? t('notice.importedSkipped').replace('{n}', String(count)).replace('{m}', String(skippedCount))
2699
+ : fmt(t('notice.imported'), { n: count })
2700
+ finish(message, false)
2701
+ },
2702
+ onCancel: () => setView({ mode: 'list' }),
2703
+ }))
2704
+ } else {
2705
+ body = h(
2706
+ 'div',
2707
+ { className: 'dbmcp-grid is-single' },
2708
+ tab === 'list'
2709
+ ? h(
2710
+ Card,
2711
+ {
2712
+ title: t('tab.list'),
2713
+ aside: t('sources.count').replace('{n}', String(sources.length)),
2714
+ actions: [
2715
+ h('button', { key: 'refresh', type: 'button', className: 'dbmcp-btn is-small', disabled: busy, onClick: load }, t('action.refresh')),
2716
+ h(
2717
+ 'button',
2718
+ {
2719
+ key: 'restart',
2720
+ type: 'button',
2721
+ className: 'dbmcp-btn is-small',
2722
+ disabled: busy,
2723
+ title: t('action.restartMcpHint'),
2724
+ onClick: async () => {
2725
+ setBusy(true)
2726
+ try {
2727
+ const result = await apiPost('/mcp/restart', {})
2728
+ setNotice({
2729
+ message: result.ok
2730
+ ? t('notice.mcpReady').replace('{count}', String((result.tools || []).length))
2731
+ : `${t('diag.mcpFailed')} ${result.error || ''}`,
2732
+ isError: !result.ok,
2733
+ })
2734
+ await load()
2735
+ } catch (error) {
2736
+ setNotice({ message: String((error && error.message) || error), isError: true })
2737
+ } finally {
2738
+ setBusy(false)
2739
+ }
2740
+ },
2741
+ },
2742
+ t('action.restartMcp'),
2743
+ ),
2744
+ ],
2745
+ },
2746
+ renderGroupCreate(),
2747
+ renderBulk(),
2748
+ renderList(),
2749
+ )
2750
+ : null,
2751
+ tab !== 'runtime'
2752
+ ? null
2753
+ : h(
2754
+ Card,
2755
+ { title: t('diag.title') },
2756
+ h(
2757
+ 'div',
2758
+ { className: 'dbmcp-rows' },
2759
+ h(Row, { label: t('diag.hostApi') }, h('span', { className: 'dbmcp-dot is-ok' }), t('state.connected')),
2760
+ h(Row, { label: t('diag.node') }, (data.host && data.host.node) || '-'),
2761
+ h(Row, { label: t('diag.plugin') }, `${data.plugin.name} ${data.plugin.version} · ${data.plugin.phase}`),
2762
+ h(Row, { label: t('diag.python') }, pythonLabel(t, data.python)),
2763
+ h(Row, { label: t('diag.runtimeDir') }, (data.runtime && data.runtime.dir) || '-'),
2764
+ h(
2765
+ Row,
2766
+ { label: t('diag.registry') },
2767
+ data.runtime && data.runtime.registryError
2768
+ ? `${data.runtime.registryFile} — ${data.runtime.registryError}`
2769
+ : (data.runtime && data.runtime.registryFile) || '-',
2770
+ ),
2771
+ h(Row, { label: t('diag.sources') }, String(sources.length)),
2772
+ h(
2773
+ Row,
2774
+ { label: t('diag.mcp') },
2775
+ `${data.mcp.serverName} · ${
2776
+ data.mcp.wired
2777
+ ? t('diag.mcpLive').replace('{count}', String((data.mcp.tools || []).length))
2778
+ : data.mcp.phase === 'failed'
2779
+ ? `${t('diag.mcpFailed')} ${data.mcp.error || ''}`.trim()
2780
+ : t('diag.mcpPending')
2781
+ }`,
2782
+ ),
2783
+ h(
2784
+ Row,
2785
+ { label: t('diag.write') },
2786
+ h('input', {
2787
+ type: 'checkbox',
2788
+ className: 'dbmcp-check',
2789
+ checked: Boolean(settings && settings.allowWrite),
2790
+ onChange: (event) => toggleWrite(event.target.checked),
2791
+ }),
2792
+ h(
2793
+ 'span',
2794
+ { className: 'dbmcp-hint' },
2795
+ settings && settings.allowWrite ? t('diag.writeOn') : t('diag.writeOff'),
2796
+ ),
2797
+ ),
2798
+ h(
2799
+ Row,
2800
+ { label: t('diag.venv') },
2801
+ env && env.venvPython ? env.venvPython : h('span', { className: 'dbmcp-hint' }, t('diag.venvMissing')),
2802
+ ),
2803
+ h(
2804
+ Row,
2805
+ { label: t('diag.packages') },
2806
+ h('span', { className: 'dbmcp-hint' }, (env && env.packages) || '—'),
2807
+ h(
2808
+ 'button',
2809
+ {
2810
+ type: 'button',
2811
+ className: 'dbmcp-btn is-small',
2812
+ disabled: installing,
2813
+ title: t('diag.venvHint'),
2814
+ onClick: installDeps,
2815
+ },
2816
+ installing ? t('action.installing') : t('action.installDeps'),
2817
+ ),
2818
+ ),
2819
+ data.mcp && data.mcp.phase === 'failed' && /tools`? service/.test(String(data.mcp.error || ''))
2820
+ ? h(Row, { label: t('diag.tip') }, h('span', { className: 'dbmcp-hint' }, t('diag.restartNeeded')))
2821
+ : null,
2822
+ h(Row, { label: t('diag.uptime') }, formatUptime(data.host && data.host.uptimeMs)),
2823
+ ),
2824
+ ),
2825
+ tab !== 'audit'
2826
+ ? null
2827
+ : h(
2828
+ Card,
2829
+ { title: t('audit.title'), aside: audit.entries.length ? String(audit.entries.length) : null },
2830
+ h(
2831
+ 'div',
2832
+ { className: 'dbmcp-audit-list' },
2833
+ audit.entries.length === 0
2834
+ ? h('div', { className: 'dbmcp-empty' }, t('audit.empty'))
2835
+ : audit.entries.map((entry, index) =>
2836
+ h(
2837
+ 'div',
2838
+ { className: 'dbmcp-audit', key: `${entry.at || 'entry'}-${index}` },
2839
+ h('span', { className: 'dbmcp-audit-time' }, String(entry.at || '').slice(11, 19)),
2840
+ h('span', { className: 'dbmcp-audit-tool' }, entry.tool || '?'),
2841
+ h('span', { className: 'dbmcp-pill' }, entry.datasource || t('sources.default')),
2842
+ h('span', { className: 'dbmcp-hint' }, `${entry.ms ?? 0}ms`),
2843
+ h(
2844
+ 'span',
2845
+ { className: entry.ok ? 'dbmcp-audit-ok is-ok' : 'dbmcp-audit-ok is-error' },
2846
+ entry.ok ? t('audit.ok') : t('audit.err'),
2847
+ ),
2848
+ typeof entry.rows === 'number'
2849
+ ? h('span', { className: 'dbmcp-hint' }, t('audit.rows').replace('{n}', String(entry.rows)))
2850
+ : null,
2851
+ entry.sql ? h('code', { className: 'dbmcp-audit-sql' }, entry.sql) : null,
2852
+ entry.error ? h('code', { className: 'dbmcp-audit-sql is-error' }, entry.error) : null,
2853
+ ),
2854
+ ),
2855
+ h('p', { className: 'dbmcp-hint' }, t('audit.hint')),
2856
+ audit.file ? h('p', { className: 'dbmcp-hint' }, `${t('audit.file')}: ${audit.file}`) : null,
2857
+ ),
2858
+ ),
2859
+ )
2860
+ }
2861
+
2862
+ return h(
2863
+ 'div',
2864
+ { className: 'dbmcp-page' },
2865
+ h('style', null, CSS),
2866
+ h(
2867
+ 'header',
2868
+ { className: 'dbmcp-masthead' },
2869
+ h(
2870
+ 'button',
2871
+ { type: 'button', className: 'dbmcp-back', onClick: backToChat },
2872
+ h('span', { className: 'dbmcp-back-caret', 'aria-hidden': 'true' }, '‹'),
2873
+ t('header.backToChat'),
2874
+ ),
2875
+ h('h1', null, t('panel.title')),
2876
+ h('span', { className: 'dbmcp-sub' }, t('panel.subtitle')),
2877
+ h('span', { className: 'dbmcp-masthead-spacer' }),
2878
+ h(
2879
+ 'span',
2880
+ { className: 'dbmcp-pill' },
2881
+ h('span', { className: 'dbmcp-dot ' + (apiOk ? 'is-ok' : state.status === 'error' ? 'is-error' : '') }),
2882
+ apiOk ? t('state.connected') : state.status === 'loading' ? t('state.loading') : t('state.failed'),
2883
+ ),
2884
+ data && data.plugin ? h('span', { className: 'dbmcp-pill' }, `${data.plugin.name} ${data.plugin.version}`) : null,
2885
+ ),
2886
+ h(
2887
+ 'nav',
2888
+ { className: 'dbmcp-tabs', role: 'tablist' },
2889
+ TABS.map((entry) =>
2890
+ h(
2891
+ 'button',
2892
+ {
2893
+ key: entry.id,
2894
+ type: 'button',
2895
+ role: 'tab',
2896
+ 'aria-selected': tab === entry.id,
2897
+ className: 'dbmcp-tab' + (tab === entry.id ? ' is-active' : ''),
2898
+ onClick: () => selectTab(entry.id),
2899
+ },
2900
+ tabLabel(t, entry.id),
2901
+ ),
2902
+ ),
2903
+ ),
2904
+ notice
2905
+ ? h(
2906
+ 'div',
2907
+ { className: 'dbmcp-notice' + (notice.isError ? ' is-error' : '') },
2908
+ h('span', null, notice.message),
2909
+ h('button', { type: 'button', className: 'dbmcp-btn is-small', onClick: () => setNotice(null) }, '×'),
2910
+ )
2911
+ : null,
2912
+ body,
2913
+ )
2914
+ }
2915
+
2916
+ return {
2917
+ inject: ['slots', 'locale', 'layout'],
2918
+ /** Parser and components exposed for the offline tests in dev/. */
2919
+ __test: { parseConnectionText, uniqueId, slugId, canonicalUrl, emptyDraft, draftFromSource, SourceForm, SchemaView, Breadcrumbs, MainPanel },
2920
+ apply(ctx) {
2921
+ localeService = ctx.locale
2922
+ layoutService = ctx.layout || null
2923
+ ctx.effect(() => ctx.locale.register(NS, 'en', DICT.en))
2924
+ ctx.effect(() => ctx.locale.register(NS, 'zh', DICT.zh))
2925
+
2926
+ const t = ctx.locale.bind(NS)
2927
+
2928
+ ctx.slots.inject('sidebar.panellist', () =>
2929
+ ctx.slots.register(
2930
+ { name: 'sidebar.panellist', id: 'db-mcp', order: 25, label: () => t('panel.title') },
2931
+ SidebarIcon,
2932
+ ),
2933
+ )
2934
+
2935
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'db-mcp' }, MainPanel))
2936
+ },
2937
+ }
2938
+ },
2939
+ })