dsh-d365-kit 0.34.1 → 0.34.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/client.js CHANGED
@@ -1,1818 +1,1818 @@
1
- /**
2
- * Client half: the "D365 工具箱" settings page, split into tabs.
3
- *
4
- * Environments the environment list, secrets, default environment
5
- * Permissions which capabilities are granted to which environment
6
- * Pending plans the agent prepared; the only place they can be executed
7
- * General tool parameters that are not per-environment
8
- *
9
- * Data comes from the authenticated `/api/d365-meta/*` routes; the environment
10
- * list is the same storage the `d365_configure` tool writes, so the page can
11
- * never drift from what the agent reads.
12
- */
13
- window.__ModuleLoader__.load({
14
- id: 'dsh-d365-kit',
15
- factory(require) {
16
- const React = require('react')
17
- const h = React.createElement
18
-
19
- /**
20
- * Header every route that can change an environment expects.
21
- *
22
- * The `/api` channel strips `Origin`, so this header is what proves the
23
- * request came from this page: another site cannot send it without a CORS
24
- * preflight, which these routes never answer.
25
- */
26
- const WRITE_HEADERS = { 'content-type': 'application/json', 'x-d365-meta-write': '1' }
27
-
28
- const DICT = {
29
- en: {
30
- 'section.title': 'D365 Toolkit',
31
- 'section.desc': 'Connect Dataverse environments, manage read/write permissions and pending changes, review local data and check results.',
32
- 'tab.environments': 'Environments',
33
- 'tab.permissions': 'Permissions',
34
- 'tab.plans': 'Pending changes',
35
- 'tab.data': 'Data',
36
- 'tab.general': 'General',
37
- 'data.title': 'Local data',
38
- 'data.desc': 'Snapshots, reports and query artifacts are stored on this machine. This page shows what is using space and applies the retention policy.',
39
- 'data.total': 'Total',
40
- 'data.class': 'Class',
41
- 'data.count': 'Files',
42
- 'data.size': 'Size',
43
- 'data.policy': 'Retention',
44
- 'data.keepPolicy': 'keep {keep} / {days} days',
45
- 'data.overLimit': 'over {mb} MB — consider cleaning up',
46
- 'data.cleanup': 'Preview cleanup',
47
- 'data.compact': 'Compact snapshots',
48
- 'data.plan': 'Will delete {files} files and free {size}.',
49
- 'data.confirm': 'Delete now',
50
- 'data.deleted': 'Deleted {files} files, freed {size}.',
51
- 'data.compacted': 'Compacted {snapshots} snapshots, freed {size}.',
52
- 'data.nothing': 'Nothing to clean up under the current policy.',
53
- 'data.failed': 'Failed: {error}',
54
- 'add': 'Add environment',
55
- 'save': 'Save',
56
- 'saving': 'Saving…',
57
- 'test': 'Test connection',
58
- 'testing': 'Testing…',
59
- 'remove': 'Remove',
60
- 'label': 'Display name',
61
- 'url': 'Environment URL',
62
- 'cloud': 'Cloud',
63
- 'tenantId': 'Tenant ID',
64
- 'clientId': 'Application user ClientId',
65
- 'secret': 'Client secret',
66
- 'secretSaved': 'Secret saved (source: {source})',
67
- 'clear': 'Remove saved secret',
68
- 'secretHint': 'The secret is written to the local d365.secrets.json in plain text, never to d365.config.json. Keep that file out of Git.',
69
- 'default': 'Default environment',
70
- 'saved': 'Saved.',
71
- 'savedSecrets': 'Saved; the secret went to d365.secrets.json.',
72
- 'loadFailed': 'Could not load the configuration: {error}',
73
- 'saveFailed': 'Could not save: {error}',
74
- 'checkOk': 'Connected: {name} ({version})',
75
- 'checkFail': 'Connection failed: {error}',
76
- 'checkMissing': 'Missing credentials: {list}',
77
- 'issues': 'Configuration warnings',
78
- 'files': 'Config: {config}',
79
- 'secretFile': 'Secrets: {secrets}',
80
- 'empty': 'No environments yet. Start with "Add environment".',
81
- 'urlRequired': 'Fill in the URL for "{key}" first.',
82
- 'duplicate': 'Duplicate key: {key}',
83
- 'existing': 'already saved',
84
- 'keyHint': 'Short key the tools use, e.g. dev.',
85
- 'write.title': 'Write permissions',
86
- 'write.desc': 'Choose what this machine may do in each environment. Metadata reads are on by default; business records need "Read business data". Metadata changes need a capability here and still require a confirm click.',
87
- 'write.gate': 'Every request is checked against the target environment\'s grants below before it leaves this machine; a tool cannot skip the check.',
88
- 'write.envSummary': '{granted} of {total} granted',
89
- 'write.envNote': 'Metadata (tables, fields, forms, views, plugins, solution layers) needs "Read metadata", ticked by default. Untick everything to block even metadata. Business records need "Read business data"; a write also needs its capability here plus a confirm click in the chat.',
90
- 'write.noEnvironments': 'Add an environment first.',
91
- 'write.solution': 'Default solution for changes',
92
- 'write.solutionHint': 'Solution unique name, so exported solutions carry the change',
93
- 'write.savePolicy': 'Save permissions',
94
- 'write.copyToAll': 'Copy to all environments',
95
- 'write.copied': 'Copied into the other environments — review, then save.',
96
- 'write.savedPolicy': 'Permissions saved.',
97
- 'write.saveFailed': 'Could not save: {error}',
98
- 'write.plans': 'Pending changes',
99
- 'write.plansDesc': 'Plans prepared by the agent. Nothing runs until you confirm here or in the chat.',
100
- 'write.noPlans': 'No pending changes. The agent can prepare one with d365_write.',
101
- 'write.execute': 'Review & execute',
102
- 'write.confirmExecute': 'Execute now',
103
- 'write.executing': 'Executing…',
104
- 'write.back': 'Cancel',
105
- 'write.cancelPlan': 'Discard plan',
106
- 'write.warningTitle': 'This writes to a real environment',
107
- 'write.warningBody': 'The following operations will run against {env} immediately. Dataverse metadata changes cannot be rolled back automatically.',
108
- 'write.destructiveBody': 'This includes a deletion that cannot be undone: the column or option value is removed permanently. Check the names before confirming.',
109
- 'write.highRisk': 'destructive',
110
- 'write.executed': 'Done: {ok} succeeded, {failed} failed.',
111
- 'write.skipped': '{count} entries skipped (already present or unchanged).',
112
- 'write.publishing': ' Publishing in the background; the change becomes visible shortly.',
113
- 'write.recent': 'Recent changes',
114
- 'write.recentLine': '{time} · {env} · {ok} ok / {failed} failed',
115
- 'plans.more': '… {count} more',
116
- 'write.publishRunning': 'publishing…',
117
- 'write.published': 'published',
118
- 'write.publishFailed': 'publish failed: {error}',
119
- 'write.publishSkipped': 'nothing to publish',
120
- 'write.republish': 'Publish again',
121
- 'write.republishing': 'Publishing…',
122
- 'write.republishOk': 'Published: {targets}',
123
- 'write.republishFailed': 'Publish failed: {error}',
124
- 'write.noTargets': 'nothing to publish',
125
- 'write.optionSetLabel': 'option set {name}',
126
- 'write.loadFailed': 'Could not load write settings: {error}',
127
- 'general.title': 'Tool parameters',
128
- 'general.desc': 'Numbers the tools use by default. They are stored in d365.config.json next to the environment list.',
129
- 'general.snapshotKeep': 'Snapshots kept per environment',
130
- 'general.snapshotKeepHint': 'Older snapshots are deleted after a sync. 1–50.',
131
- 'general.maxRecords': 'Default row limit',
132
- 'general.maxRecordsHint': 'Used when a tool call passes no value of its own. 1–100000.',
133
- 'general.timeoutMs': 'Request timeout (ms)',
134
- 'general.timeoutMsHint': 'Per HTTP request. 5000–300000.',
135
- 'general.dataDir': 'Local data directory',
136
- 'general.dataDirHint': 'Snapshots, diffs, pending writes and change records live here.',
137
- 'general.save': 'Save parameters',
138
- 'general.saved': 'Parameters saved.',
139
- 'general.loadFailed': 'Could not load parameters: {error}',
140
- 'tab.gitlab': 'GitLab',
141
- 'gitlab.title': 'GitLab',
142
- 'gitlab.desc': 'Used by d365_code_check to read a merge request and check its changed scripts against the environments. Read-only access is enough.',
143
- 'gitlab.url': 'GitLab base URL',
144
- 'gitlab.urlHint': 'For example https://gitlab.example.com — the page stays the same for every project.',
145
- 'gitlab.project': 'Default project',
146
- 'gitlab.projectHint': 'group/project. Used when the merge request link does not carry one.',
147
- 'gitlab.token': 'Access token',
148
- 'gitlab.tokenHint': 'A personal or project token with read_api. Saved to d365.secrets.json, never to d365.config.json.',
149
- 'gitlab.tokenSaved': 'A token is already saved ({source}). Leave empty to keep it.',
150
- 'gitlab.save': 'Save',
151
- 'gitlab.saved': 'Saved.',
152
- 'gitlab.check': 'Test access',
153
- 'gitlab.checking': 'Testing…',
154
- 'gitlab.ok': 'Connected as {user}.',
155
- 'gitlab.clear': 'Remove token',
156
- 'gitlab.cleared': 'Token removed.',
157
- 'gitlab.loadFailed': 'Could not load GitLab settings: {error}',
158
- },
159
- zh: {
160
- 'section.title': 'D365 工具箱',
161
- 'section.desc': '接入 Dataverse 环境,管理读写权限与待确认变更,查看本机数据与检查结果。',
162
- 'tab.environments': '环境',
163
- 'tab.permissions': '权限',
164
- 'tab.plans': '计划',
165
- 'tab.data': '数据',
166
- 'tab.general': '常规',
167
- 'data.title': '本机数据',
168
- 'data.desc': '快照、报告与查询留证都存在本机。这里显示占用情况,并按保留策略清理。',
169
- 'data.total': '合计',
170
- 'data.class': '类别',
171
- 'data.count': '文件',
172
- 'data.size': '占用',
173
- 'data.policy': '保留策略',
174
- 'data.keepPolicy': '保留 {keep} 个 / {days} 天',
175
- 'data.overLimit': '超过 {mb} MB,建议清理',
176
- 'data.cleanup': '预演清理',
177
- 'data.compact': '压缩快照',
178
- 'data.plan': '将删除 {files} 个文件,释放 {size}。',
179
- 'data.confirm': '确认删除',
180
- 'data.deleted': '已删除 {files} 个文件,释放 {size}。',
181
- 'data.compacted': '已压缩 {snapshots} 份快照,释放 {size}。',
182
- 'data.nothing': '按当前策略没有需要清理的内容。',
183
- 'data.failed': '操作失败:{error}',
184
- 'add': '添加环境',
185
- 'save': '保存',
186
- 'saving': '保存中…',
187
- 'test': '测试连接',
188
- 'testing': '测试中…',
189
- 'remove': '删除',
190
- 'label': '显示名',
191
- 'url': '环境 URL',
192
- 'cloud': '云',
193
- 'tenantId': '租户 ID',
194
- 'clientId': '应用用户 ClientId',
195
- 'secret': '客户端密钥',
196
- 'secretSaved': '密钥已保存(来源:{source})',
197
- 'clear': '清除已保存的密钥',
198
- 'secretHint': '密钥以明文写入本机 d365.secrets.json,不会写进 d365.config.json;别把它提交到 Git。',
199
- 'default': '默认环境',
200
- 'saved': '已保存。',
201
- 'savedSecrets': '已保存,密钥写入了 d365.secrets.json。',
202
- 'loadFailed': '读取配置失败:{error}',
203
- 'saveFailed': '保存失败:{error}',
204
- 'checkOk': '连接正常:{name}({version})',
205
- 'checkFail': '连接失败:{error}',
206
- 'checkMissing': '缺少凭据:{list}',
207
- 'issues': '配置告警',
208
- 'files': '配置文件:{config}',
209
- 'secretFile': '密钥文件:{secrets}',
210
- 'empty': '还没有环境,点"添加环境"开始。',
211
- 'urlRequired': '请先填写 "{key}" 的 URL。',
212
- 'duplicate': 'key 重复:{key}',
213
- 'existing': '已保存',
214
- 'keyHint': '工具里用的短名,例如 dev。',
215
- 'write.title': '权限',
216
- 'write.desc': '选择这台机器可以对哪些环境做什么。元数据默认可读;读取业务数据需要勾「读取业务数据」;元数据修改需要先在这里授权,执行前仍要确认。',
217
- 'write.gate': '每一次访问在发出前都会按目标环境核对下面的勾选,任何工具都绕不过这道检查。',
218
- 'write.envSummary': '已授权 {granted}/{total}',
219
- 'write.envNote': '元数据(表、字段、表单、视图、插件、解决方案层)归「读取元数据」管,默认勾上;全部取消就什么都查不到。业务记录需要勾「读取业务数据」;写操作除勾选外,还要在对话里点确认。',
220
- 'write.noEnvironments': '请先添加环境。',
221
- 'write.solution': '变更默认解决方案',
222
- 'write.solutionHint': '解决方案唯一名:填了,导出的解决方案才会带上这次改动',
223
- 'write.savePolicy': '保存权限',
224
- 'write.copyToAll': '复制到其他环境',
225
- 'write.copied': '已复制到其他环境,核对后再保存。',
226
- 'write.savedPolicy': '权限已保存。',
227
- 'write.saveFailed': '保存失败:{error}',
228
- 'write.plans': '待确认的写入',
229
- 'write.plansDesc': 'agent 准备好的计划。在你确认之前,什么都不会执行。',
230
- 'write.noPlans': '没有待确认的变更。可以让 agent 用 d365_write 生成一个计划。',
231
- 'write.execute': '审阅并执行',
232
- 'write.confirmExecute': '确认执行',
233
- 'write.executing': '执行中…',
234
- 'write.back': '返回',
235
- 'write.cancelPlan': '丢弃计划',
236
- 'write.warningTitle': '这是对真实环境的写入',
237
- 'write.warningBody': '以下操作会立即作用到 {env}。Dataverse 的元数据变更无法自动回滚,请先核对操作列表。',
238
- 'write.destructiveBody': '其中包含不可恢复的删除操作:被删的字段或选项值连同其数据一起消失,无法通过重新部署找回。确认前请核对名称。',
239
- 'write.highRisk': '高危',
240
- 'write.executed': '执行完成:成功 {ok} 个,失败 {failed} 个。',
241
- 'write.skipped': '跳过 {count} 条(已存在或无变化)。',
242
- 'write.publishing': '正在后台发布,稍后生效。',
243
- 'write.recent': '最近的变更记录',
244
- 'write.recentLine': '{time} · {env} · 成功 {ok} / 失败 {failed}',
245
- 'plans.more': '… 其余 {count} 个',
246
- 'write.publishRunning': '发布中…',
247
- 'write.published': '已发布',
248
- 'write.publishFailed': '发布失败:{error}',
249
- 'write.publishSkipped': '无需发布',
250
- 'write.republish': '重新发布',
251
- 'write.republishing': '发布中…',
252
- 'write.republishOk': '已发布:{targets}',
253
- 'write.republishFailed': '发布失败:{error}',
254
- 'write.noTargets': '无需发布',
255
- 'write.optionSetLabel': '选项集 {name}',
256
- 'write.loadFailed': '读取写入配置失败:{error}',
257
- 'general.title': '工具参数',
258
- 'general.desc': '工具默认使用的数值,和环境清单一起存在 d365.config.json 里。',
259
- 'general.snapshotKeep': '每个环境保留几份快照',
260
- 'general.snapshotKeepHint': '同步后删除更旧的快照。范围 1–50。',
261
- 'general.maxRecords': '单次返回行数上限',
262
- 'general.maxRecordsHint': '仅在工具调用自己没传值时生效。范围 1–100000。',
263
- 'general.timeoutMs': '请求超时(毫秒)',
264
- 'general.timeoutMsHint': '单个 HTTP 请求的超时。范围 5000–300000。',
265
- 'general.dataDir': '本地数据目录',
266
- 'general.dataDirHint': '快照、差异、待确认计划、变更记录都在这里。',
267
- 'general.save': '保存参数',
268
- 'general.saved': '参数已保存。',
269
- 'general.loadFailed': '读取参数失败:{error}',
270
- 'tab.gitlab': 'GitLab',
271
- 'gitlab.title': 'GitLab',
272
- 'gitlab.desc': '供 d365_code_check 读取 MR、把改动的脚本对照环境做检查。只需要只读权限。',
273
- 'gitlab.url': 'GitLab 地址',
274
- 'gitlab.urlHint': '例如 https://gitlab.example.com,各项目共用同一个页面地址。',
275
- 'gitlab.project': '默认项目',
276
- 'gitlab.projectHint': 'group/project。MR 链接里带项目时以链接为准。',
277
- 'gitlab.token': '访问令牌',
278
- 'gitlab.tokenHint': '勾了 read_api 的个人令牌或项目令牌。存在 d365.secrets.json,不会写进 d365.config.json。',
279
- 'gitlab.tokenSaved': '已有令牌(来源:{source}),留空表示不修改。',
280
- 'gitlab.save': '保存',
281
- 'gitlab.saved': '已保存。',
282
- 'gitlab.check': '测试访问',
283
- 'gitlab.checking': '测试中…',
284
- 'gitlab.ok': '连接正常,当前用户 {user}。',
285
- 'gitlab.clear': '删除令牌',
286
- 'gitlab.cleared': '令牌已删除。',
287
- 'gitlab.loadFailed': '读取 GitLab 设置失败:{error}',
288
- },
289
- }
290
-
291
- /**
292
- * The page translates from its own dictionary; the locale service only says
293
- * *which* language is active. Using the service's lookup directly would turn
294
- * one failed registration into a page of raw keys.
295
- */
296
- let activeLocale = 'en'
297
- let localeService = null
298
- const localeListeners = new Set()
299
-
300
- /** Re-render every mounted panel (locale service arrival or language switch). */
301
- function notifyLocale() {
302
- for (const listener of [...localeListeners]) {
303
- try {
304
- listener()
305
- } catch {
306
- // One crashed panel must not stop the others from re-rendering.
307
- }
308
- }
309
- }
310
-
311
- function localeFrom(id) {
312
- return String(id ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'
313
- }
314
-
315
- /** Active locale id from either read entry the service exposes. */
316
- function activeIdOf(locale) {
317
- try {
318
- const snapshot = locale?.getLocale?.() ?? locale?.getSnapshot?.()
319
- return snapshot?.active
320
- } catch {
321
- return undefined
322
- }
323
- }
324
-
325
- /** Read the live locale on every call: the page must follow DSH's setting. */
326
- function currentLocale() {
327
- const live = activeIdOf(localeService)
328
- if (live) return localeFrom(live)
329
- return activeLocale
330
- }
331
-
332
- function t(key, params) {
333
- const text = DICT[currentLocale()]?.[key] ?? DICT.en[key] ?? key
334
- if (!params) return text
335
- return String(text).replace(/\{(\w+)\}/g, (match, name) => (params[name] ?? match))
336
- }
337
-
338
- /** Re-render a panel when the locale service arrives or the language changes. */
339
- function useLocaleRevision() {
340
- const [, setRevision] = React.useState(0)
341
- React.useEffect(() => {
342
- const listener = () => setRevision((value) => value + 1)
343
- localeListeners.add(listener)
344
- return () => {
345
- localeListeners.delete(listener)
346
- }
347
- }, [])
348
- }
349
-
350
- const T = {
351
- text: 'var(--dsw-alias-label-primary)',
352
- muted: 'var(--dsw-alias-label-secondary)',
353
- border: 'var(--dsw-alias-border-l1)',
354
- borderStrong: 'var(--dsw-alias-border-l2)',
355
- bg: 'var(--dsw-alias-bg-layer-1)',
356
- bg2: 'var(--dsw-alias-bg-layer-2)',
357
- brand: 'var(--dsw-alias-brand-primary)',
358
- error: 'var(--dsw-alias-state-error-primary)',
359
- success: 'var(--dsw-alias-state-success-primary)',
360
- warn: 'var(--dsw-alias-state-warn-primary)',
361
- }
362
-
363
- const inputStyle = {
364
- width: '100%',
365
- boxSizing: 'border-box',
366
- padding: '6px 8px',
367
- borderRadius: '6px',
368
- border: `1px solid ${T.border}`,
369
- background: T.bg2,
370
- color: T.text,
371
- font: 'inherit',
372
- fontSize: '13px',
373
- outline: 'none',
374
- }
375
-
376
- const labelStyle = {
377
- display: 'block',
378
- fontSize: '12px',
379
- color: T.muted,
380
- marginBottom: '4px',
381
- }
382
-
383
- function buttonStyle(kind) {
384
- const base = {
385
- padding: '6px 12px',
386
- borderRadius: '6px',
387
- fontSize: '13px',
388
- font: 'inherit',
389
- cursor: 'pointer',
390
- border: `1px solid ${T.border}`,
391
- background: 'transparent',
392
- color: T.text,
393
- whiteSpace: 'nowrap',
394
- }
395
- if (kind === 'primary') return { ...base, background: T.brand, borderColor: T.brand, color: '#fff' }
396
- if (kind === 'danger') return { ...base, color: T.error, borderColor: 'transparent', padding: '6px 8px' }
397
- return base
398
- }
399
-
400
- const thStyle = {
401
- textAlign: 'left',
402
- padding: '6px 8px',
403
- borderBottom: `1px solid ${T.border}`,
404
- color: T.muted,
405
- fontWeight: 500,
406
- whiteSpace: 'nowrap',
407
- }
408
-
409
- const tdStyle = {
410
- padding: '6px 8px',
411
- borderBottom: `1px solid ${T.border}`,
412
- verticalAlign: 'top',
413
- color: T.text,
414
- }
415
-
416
- /** Human-readable byte size, matching the Host's retention formatter. */
417
- function formatBytesUi(bytes) {
418
- if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
419
- const units = ['B', 'KB', 'MB', 'GB']
420
- let value = bytes
421
- let unit = 0
422
- while (value >= 1024 && unit < units.length - 1) {
423
- value /= 1024
424
- unit += 1
425
- }
426
- return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
427
- }
428
-
429
- function Field({ label, hint, children }) {
430
- return h('label', { style: { display: 'block', minWidth: 0 } },
431
- h('span', { style: labelStyle }, label),
432
- children,
433
- hint ? h('span', { style: { display: 'block', marginTop: '3px', fontSize: '11px', color: T.muted } }, hint) : null,
434
- )
435
- }
436
-
437
- function Hint({ children, kind }) {
438
- const color = kind === 'error' ? T.error : kind === 'ok' ? T.success : T.muted
439
- return h('div', { style: { fontSize: '12px', color, lineHeight: 1.5 } }, children)
440
- }
441
-
442
- /**
443
- * Row identity for React. It must not derive from the editable key: a card
444
- * keyed by its own value is a new element on every keystroke, which unmounts
445
- * the input and drops the caret after one character.
446
- */
447
- let draftSeq = 0
448
- function nextDraftId() {
449
- draftSeq += 1
450
- return `draft-${draftSeq}`
451
- }
452
-
453
- function toDraft(env) {
454
- return {
455
- id: `env-${env.key}`,
456
- key: env.key,
457
- label: env.label ?? '',
458
- url: env.url ?? '',
459
- cloud: env.cloud ?? 'public',
460
- tenantId: env.tenantId ?? '',
461
- // Carried through the draft so a save round-trips them; the server
462
- // deletes any field the request omits.
463
- tenantIdEnv: env.tenantIdEnv ?? '',
464
- clientIdEnv: env.clientIdEnv ?? '',
465
- note: env.note ?? '',
466
- clientId: env.clientId ?? '',
467
- clientSecret: '',
468
- clearSecret: false,
469
- hasSecret: Boolean(env.hasSecret),
470
- secretSource: env.secretSource,
471
- existing: true,
472
- }
473
- }
474
-
475
- // ---------------------------------------------------------------- environments
476
-
477
- function EnvironmentsPanel() {
478
- useLocaleRevision()
479
- const [loading, setLoading] = React.useState(true)
480
- const [error, setError] = React.useState(null)
481
- const [settings, setSettings] = React.useState(null)
482
- const [rows, setRows] = React.useState([])
483
- const [defaultKey, setDefaultKey] = React.useState('')
484
- const [busy, setBusy] = React.useState(false)
485
- const [message, setMessage] = React.useState(null)
486
- const [checks, setChecks] = React.useState({})
487
-
488
- const applySettings = React.useCallback((next) => {
489
- setSettings(next)
490
- setRows((next.environments ?? []).map(toDraft))
491
- setDefaultKey(next.defaultEnvironment ?? '')
492
- }, [])
493
-
494
- React.useEffect(() => {
495
- let alive = true
496
- ;(async () => {
497
- try {
498
- const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
499
- const data = await response.json().catch(() => ({}))
500
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
501
- if (!alive) return
502
- applySettings(data.settings)
503
- setError(null)
504
- } catch (cause) {
505
- if (alive) setError(cause.message)
506
- } finally {
507
- if (alive) setLoading(false)
508
- }
509
- })()
510
- return () => { alive = false }
511
- }, [applySettings])
512
-
513
- const update = (index, patch) => {
514
- setRows((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row)))
515
- }
516
-
517
- const addRow = () => {
518
- let n = 1
519
- while (rows.some((row) => row.key === `env${n}`)) n += 1
520
- setRows((current) => [...current, {
521
- id: nextDraftId(),
522
- key: `env${n}`,
523
- label: '',
524
- url: '',
525
- cloud: 'public',
526
- tenantId: '',
527
- clientId: '',
528
- clientSecret: '',
529
- clearSecret: false,
530
- hasSecret: false,
531
- existing: false,
532
- }])
533
- }
534
-
535
- const removeRow = (index) => {
536
- setRows((current) => current.filter((_, i) => i !== index))
537
- }
538
-
539
- const save = async () => {
540
- const keys = new Set()
541
- for (const row of rows) {
542
- if (!row.url.trim()) {
543
- setMessage({ kind: 'error', text: t('urlRequired', { key: row.key }) })
544
- return
545
- }
546
- if (keys.has(row.key)) {
547
- setMessage({ kind: 'error', text: t('duplicate', { key: row.key }) })
548
- return
549
- }
550
- keys.add(row.key)
551
- }
552
-
553
- const environments = {}
554
- for (const row of rows) {
555
- environments[row.key] = {
556
- label: row.label,
557
- url: row.url,
558
- cloud: row.cloud,
559
- tenantId: row.tenantId,
560
- // These three are not editable here, but they are part of the row:
561
- // the server treats a missing field as "delete it", so dropping them
562
- // would silently strip an env-var credential source or a note.
563
- tenantIdEnv: row.tenantIdEnv ?? '',
564
- clientIdEnv: row.clientIdEnv ?? '',
565
- note: row.note ?? '',
566
- clientId: row.clientId,
567
- clientSecret: row.clientSecret || undefined,
568
- clearSecret: row.clearSecret || undefined,
569
- }
570
- }
571
-
572
- setBusy(true)
573
- setMessage(null)
574
- try {
575
- const response = await fetch('/api/d365-meta/config', {
576
- method: 'POST',
577
- headers: WRITE_HEADERS,
578
- body: JSON.stringify({ defaultEnvironment: defaultKey, environments }),
579
- })
580
- const data = await response.json().catch(() => ({}))
581
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
582
- applySettings(data.settings)
583
- setChecks({})
584
- setMessage({ kind: 'ok', text: data.secretsTouched?.length ? t('savedSecrets') : t('saved') })
585
- } catch (cause) {
586
- setMessage({ kind: 'error', text: t('saveFailed', { error: cause.message }) })
587
- } finally {
588
- setBusy(false)
589
- }
590
- }
591
-
592
- const test = async (row) => {
593
- setChecks((current) => ({ ...current, [row.key]: { status: 'pending' } }))
594
- try {
595
- const response = await fetch('/api/d365-meta/check', {
596
- method: 'POST',
597
- headers: { 'content-type': 'application/json' },
598
- body: JSON.stringify({ key: row.key }),
599
- })
600
- const data = await response.json().catch(() => ({}))
601
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
602
- if (data.ok) {
603
- const org = data.organization ?? {}
604
- setChecks((current) => ({
605
- ...current,
606
- [row.key]: { status: 'ok', text: t('checkOk', { name: org.name ?? '—', version: org.version ?? '?' }) },
607
- }))
608
- return
609
- }
610
- const missing = data.missing?.length
611
- ? ` ${t('checkMissing', { list: data.missing.map((item) => item.kind).join(' / ') })}`
612
- : ''
613
- setChecks((current) => ({
614
- ...current,
615
- [row.key]: { status: 'fail', text: `${t('checkFail', { error: data.error ?? '?' })}${missing}` },
616
- }))
617
- } catch (cause) {
618
- setChecks((current) => ({ ...current, [row.key]: { status: 'fail', text: t('checkFail', { error: cause.message }) } }))
619
- }
620
- }
621
-
622
- if (loading) return h(Hint, null, '…')
623
- if (error) return h(Hint, { kind: 'error' }, t('loadFailed', { error }))
624
-
625
- const cards = rows.map((row, index) => {
626
- const check = checks[row.key]
627
- return h('div', {
628
- key: row.id,
629
- style: {
630
- border: `1px solid ${T.border}`,
631
- borderRadius: '8px',
632
- background: T.bg,
633
- padding: '12px',
634
- display: 'flex',
635
- flexDirection: 'column',
636
- gap: '10px',
637
- },
638
- },
639
- // One row, equal column shapes: every cell is a label plus exactly one
640
- // control of the same height, so the labels and the controls line up.
641
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' } },
642
- h('div', { style: { flex: '0 0 116px', minWidth: '116px' } },
643
- h(Field, { label: 'key' },
644
- row.existing
645
- ? h('input', {
646
- style: { ...inputStyle, background: 'transparent', fontWeight: 600 },
647
- value: row.key,
648
- readOnly: true,
649
- })
650
- : h('input', {
651
- style: inputStyle,
652
- value: row.key,
653
- placeholder: 'dev',
654
- spellCheck: false,
655
- onChange: (event) => update(index, { key: event.target.value.trim() }),
656
- }),
657
- ),
658
- ),
659
- h('div', { style: { flex: '1 1 150px', minWidth: 0 } },
660
- h(Field, { label: t('label') },
661
- h('input', {
662
- style: inputStyle,
663
- value: row.label,
664
- placeholder: row.key,
665
- onChange: (event) => update(index, { label: event.target.value }),
666
- }),
667
- ),
668
- ),
669
- h('div', { style: { flex: '0 0 118px' } },
670
- h(Field, { label: t('cloud') },
671
- h('select', {
672
- style: inputStyle,
673
- value: row.cloud,
674
- onChange: (event) => update(index, { cloud: event.target.value }),
675
- },
676
- (settings?.clouds ?? ['public']).map((cloud) => h('option', { key: cloud, value: cloud }, cloud)),
677
- ),
678
- ),
679
- ),
680
- h('button', {
681
- type: 'button',
682
- style: { ...buttonStyle('danger'), marginLeft: 'auto' },
683
- onClick: () => removeRow(index),
684
- }, t('remove')),
685
- ),
686
- h(Field, { label: t('url') },
687
- h('input', {
688
- style: inputStyle,
689
- value: row.url,
690
- placeholder: 'https://contoso-dev.crm5.dynamics.com',
691
- spellCheck: false,
692
- onChange: (event) => update(index, { url: event.target.value }),
693
- }),
694
- ),
695
- h('div', { style: { display: 'flex', gap: '8px' } },
696
- h('div', { style: { flex: '1 1 50%', minWidth: 0 } },
697
- h(Field, { label: t('tenantId') },
698
- h('input', {
699
- style: inputStyle,
700
- value: row.tenantId,
701
- placeholder: '00000000-0000-0000-0000-000000000000',
702
- spellCheck: false,
703
- onChange: (event) => update(index, { tenantId: event.target.value }),
704
- }),
705
- ),
706
- ),
707
- h('div', { style: { flex: '1 1 50%', minWidth: 0 } },
708
- h(Field, { label: t('clientId') },
709
- h('input', {
710
- style: inputStyle,
711
- value: row.clientId,
712
- placeholder: '00000000-0000-0000-0000-000000000000',
713
- spellCheck: false,
714
- onChange: (event) => update(index, { clientId: event.target.value }),
715
- }),
716
- ),
717
- ),
718
- ),
719
- h(Field, {
720
- label: t('secret'),
721
- hint: row.hasSecret ? t('secretSaved', { source: row.secretSource ?? 'secrets' }) : t('secretHint'),
722
- },
723
- h('input', {
724
- style: inputStyle,
725
- type: 'password',
726
- value: row.clientSecret,
727
- placeholder: row.hasSecret ? '••••••••' : '',
728
- autoComplete: 'new-password',
729
- onChange: (event) => update(index, { clientSecret: event.target.value, clearSecret: false }),
730
- }),
731
- ),
732
- row.hasSecret
733
- ? h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: T.muted } },
734
- h('input', {
735
- type: 'checkbox',
736
- checked: row.clearSecret,
737
- onChange: (event) => update(index, { clearSecret: event.target.checked, clientSecret: '' }),
738
- }),
739
- t('clear'),
740
- )
741
- : null,
742
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
743
- h('button', {
744
- type: 'button',
745
- style: buttonStyle(),
746
- disabled: check?.status === 'pending' || !row.existing,
747
- onClick: () => test(row),
748
- }, check?.status === 'pending' ? t('testing') : t('test')),
749
- check?.status === 'ok' ? h('span', { style: { fontSize: '12px', color: T.success } }, check.text) : null,
750
- check?.status === 'fail' ? h('span', { style: { fontSize: '12px', color: T.error } }, check.text) : null,
751
- ),
752
- )
753
- })
754
-
755
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
756
- settings?.issues?.length
757
- ? h('div', {
758
- style: {
759
- border: `1px solid ${T.warn}`,
760
- borderRadius: '8px',
761
- padding: '10px 12px',
762
- fontSize: '12px',
763
- color: T.text,
764
- display: 'flex',
765
- flexDirection: 'column',
766
- gap: '4px',
767
- },
768
- },
769
- h('strong', { style: { fontWeight: 600 } }, t('issues')),
770
- settings.issues.map((issue, index) => h('div', { key: index, style: { color: T.muted } }, issue)),
771
- )
772
- : null,
773
-
774
- cards.length
775
- ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, cards)
776
- : h(Hint, null, t('empty')),
777
-
778
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' } },
779
- h('button', { type: 'button', style: buttonStyle(), onClick: addRow }, t('add')),
780
- rows.length
781
- ? h('div', { style: { flex: '0 0 200px' } },
782
- h(Field, { label: t('default') },
783
- h('select', {
784
- style: inputStyle,
785
- value: defaultKey,
786
- onChange: (event) => setDefaultKey(event.target.value),
787
- },
788
- rows.map((row) => h('option', { key: row.key, value: row.key }, row.key)),
789
- ),
790
- ),
791
- )
792
- : null,
793
- h('button', {
794
- type: 'button',
795
- style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
796
- disabled: busy,
797
- onClick: save,
798
- }, busy ? t('saving') : t('save')),
799
- ),
800
-
801
- message ? h(Hint, { kind: message.kind }, message.text) : null,
802
-
803
- h('div', { style: { fontSize: '11px', color: T.muted, lineHeight: 1.6 } },
804
- settings?.configFile ? h('div', null, t('files', { config: settings.configFile })) : null,
805
- settings?.secretsFile ? h('div', null, t('secretFile', { secrets: settings.secretsFile })) : null,
806
- ),
807
- )
808
- }
809
-
810
- // ---------------------------------------------------------------- permissions
811
-
812
- function PermissionsPanel() {
813
- useLocaleRevision()
814
- const [loading, setLoading] = React.useState(true)
815
- const [error, setError] = React.useState(null)
816
- const [capabilities, setCapabilities] = React.useState({})
817
- const [environments, setEnvironments] = React.useState([])
818
- const [draft, setDraft] = React.useState({})
819
- const [solution, setSolution] = React.useState('')
820
- const [saving, setSaving] = React.useState(false)
821
- const [message, setMessage] = React.useState(null)
822
- const [copySource, setCopySource] = React.useState('')
823
-
824
- const load = React.useCallback(async () => {
825
- try {
826
- const response = await fetch('/api/d365-meta/write/policy')
827
- const policy = await response.json().catch(() => ({}))
828
- if (!response.ok) throw new Error(policy.error ?? `HTTP ${response.status}`)
829
-
830
- const nextDraft = {}
831
- for (const key of policy.environments ?? []) {
832
- // An environment with no entry has never been configured: it keeps
833
- // the default metadata read. An entry, even an empty one, is the
834
- // whole truth.
835
- const explicit = policy.policy?.environments?.[key]
836
- const granted = Array.isArray(explicit) ? explicit : null
837
- nextDraft[key] = Object.fromEntries(
838
- Object.keys(policy.capabilities ?? {}).map((capability) => [
839
- capability,
840
- granted ? granted.includes(capability) : capability === 'metadata.read',
841
- ]),
842
- )
843
- }
844
- setCapabilities(policy.capabilities ?? {})
845
- setEnvironments(policy.environments ?? [])
846
- setDraft(nextDraft)
847
- setSolution(policy.policy?.solutionHint ?? '')
848
- setCopySource((current) => current || (policy.environments ?? [])[0] || '')
849
- setError(null)
850
- } catch (cause) {
851
- setError(cause.message)
852
- } finally {
853
- setLoading(false)
854
- }
855
- }, [])
856
-
857
- React.useEffect(() => {
858
- load()
859
- }, [load])
860
-
861
- const toggle = (envKey, capability) => {
862
- setDraft((current) => ({
863
- ...current,
864
- [envKey]: { ...(current[envKey] ?? {}), [capability]: !current[envKey]?.[capability] },
865
- }))
866
- }
867
-
868
- const savePolicy = async () => {
869
- setSaving(true)
870
- setMessage(null)
871
- try {
872
- const payload = { environments: {}, solutionUniqueName: solution.trim() || undefined }
873
- for (const [envKey, values] of Object.entries(draft)) {
874
- const granted = Object.entries(values).filter(([, on]) => on).map(([capability]) => capability)
875
- // An empty array is meaningful: it is how "untick everything" is
876
- // recorded. Omitting the key would fall back to the default grants.
877
- payload.environments[envKey] = granted
878
- }
879
- const response = await fetch('/api/d365-meta/write/policy', {
880
- method: 'POST',
881
- headers: WRITE_HEADERS,
882
- body: JSON.stringify(payload),
883
- })
884
- const data = await response.json().catch(() => ({}))
885
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
886
- // Re-read so the boxes show what was stored, not what was clicked.
887
- await load()
888
- setMessage({ kind: 'ok', text: t('write.savedPolicy') })
889
- } catch (cause) {
890
- setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
891
- } finally {
892
- setSaving(false)
893
- }
894
- }
895
-
896
- if (loading) return h(Hint, null, '…')
897
- if (error) return h(Hint, { kind: 'error' }, t('write.loadFailed', { error }))
898
-
899
- // Grouping is server-driven: a capability may be added without a client change.
900
- const groups = {}
901
- for (const [capability, meta] of Object.entries(capabilities)) {
902
- const group = meta.group ?? ''
903
- groups[group] = groups[group] ?? []
904
- groups[group].push([capability, meta])
905
- }
906
-
907
- const totalCapabilities = Object.keys(capabilities).length
908
-
909
- // Permissions are per-environment, so each environment gets its own
910
- // block, separated by a rule: a single wide table made one environment's
911
- // row run into the next.
912
- const environmentBlocks = environments.map((envKey, index) => {
913
- const granted = Object.values(draft[envKey] ?? {}).filter(Boolean).length
914
- return h('div', {
915
- key: envKey,
916
- style: {
917
- display: 'flex',
918
- flexDirection: 'column',
919
- gap: '8px',
920
- paddingTop: index === 0 ? 0 : '16px',
921
- borderTop: index === 0 ? 'none' : `1px solid ${T.border}`,
922
- },
923
- },
924
- h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' } },
925
- h('span', { style: { fontSize: '13px', fontWeight: 600, color: T.text } }, envKey),
926
- h('span', { style: { fontSize: '11px', color: T.muted } }, t('write.envSummary', { granted, total: totalCapabilities })),
927
- ),
928
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
929
- Object.entries(groups).map(([group, entries]) => h('div', {
930
- key: group || '-',
931
- style: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' },
932
- },
933
- group ? h('span', { style: { fontSize: '11px', color: T.muted, minWidth: '48px' } }, group) : null,
934
- entries.map(([capability, meta]) => h('label', {
935
- key: capability,
936
- style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: T.muted },
937
- title: meta.description,
938
- },
939
- h('input', {
940
- type: 'checkbox',
941
- checked: Boolean(draft[envKey]?.[capability]),
942
- onChange: () => toggle(envKey, capability),
943
- }),
944
- meta.label,
945
- meta.risk === 'high' ? h('span', { style: { color: T.error } }, t('write.highRisk')) : null,
946
- )),
947
- )),
948
- ),
949
- )
950
- })
951
-
952
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
953
- h('div', null,
954
- h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('write.title')),
955
- h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('write.desc')),
956
- h('p', { style: { margin: '4px 0 0', fontSize: '11px', color: T.muted, lineHeight: 1.6 } }, t('write.gate')),
957
- h('p', { style: { margin: '2px 0 0', fontSize: '11px', color: T.muted, lineHeight: 1.6 } }, t('write.envNote')),
958
- ),
959
-
960
- environmentBlocks.length
961
- ? h('div', { style: { display: 'flex', flexDirection: 'column' } }, environmentBlocks)
962
- : h(Hint, null, t('write.noEnvironments')),
963
-
964
- h('div', { style: { maxWidth: '420px' } },
965
- h(Field, { label: t('write.solution'), hint: t('write.solutionHint') },
966
- h('input', {
967
- style: inputStyle,
968
- value: solution,
969
- onChange: (event) => setSolution(event.target.value),
970
- }),
971
- ),
972
- ),
973
-
974
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
975
- h('button', {
976
- type: 'button',
977
- style: { ...buttonStyle('primary'), opacity: saving ? 0.6 : 1 },
978
- disabled: saving,
979
- onClick: savePolicy,
980
- }, saving ? t('saving') : t('write.savePolicy')),
981
- h('select', {
982
- style: { ...inputStyle, width: 'auto' },
983
- value: copySource,
984
- onChange: (event) => setCopySource(event.target.value),
985
- }, environments.map((envKey) => h('option', { key: envKey, value: envKey }, envKey))),
986
- h('button', {
987
- type: 'button',
988
- style: buttonStyle('ghost'),
989
- disabled: !environments.length,
990
- onClick: () => {
991
- const source = draft[copySource] ?? {}
992
- const next = {}
993
- for (const envKey of environments) next[envKey] = { ...source }
994
- setDraft(next)
995
- setMessage({ kind: 'ok', text: t('write.copied') })
996
- },
997
- }, t('write.copyToAll')),
998
- ),
999
-
1000
- message ? h(Hint, { kind: message.kind }, message.text) : null,
1001
- )
1002
- }
1003
-
1004
- // --------------------------------------------------------------------- plans
1005
-
1006
- /**
1007
- * One line of a plan. Option operations carry their own fields; metadata
1008
- * operations carry a summary built server-side, so new capabilities need no
1009
- * client change to be readable.
1010
- */
1011
- const OPERATION_VERBS = {
1012
- InsertOptionValue: '+',
1013
- CreateAttribute: '+',
1014
- UpdateOptionValue: '~',
1015
- UpdateAttribute: '~',
1016
- UpdateEntity: '~',
1017
- UpdateViewLayout: '~',
1018
- DeleteOptionValue: '!',
1019
- DeleteAttribute: '!',
1020
- }
1021
-
1022
- function describeOperation(operation) {
1023
- const verb = OPERATION_VERBS[operation.type] ?? '·'
1024
- if (operation.type === 'InsertOptionValue') return `${verb} ${operation.value} ${operation.label}`
1025
- if (operation.type === 'UpdateOptionValue') {
1026
- return `${verb} ${operation.value} ${operation.previousLabel ?? ''} → ${operation.label}`
1027
- }
1028
- return `${verb} ${operation.summary ?? operation.type}`
1029
- }
1030
-
1031
- function PlansPanel({ onCount }) {
1032
- useLocaleRevision()
1033
- const [loading, setLoading] = React.useState(true)
1034
- const [error, setError] = React.useState(null)
1035
- const [plans, setPlans] = React.useState([])
1036
- const [changes, setChanges] = React.useState([])
1037
- const [publishes, setPublishes] = React.useState({})
1038
- const [message, setMessage] = React.useState(null)
1039
- const [confirming, setConfirming] = React.useState(null)
1040
- const [executing, setExecuting] = React.useState(null)
1041
- const [republishing, setRepublishing] = React.useState(null)
1042
- const [polls, setPolls] = React.useState(0)
1043
-
1044
- const load = React.useCallback(async () => {
1045
- try {
1046
- const response = await fetch('/api/d365-meta/write/plans')
1047
- const data = await response.json().catch(() => ({}))
1048
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1049
- setPlans(data.plans ?? [])
1050
- setChanges(data.changes ?? [])
1051
- setPublishes(data.publishes ?? {})
1052
- setError(null)
1053
- } catch (cause) {
1054
- setError(cause.message)
1055
- } finally {
1056
- setLoading(false)
1057
- }
1058
- }, [])
1059
-
1060
- React.useEffect(() => {
1061
- load()
1062
- }, [load])
1063
-
1064
- // A publish runs detached, so its outcome appears in the sidecar file
1065
- // later. Poll while something is still in flight, but never forever: a
1066
- // request that died with the Host leaves "running" behind for good.
1067
- const pendingPublish = changes.some((entry) => entry.publishState === 'running' && !publishes[entry.planId])
1068
- React.useEffect(() => {
1069
- if (!pendingPublish || polls >= 40) return undefined
1070
- const timer = setTimeout(() => {
1071
- setPolls((value) => value + 1)
1072
- load()
1073
- }, 3000)
1074
- return () => clearTimeout(timer)
1075
- }, [pendingPublish, polls, load])
1076
-
1077
- React.useEffect(() => {
1078
- if (!pendingPublish && polls !== 0) setPolls(0)
1079
- }, [pendingPublish, polls])
1080
-
1081
- React.useEffect(() => {
1082
- onCount?.(plans.length)
1083
- }, [plans.length, onCount])
1084
-
1085
- const cancelPlan = async (planId) => {
1086
- try {
1087
- const response = await fetch('/api/d365-meta/write/cancel', {
1088
- method: 'POST',
1089
- headers: WRITE_HEADERS,
1090
- body: JSON.stringify({ planId }),
1091
- })
1092
- // A 403/404/500 here used to look like success: the list refreshed,
1093
- // the plan was still there, and the person believed it was gone.
1094
- const data = await response.json().catch(() => ({}))
1095
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1096
- setConfirming(null)
1097
- await load()
1098
- } catch (cause) {
1099
- setMessage({ kind: 'error', text: String(cause.message) })
1100
- }
1101
- }
1102
-
1103
- const executePlan = async (planId) => {
1104
- setExecuting(planId)
1105
- setMessage(null)
1106
- try {
1107
- const response = await fetch('/api/d365-meta/write/execute', {
1108
- method: 'POST',
1109
- headers: WRITE_HEADERS,
1110
- body: JSON.stringify({ planId }),
1111
- })
1112
- const data = await response.json().catch(() => ({}))
1113
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1114
- const ok = data.results?.length ?? 0
1115
- const failed = data.failures?.length ?? 0
1116
- const publish = data.publishState === 'running' ? t('write.publishing') : ''
1117
- setMessage({ kind: failed ? 'error' : 'ok', text: t('write.executed', { ok, failed }) + publish })
1118
- setConfirming(null)
1119
- await load()
1120
- } catch (cause) {
1121
- setMessage({ kind: 'error', text: cause.message })
1122
- } finally {
1123
- setExecuting(null)
1124
- }
1125
- }
1126
-
1127
- const republish = async (planId) => {
1128
- setRepublishing(planId)
1129
- setMessage(null)
1130
- try {
1131
- const response = await fetch('/api/d365-meta/write/publish', {
1132
- method: 'POST',
1133
- headers: WRITE_HEADERS,
1134
- body: JSON.stringify({ planId }),
1135
- })
1136
- const data = await response.json().catch(() => ({}))
1137
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1138
- const publish = data.publish ?? {}
1139
- setMessage(publish.ok
1140
- ? { kind: 'ok', text: t('write.republishOk', { targets: targetsText(publish.targets) }) }
1141
- : { kind: 'error', text: t('write.republishFailed', { error: publish.error ?? '' }) })
1142
- await load()
1143
- } catch (cause) {
1144
- setMessage({ kind: 'error', text: String(cause.message) })
1145
- } finally {
1146
- setRepublishing(null)
1147
- }
1148
- }
1149
-
1150
- const targetsText = (scope) => {
1151
- const parts = [...(scope?.entities ?? [])]
1152
- for (const name of scope?.optionSets ?? []) parts.push(t('write.optionSetLabel', { name }))
1153
- return parts.join(' · ')
1154
- }
1155
-
1156
- const publishStatus = (entry) => {
1157
- const report = publishes[entry.planId]
1158
- if (report && report.ok) return { kind: 'ok', text: t('write.published') }
1159
- if (report && !report.ok) return { kind: 'error', text: t('write.publishFailed', { error: report.error ?? '' }) }
1160
- if (entry.publishState === 'running') return { kind: 'muted', text: t('write.publishRunning') }
1161
- return { kind: 'muted', text: t('write.publishSkipped') }
1162
- }
1163
-
1164
- if (loading) return h(Hint, null, '…')
1165
- if (error) return h(Hint, { kind: 'error' }, t('write.loadFailed', { error }))
1166
-
1167
- const planCards = plans.map((plan) => {
1168
- const isConfirming = confirming === plan.id
1169
- return h('div', {
1170
- key: plan.id,
1171
- style: {
1172
- border: `1px solid ${isConfirming ? T.error : T.border}`,
1173
- borderRadius: '8px',
1174
- padding: '10px 12px',
1175
- display: 'flex',
1176
- flexDirection: 'column',
1177
- gap: '8px',
1178
- background: T.bg,
1179
- },
1180
- },
1181
- h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } },
1182
- h('strong', { style: { fontSize: '13px', color: T.text } }, plan.title ?? plan.kind),
1183
- h('span', { style: { fontSize: '11px', color: T.muted } }, `${plan.envKey} · ${plan.createdAt ?? ''}`),
1184
- ),
1185
- h('div', { style: { fontSize: '12px', color: T.muted, fontFamily: 'ui-monospace, monospace', lineHeight: 1.5, maxHeight: '140px', overflow: 'auto' } },
1186
- (plan.operations ?? []).slice(0, 12).map((operation, index) => h('div', { key: index }, describeOperation(operation))),
1187
- (plan.operations ?? []).length > 12
1188
- ? h('div', null, t('plans.more', { count: plan.operations.length - 12 }))
1189
- : null,
1190
- ),
1191
- (plan.skipped ?? []).length
1192
- ? h('div', { style: { fontSize: '11px', color: T.muted } }, t('write.skipped', { count: plan.skipped.length }))
1193
- : null,
1194
-
1195
- isConfirming
1196
- ? h('div', {
1197
- style: {
1198
- border: `1px solid ${T.error}`,
1199
- borderRadius: '6px',
1200
- padding: '8px 10px',
1201
- fontSize: '12px',
1202
- color: T.text,
1203
- display: 'flex',
1204
- flexDirection: 'column',
1205
- gap: '6px',
1206
- },
1207
- },
1208
- h('strong', { style: { color: T.error } }, t('write.warningTitle')),
1209
- h('span', null, t('write.warningBody', { env: plan.envKey })),
1210
- plan.kind === 'attribute.delete' || plan.kind === 'optionValue.delete'
1211
- ? h('span', { style: { color: T.error } }, t('write.destructiveBody'))
1212
- : null,
1213
- h('div', { style: { display: 'flex', gap: '8px' } },
1214
- h('button', {
1215
- type: 'button',
1216
- style: { ...buttonStyle('primary'), background: T.error, borderColor: T.error },
1217
- disabled: executing === plan.id,
1218
- onClick: () => executePlan(plan.id),
1219
- }, executing === plan.id ? t('write.executing') : t('write.confirmExecute')),
1220
- h('button', { type: 'button', style: buttonStyle(), onClick: () => setConfirming(null) }, t('write.back')),
1221
- ),
1222
- )
1223
- : h('div', { style: { display: 'flex', gap: '8px' } },
1224
- h('button', {
1225
- type: 'button',
1226
- style: buttonStyle('primary'),
1227
- onClick: () => setConfirming(plan.id),
1228
- }, t('write.execute')),
1229
- h('button', { type: 'button', style: buttonStyle('danger'), onClick: () => cancelPlan(plan.id) }, t('write.cancelPlan')),
1230
- ),
1231
- )
1232
- })
1233
-
1234
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1235
- h('div', null,
1236
- h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('write.plans')),
1237
- h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('write.plansDesc')),
1238
- ),
1239
-
1240
- message ? h(Hint, { kind: message.kind }, message.text) : null,
1241
-
1242
- planCards.length
1243
- ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, planCards)
1244
- : h(Hint, null, t('write.noPlans')),
1245
-
1246
- changes.length
1247
- ? h('div', { style: { fontSize: '11px', color: T.muted, lineHeight: 1.6, marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '4px' } },
1248
- h('div', null, t('write.recent')),
1249
- changes.slice(0, 5).map((entry, index) => {
1250
- const status = publishStatus(entry)
1251
- return h('div', {
1252
- key: entry.planId ?? index,
1253
- style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
1254
- },
1255
- h('span', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } },
1256
- h('span', null, t('write.recentLine', {
1257
- time: entry.recordedAt ?? '',
1258
- env: entry.envKey ?? '',
1259
- ok: entry.applied?.length ?? 0,
1260
- failed: entry.failed?.length ?? 0,
1261
- })),
1262
- h('span', { style: { color: status.kind === 'error' ? T.error : status.kind === 'ok' ? T.success : T.muted } }, status.text),
1263
- ),
1264
- (entry.applied?.length ?? 0) > 0
1265
- ? h('button', {
1266
- type: 'button',
1267
- style: { ...buttonStyle(), fontSize: '11px', padding: '2px 8px' },
1268
- disabled: republishing === entry.planId,
1269
- onClick: () => republish(entry.planId),
1270
- }, republishing === entry.planId ? t('write.republishing') : t('write.republish'))
1271
- : null,
1272
- )
1273
- }),
1274
- )
1275
- : null,
1276
- )
1277
- }
1278
-
1279
- // ------------------------------------------------------------------- general
1280
-
1281
- const GENERAL_FIELDS = [
1282
- { key: 'snapshotKeep', label: 'general.snapshotKeep', hint: 'general.snapshotKeepHint', min: 1, max: 50 },
1283
- { key: 'maxRecords', label: 'general.maxRecords', hint: 'general.maxRecordsHint', min: 1, max: 100000 },
1284
- { key: 'timeoutMs', label: 'general.timeoutMs', hint: 'general.timeoutMsHint', min: 5000, max: 300000 },
1285
- ]
1286
-
1287
- function GeneralPanel() {
1288
- useLocaleRevision()
1289
- const [loading, setLoading] = React.useState(true)
1290
- const [error, setError] = React.useState(null)
1291
- const [general, setGeneral] = React.useState(null)
1292
- const [form, setForm] = React.useState({})
1293
- const [busy, setBusy] = React.useState(false)
1294
- const [message, setMessage] = React.useState(null)
1295
-
1296
- React.useEffect(() => {
1297
- let alive = true
1298
- ;(async () => {
1299
- try {
1300
- const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
1301
- const data = await response.json().catch(() => ({}))
1302
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1303
- if (!alive) return
1304
- const next = data.settings?.general ?? {}
1305
- setGeneral(next)
1306
- setForm({
1307
- snapshotKeep: String(next.snapshotKeep ?? ''),
1308
- maxRecords: String(next.maxRecords ?? ''),
1309
- timeoutMs: String(next.timeoutMs ?? ''),
1310
- })
1311
- setError(null)
1312
- } catch (cause) {
1313
- if (alive) setError(cause.message)
1314
- } finally {
1315
- if (alive) setLoading(false)
1316
- }
1317
- })()
1318
- return () => { alive = false }
1319
- }, [])
1320
-
1321
- const save = async () => {
1322
- setBusy(true)
1323
- setMessage(null)
1324
- try {
1325
- const generalPayload = {}
1326
- for (const field of GENERAL_FIELDS) {
1327
- const value = Number(form[field.key])
1328
- if (!Number.isInteger(value) || value < field.min || value > field.max) {
1329
- throw new Error(`${t(field.label)}: ${field.min}–${field.max}`)
1330
- }
1331
- generalPayload[field.key] = value
1332
- }
1333
- const response = await fetch('/api/d365-meta/settings', {
1334
- method: 'POST',
1335
- headers: WRITE_HEADERS,
1336
- body: JSON.stringify({ general: generalPayload }),
1337
- })
1338
- const data = await response.json().catch(() => ({}))
1339
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1340
- const next = data.settings?.general ?? general
1341
- setGeneral(next)
1342
- setMessage({ kind: 'ok', text: t('general.saved') })
1343
- } catch (cause) {
1344
- setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
1345
- } finally {
1346
- setBusy(false)
1347
- }
1348
- }
1349
-
1350
- if (loading) return h(Hint, null, '…')
1351
- if (error) return h(Hint, { kind: 'error' }, t('general.loadFailed', { error }))
1352
-
1353
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1354
- h('div', null,
1355
- h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('general.title')),
1356
- h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('general.desc')),
1357
- ),
1358
-
1359
- h('div', { style: { display: 'flex', gap: '10px', flexWrap: 'wrap' } },
1360
- GENERAL_FIELDS.map((field) => h('div', { key: field.key, style: { flex: '1 1 190px', minWidth: '170px' } },
1361
- h(Field, { label: t(field.label), hint: t(field.hint) },
1362
- h('input', {
1363
- style: inputStyle,
1364
- type: 'number',
1365
- min: field.min,
1366
- max: field.max,
1367
- value: form[field.key] ?? '',
1368
- onChange: (event) => setForm((current) => ({ ...current, [field.key]: event.target.value })),
1369
- }),
1370
- ),
1371
- )),
1372
- ),
1373
-
1374
- h(Field, { label: t('general.dataDir'), hint: t('general.dataDirHint') },
1375
- h('input', {
1376
- style: { ...inputStyle, background: 'transparent', color: T.muted },
1377
- value: general?.dataDirectory ?? '',
1378
- readOnly: true,
1379
- }),
1380
- ),
1381
-
1382
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
1383
- h('button', {
1384
- type: 'button',
1385
- style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1386
- disabled: busy,
1387
- onClick: save,
1388
- }, busy ? t('saving') : t('general.save')),
1389
- ),
1390
-
1391
- message ? h(Hint, { kind: message.kind }, message.text) : null,
1392
- )
1393
- }
1394
-
1395
- function GitlabPanel() {
1396
- useLocaleRevision()
1397
- const [loading, setLoading] = React.useState(true)
1398
- const [error, setError] = React.useState(null)
1399
- const [gitlab, setGitlab] = React.useState(null)
1400
- const [form, setForm] = React.useState({ url: '', defaultProject: '', token: '' })
1401
- const [busy, setBusy] = React.useState(false)
1402
- const [checking, setChecking] = React.useState(false)
1403
- const [message, setMessage] = React.useState(null)
1404
-
1405
- React.useEffect(() => {
1406
- let alive = true
1407
- ;(async () => {
1408
- try {
1409
- const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
1410
- const data = await response.json().catch(() => ({}))
1411
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1412
- if (!alive) return
1413
- const next = data.settings?.gitlab ?? {}
1414
- setGitlab(next)
1415
- setForm({
1416
- url: next.url ?? '',
1417
- defaultProject: next.defaultProject ?? '',
1418
- token: '',
1419
- })
1420
- setError(null)
1421
- } catch (cause) {
1422
- if (alive) setError(cause.message)
1423
- } finally {
1424
- if (alive) setLoading(false)
1425
- }
1426
- })()
1427
- return () => { alive = false }
1428
- }, [])
1429
-
1430
- const save = async (extra) => {
1431
- setBusy(true)
1432
- setMessage(null)
1433
- try {
1434
- const payload = {
1435
- url: form.url.trim(),
1436
- defaultProject: form.defaultProject.trim(),
1437
- // A clear request must not also carry whatever is still typed in the
1438
- // box: the server stores a supplied token before honouring the clear.
1439
- ...(form.token && !extra?.clearToken ? { token: form.token } : {}),
1440
- ...(extra ?? {}),
1441
- }
1442
- const response = await fetch('/api/d365-meta/settings', {
1443
- method: 'POST',
1444
- headers: WRITE_HEADERS,
1445
- body: JSON.stringify({ gitlab: payload }),
1446
- })
1447
- const data = await response.json().catch(() => ({}))
1448
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1449
- const next = data.settings?.gitlab ?? gitlab
1450
- setGitlab(next)
1451
- setForm((current) => ({ ...current, token: '' }))
1452
- setMessage({ kind: 'ok', text: extra?.clearToken ? t('gitlab.cleared') : t('gitlab.saved') })
1453
- } catch (cause) {
1454
- setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
1455
- } finally {
1456
- setBusy(false)
1457
- }
1458
- }
1459
-
1460
- const check = async () => {
1461
- setChecking(true)
1462
- setMessage(null)
1463
- try {
1464
- const response = await fetch('/api/d365-meta/gitlab/check', {
1465
- method: 'POST',
1466
- headers: WRITE_HEADERS,
1467
- body: JSON.stringify({ url: form.url.trim() }),
1468
- })
1469
- const data = await response.json().catch(() => ({}))
1470
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1471
- if (!data.ok) throw new Error([data.error, data.hint].filter(Boolean).join(' '))
1472
- setMessage({ kind: 'ok', text: t('gitlab.ok', { user: data.user ?? data.name ?? '' }) })
1473
- } catch (cause) {
1474
- setMessage({ kind: 'error', text: cause.message })
1475
- } finally {
1476
- setChecking(false)
1477
- }
1478
- }
1479
-
1480
- if (loading) return h(Hint, null, '…')
1481
- if (error) return h(Hint, { kind: 'error' }, t('gitlab.loadFailed', { error }))
1482
-
1483
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1484
- h('div', null,
1485
- h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('gitlab.title')),
1486
- h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('gitlab.desc')),
1487
- ),
1488
-
1489
- h(Field, { label: t('gitlab.url'), hint: t('gitlab.urlHint') },
1490
- h('input', {
1491
- style: inputStyle,
1492
- value: form.url,
1493
- placeholder: 'https://gitlab.example.com',
1494
- onChange: (event) => setForm((current) => ({ ...current, url: event.target.value })),
1495
- }),
1496
- ),
1497
-
1498
- h(Field, { label: t('gitlab.project'), hint: t('gitlab.projectHint') },
1499
- h('input', {
1500
- style: inputStyle,
1501
- value: form.defaultProject,
1502
- placeholder: 'group/project',
1503
- onChange: (event) => setForm((current) => ({ ...current, defaultProject: event.target.value })),
1504
- }),
1505
- ),
1506
-
1507
- h(Field, {
1508
- label: t('gitlab.token'),
1509
- hint: gitlab?.hasToken && !form.token
1510
- ? t('gitlab.tokenSaved', { source: gitlab.tokenSource ?? '' })
1511
- : t('gitlab.tokenHint'),
1512
- },
1513
- h('input', {
1514
- style: inputStyle,
1515
- type: 'password',
1516
- value: form.token,
1517
- placeholder: gitlab?.hasToken ? '••••••••' : '',
1518
- autoComplete: 'off',
1519
- onChange: (event) => setForm((current) => ({ ...current, token: event.target.value })),
1520
- }),
1521
- ),
1522
-
1523
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
1524
- h('button', {
1525
- type: 'button',
1526
- style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1527
- disabled: busy,
1528
- onClick: () => save(),
1529
- }, busy ? t('saving') : t('gitlab.save')),
1530
- h('button', {
1531
- type: 'button',
1532
- style: { ...buttonStyle('ghost'), opacity: checking ? 0.6 : 1 },
1533
- disabled: checking,
1534
- onClick: check,
1535
- }, checking ? t('gitlab.checking') : t('gitlab.check')),
1536
- gitlab?.hasToken
1537
- ? h('button', {
1538
- type: 'button',
1539
- style: buttonStyle('ghost'),
1540
- disabled: busy,
1541
- onClick: () => save({ clearToken: true }),
1542
- }, t('gitlab.clear'))
1543
- : null,
1544
- ),
1545
-
1546
- message ? h(Hint, { kind: message.kind }, message.text) : null,
1547
- )
1548
- }
1549
-
1550
- function StoragePanel() {
1551
- useLocaleRevision()
1552
- const [loading, setLoading] = React.useState(true)
1553
- const [error, setError] = React.useState(null)
1554
- const [storage, setStorage] = React.useState(null)
1555
- const [plan, setPlan] = React.useState(null)
1556
- const [busy, setBusy] = React.useState(false)
1557
- const [message, setMessage] = React.useState(null)
1558
-
1559
- const load = React.useCallback(async () => {
1560
- const response = await fetch('/api/d365-meta/storage', { headers: { accept: 'application/json' } })
1561
- const data = await response.json().catch(() => ({}))
1562
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1563
- return data.storage
1564
- }, [])
1565
-
1566
- React.useEffect(() => {
1567
- let alive = true
1568
- ;(async () => {
1569
- try {
1570
- const next = await load()
1571
- if (alive) {
1572
- setStorage(next)
1573
- setError(null)
1574
- }
1575
- } catch (cause) {
1576
- if (alive) setError(cause.message)
1577
- } finally {
1578
- if (alive) setLoading(false)
1579
- }
1580
- })()
1581
- return () => { alive = false }
1582
- }, [load])
1583
-
1584
- const run = async (path, body) => {
1585
- setBusy(true)
1586
- setMessage(null)
1587
- try {
1588
- const response = await fetch(path, {
1589
- method: 'POST',
1590
- headers: WRITE_HEADERS,
1591
- body: JSON.stringify(body ?? {}),
1592
- })
1593
- const data = await response.json().catch(() => ({}))
1594
- if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1595
- if (data.storage) setStorage(data.storage)
1596
- return data
1597
- } catch (cause) {
1598
- setMessage({ kind: 'error', text: t('data.failed', { error: cause.message }) })
1599
- return null
1600
- } finally {
1601
- setBusy(false)
1602
- }
1603
- }
1604
-
1605
- const preview = async () => {
1606
- const data = await run('/api/d365-meta/storage/cleanup', { dryRun: true, target: 'all' })
1607
- if (!data) return
1608
- setPlan(data.plan)
1609
- if (!data.plan.totalFiles) setMessage({ kind: 'muted', text: t('data.nothing') })
1610
- }
1611
-
1612
- const remove = async () => {
1613
- const data = await run('/api/d365-meta/storage/cleanup', { dryRun: false, target: 'all' })
1614
- if (!data) return
1615
- setPlan(null)
1616
- setMessage({ kind: 'ok', text: t('data.deleted', { files: data.deleted, size: data.bytesText }) })
1617
- }
1618
-
1619
- const compact = async () => {
1620
- const data = await run('/api/d365-meta/storage/compact', {})
1621
- if (!data) return
1622
- setMessage({ kind: 'ok', text: t('data.compacted', { snapshots: data.snapshots, size: data.savedText }) })
1623
- }
1624
-
1625
- if (loading) return h(Hint, null, '…')
1626
- if (error) return h(Hint, { kind: 'error' }, t('general.loadFailed', { error }))
1627
- if (!storage) return null
1628
-
1629
- const rows = storage.classes ?? []
1630
- const percent = storage.warnMegabytes
1631
- ? Math.min(100, Math.round((storage.totalBytes / (storage.warnMegabytes * 1048576)) * 100))
1632
- : 0
1633
-
1634
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1635
- h('div', null,
1636
- h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('data.title')),
1637
- h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('data.desc')),
1638
- ),
1639
-
1640
- h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
1641
- h('strong', { style: { fontSize: '13px', color: T.text } }, `${t('data.total')}:${formatBytesUi(storage.totalBytes)}`),
1642
- h('div', { style: { flex: '1 1 200px', minWidth: '160px' } },
1643
- h('div', { style: { height: '6px', borderRadius: '3px', background: T.border, overflow: 'hidden' } },
1644
- h('div', {
1645
- style: {
1646
- width: `${percent}%`,
1647
- height: '100%',
1648
- background: storage.overLimit ? '#d13438' : T.brand,
1649
- },
1650
- }),
1651
- ),
1652
- ),
1653
- storage.overLimit
1654
- ? h('span', { style: { fontSize: '12px', color: '#d13438' } }, t('data.overLimit', { mb: storage.warnMegabytes }))
1655
- : null,
1656
- ),
1657
-
1658
- h('div', { style: { overflowX: 'auto' } },
1659
- h('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' } },
1660
- h('thead', null, h('tr', null,
1661
- h('th', { style: thStyle }, t('data.class')),
1662
- h('th', { style: thStyle }, t('data.count')),
1663
- h('th', { style: thStyle }, t('data.size')),
1664
- h('th', { style: thStyle }, t('data.policy')),
1665
- )),
1666
- h('tbody', null, rows.map((row) => h('tr', { key: row.id },
1667
- h('td', { style: tdStyle }, row.label + (row.hint ? ` · ${row.hint}` : '')),
1668
- h('td', { style: tdStyle }, String(row.count)),
1669
- h('td', { style: tdStyle }, formatBytesUi(row.bytes)),
1670
- h('td', { style: tdStyle }, t('data.keepPolicy', { keep: row.keep, days: row.maxAgeDays })),
1671
- ))),
1672
- ),
1673
- ),
1674
-
1675
- h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
1676
- h('button', {
1677
- type: 'button',
1678
- style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1679
- disabled: busy,
1680
- onClick: preview,
1681
- }, t('data.cleanup')),
1682
- h('button', {
1683
- type: 'button',
1684
- style: { ...buttonStyle(), opacity: busy ? 0.6 : 1 },
1685
- disabled: busy,
1686
- onClick: compact,
1687
- }, t('data.compact')),
1688
- ),
1689
-
1690
- plan && plan.totalFiles
1691
- ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
1692
- h(Hint, { kind: 'muted' }, t('data.plan', { files: plan.totalFiles, size: plan.totalText })),
1693
- h('div', null, h('button', {
1694
- type: 'button',
1695
- style: { ...buttonStyle('danger'), opacity: busy ? 0.6 : 1 },
1696
- disabled: busy,
1697
- onClick: remove,
1698
- }, t('data.confirm'))),
1699
- )
1700
- : null,
1701
-
1702
- message ? h(Hint, { kind: message.kind }, message.text) : null,
1703
- )
1704
- }
1705
-
1706
- // ---------------------------------------------------------------------- page
1707
-
1708
- const TABS = [
1709
- { id: 'environments', label: 'tab.environments' },
1710
- { id: 'permissions', label: 'tab.permissions' },
1711
- { id: 'plans', label: 'tab.plans' },
1712
- { id: 'data', label: 'tab.data' },
1713
- { id: 'gitlab', label: 'tab.gitlab' },
1714
- { id: 'general', label: 'tab.general' },
1715
- ]
1716
-
1717
- function SettingsPage() {
1718
- useLocaleRevision()
1719
- const [tab, setTab] = React.useState('environments')
1720
- const [pending, setPending] = React.useState(0)
1721
-
1722
- const panels = {
1723
- environments: h(EnvironmentsPanel),
1724
- permissions: h(PermissionsPanel),
1725
- plans: h(PlansPanel, { onCount: setPending }),
1726
- data: h(StoragePanel),
1727
- gitlab: h(GitlabPanel),
1728
- general: h(GeneralPanel),
1729
- }
1730
-
1731
- const tabBar = h('div', {
1732
- style: {
1733
- display: 'flex',
1734
- gap: '2px',
1735
- borderBottom: `1px solid ${T.border}`,
1736
- marginBottom: '2px',
1737
- },
1738
- }, TABS.map((item) => {
1739
- const active = tab === item.id
1740
- const suffix = item.id === 'plans' && pending ? ` (${pending})` : ''
1741
- return h('button', {
1742
- key: item.id,
1743
- type: 'button',
1744
- onClick: () => setTab(item.id),
1745
- style: {
1746
- padding: '7px 12px',
1747
- font: 'inherit',
1748
- fontSize: '13px',
1749
- cursor: 'pointer',
1750
- background: 'transparent',
1751
- border: 'none',
1752
- borderBottom: `2px solid ${active ? T.brand : 'transparent'}`,
1753
- color: active ? T.text : T.muted,
1754
- fontWeight: active ? 600 : 400,
1755
- marginBottom: '-1px',
1756
- },
1757
- }, t(item.label) + suffix)
1758
- }))
1759
-
1760
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '840px' } },
1761
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1762
- h('h2', { style: { margin: 0, fontSize: '15px', fontWeight: 600, color: T.text } }, t('section.title')),
1763
- h('p', { style: { margin: 0, fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('section.desc')),
1764
- ),
1765
- tabBar,
1766
- // Every panel stays mounted so switching tabs does not discard edits
1767
- // or restart a connection test.
1768
- ...TABS.map((item) => h('div', {
1769
- key: item.id,
1770
- style: { display: tab === item.id ? 'block' : 'none' },
1771
- }, panels[item.id])),
1772
- )
1773
- }
1774
-
1775
- return {
1776
- inject: ['slots'],
1777
- apply(ctx) {
1778
- // The `locale` service is provided by @deepseek-ai/dsh-client-locale,
1779
- // which may activate after this plugin. A one-shot `ctx.get('locale')`
1780
- // read would then be undefined forever and the page would stay English
1781
- // — the reported "settings page does not follow DSH's language" bug.
1782
- // `ctx.inject` waits for the service and re-runs this body if it is
1783
- // replaced, so the wiring is late-safe and self-healing.
1784
- ctx.inject(['locale'], (ctx) => {
1785
- const locale = ctx.locale
1786
- localeService = locale
1787
- ctx.effect(() => () => {
1788
- if (localeService === locale) localeService = null
1789
- })
1790
-
1791
- const sync = () => {
1792
- const live = activeIdOf(locale)
1793
- if (live) activeLocale = localeFrom(live)
1794
- notifyLocale()
1795
- }
1796
- sync()
1797
- ctx.effect(() => locale.subscribe?.(sync) ?? (() => {}))
1798
- // Registration is a courtesy for other surfaces reading this
1799
- // namespace; the page never depends on it succeeding.
1800
- ctx.effect(() => {
1801
- try {
1802
- return locale.register('d365-meta', DICT)
1803
- } catch {
1804
- return () => {}
1805
- }
1806
- })
1807
- })
1808
-
1809
- ctx.slots.inject('settings.section', () => ctx.slots.register({
1810
- name: 'settings.section',
1811
- id: 'd365-meta',
1812
- order: 30,
1813
- label: () => t('section.title'),
1814
- }, SettingsPage))
1815
- },
1816
- }
1817
- },
1818
- })
1
+ /**
2
+ * Client half: the "D365 工具箱" settings page, split into tabs.
3
+ *
4
+ * Environments the environment list, secrets, default environment
5
+ * Permissions which capabilities are granted to which environment
6
+ * Pending plans the agent prepared; the only place they can be executed
7
+ * General tool parameters that are not per-environment
8
+ *
9
+ * Data comes from the authenticated `/api/d365-meta/*` routes; the environment
10
+ * list is the same storage the `d365_configure` tool writes, so the page can
11
+ * never drift from what the agent reads.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-d365-kit',
15
+ factory(require) {
16
+ const React = require('react')
17
+ const h = React.createElement
18
+
19
+ /**
20
+ * Header every route that can change an environment expects.
21
+ *
22
+ * The `/api` channel strips `Origin`, so this header is what proves the
23
+ * request came from this page: another site cannot send it without a CORS
24
+ * preflight, which these routes never answer.
25
+ */
26
+ const WRITE_HEADERS = { 'content-type': 'application/json', 'x-d365-meta-write': '1' }
27
+
28
+ const DICT = {
29
+ en: {
30
+ 'section.title': 'D365 Toolkit',
31
+ 'section.desc': 'Connect Dataverse environments, manage read/write permissions and pending changes, review local data and check results.',
32
+ 'tab.environments': 'Environments',
33
+ 'tab.permissions': 'Permissions',
34
+ 'tab.plans': 'Pending changes',
35
+ 'tab.data': 'Data',
36
+ 'tab.general': 'General',
37
+ 'data.title': 'Local data',
38
+ 'data.desc': 'Snapshots, reports and query artifacts are stored on this machine. This page shows what is using space and applies the retention policy.',
39
+ 'data.total': 'Total',
40
+ 'data.class': 'Class',
41
+ 'data.count': 'Files',
42
+ 'data.size': 'Size',
43
+ 'data.policy': 'Retention',
44
+ 'data.keepPolicy': 'keep {keep} / {days} days',
45
+ 'data.overLimit': 'over {mb} MB — consider cleaning up',
46
+ 'data.cleanup': 'Preview cleanup',
47
+ 'data.compact': 'Compact snapshots',
48
+ 'data.plan': 'Will delete {files} files and free {size}.',
49
+ 'data.confirm': 'Delete now',
50
+ 'data.deleted': 'Deleted {files} files, freed {size}.',
51
+ 'data.compacted': 'Compacted {snapshots} snapshots, freed {size}.',
52
+ 'data.nothing': 'Nothing to clean up under the current policy.',
53
+ 'data.failed': 'Failed: {error}',
54
+ 'add': 'Add environment',
55
+ 'save': 'Save',
56
+ 'saving': 'Saving…',
57
+ 'test': 'Test connection',
58
+ 'testing': 'Testing…',
59
+ 'remove': 'Remove',
60
+ 'label': 'Display name',
61
+ 'url': 'Environment URL',
62
+ 'cloud': 'Cloud',
63
+ 'tenantId': 'Tenant ID',
64
+ 'clientId': 'Application user ClientId',
65
+ 'secret': 'Client secret',
66
+ 'secretSaved': 'Secret saved (source: {source})',
67
+ 'clear': 'Remove saved secret',
68
+ 'secretHint': 'The secret is written to the local d365.secrets.json in plain text, never to d365.config.json. Keep that file out of Git.',
69
+ 'default': 'Default environment',
70
+ 'saved': 'Saved.',
71
+ 'savedSecrets': 'Saved; the secret went to d365.secrets.json.',
72
+ 'loadFailed': 'Could not load the configuration: {error}',
73
+ 'saveFailed': 'Could not save: {error}',
74
+ 'checkOk': 'Connected: {name} ({version})',
75
+ 'checkFail': 'Connection failed: {error}',
76
+ 'checkMissing': 'Missing credentials: {list}',
77
+ 'issues': 'Configuration warnings',
78
+ 'files': 'Config: {config}',
79
+ 'secretFile': 'Secrets: {secrets}',
80
+ 'empty': 'No environments yet. Start with "Add environment".',
81
+ 'urlRequired': 'Fill in the URL for "{key}" first.',
82
+ 'duplicate': 'Duplicate key: {key}',
83
+ 'existing': 'already saved',
84
+ 'keyHint': 'Short key the tools use, e.g. dev.',
85
+ 'write.title': 'Write permissions',
86
+ 'write.desc': 'Choose what this machine may do in each environment. Metadata reads are on by default; business records need "Read business data". Metadata changes need a capability here and still require a confirm click.',
87
+ 'write.gate': 'Every request is checked against the target environment\'s grants below before it leaves this machine; a tool cannot skip the check.',
88
+ 'write.envSummary': '{granted} of {total} granted',
89
+ 'write.envNote': 'Metadata (tables, fields, forms, views, plugins, solution layers) needs "Read metadata", ticked by default. Untick everything to block even metadata. Business records need "Read business data"; a write also needs its capability here plus a confirm click in the chat.',
90
+ 'write.noEnvironments': 'Add an environment first.',
91
+ 'write.solution': 'Default solution for changes',
92
+ 'write.solutionHint': 'Solution unique name, so exported solutions carry the change',
93
+ 'write.savePolicy': 'Save permissions',
94
+ 'write.copyToAll': 'Copy to all environments',
95
+ 'write.copied': 'Copied into the other environments — review, then save.',
96
+ 'write.savedPolicy': 'Permissions saved.',
97
+ 'write.saveFailed': 'Could not save: {error}',
98
+ 'write.plans': 'Pending changes',
99
+ 'write.plansDesc': 'Plans prepared by the agent. Nothing runs until you confirm here or in the chat.',
100
+ 'write.noPlans': 'No pending changes. The agent can prepare one with d365_write.',
101
+ 'write.execute': 'Review & execute',
102
+ 'write.confirmExecute': 'Execute now',
103
+ 'write.executing': 'Executing…',
104
+ 'write.back': 'Cancel',
105
+ 'write.cancelPlan': 'Discard plan',
106
+ 'write.warningTitle': 'This writes to a real environment',
107
+ 'write.warningBody': 'The following operations will run against {env} immediately. Dataverse metadata changes cannot be rolled back automatically.',
108
+ 'write.destructiveBody': 'This includes a deletion that cannot be undone: the column or option value is removed permanently. Check the names before confirming.',
109
+ 'write.highRisk': 'destructive',
110
+ 'write.executed': 'Done: {ok} succeeded, {failed} failed.',
111
+ 'write.skipped': '{count} entries skipped (already present or unchanged).',
112
+ 'write.publishing': ' Publishing in the background; the change becomes visible shortly.',
113
+ 'write.recent': 'Recent changes',
114
+ 'write.recentLine': '{time} · {env} · {ok} ok / {failed} failed',
115
+ 'plans.more': '… {count} more',
116
+ 'write.publishRunning': 'publishing…',
117
+ 'write.published': 'published',
118
+ 'write.publishFailed': 'publish failed: {error}',
119
+ 'write.publishSkipped': 'nothing to publish',
120
+ 'write.republish': 'Publish again',
121
+ 'write.republishing': 'Publishing…',
122
+ 'write.republishOk': 'Published: {targets}',
123
+ 'write.republishFailed': 'Publish failed: {error}',
124
+ 'write.noTargets': 'nothing to publish',
125
+ 'write.optionSetLabel': 'option set {name}',
126
+ 'write.loadFailed': 'Could not load write settings: {error}',
127
+ 'general.title': 'Tool parameters',
128
+ 'general.desc': 'Numbers the tools use by default. They are stored in d365.config.json next to the environment list.',
129
+ 'general.snapshotKeep': 'Snapshots kept per environment',
130
+ 'general.snapshotKeepHint': 'Older snapshots are deleted after a sync. 1–50.',
131
+ 'general.maxRecords': 'Default row limit',
132
+ 'general.maxRecordsHint': 'Used when a tool call passes no value of its own. 1–100000.',
133
+ 'general.timeoutMs': 'Request timeout (ms)',
134
+ 'general.timeoutMsHint': 'Per HTTP request. 5000–300000.',
135
+ 'general.dataDir': 'Local data directory',
136
+ 'general.dataDirHint': 'Snapshots, diffs, pending writes and change records live here.',
137
+ 'general.save': 'Save parameters',
138
+ 'general.saved': 'Parameters saved.',
139
+ 'general.loadFailed': 'Could not load parameters: {error}',
140
+ 'tab.gitlab': 'GitLab',
141
+ 'gitlab.title': 'GitLab',
142
+ 'gitlab.desc': 'Used by d365_code_check to read a merge request and check its changed scripts against the environments. Read-only access is enough.',
143
+ 'gitlab.url': 'GitLab base URL',
144
+ 'gitlab.urlHint': 'For example https://gitlab.example.com — the page stays the same for every project.',
145
+ 'gitlab.project': 'Default project',
146
+ 'gitlab.projectHint': 'group/project. Used when the merge request link does not carry one.',
147
+ 'gitlab.token': 'Access token',
148
+ 'gitlab.tokenHint': 'A personal or project token with read_api. Saved to d365.secrets.json, never to d365.config.json.',
149
+ 'gitlab.tokenSaved': 'A token is already saved ({source}). Leave empty to keep it.',
150
+ 'gitlab.save': 'Save',
151
+ 'gitlab.saved': 'Saved.',
152
+ 'gitlab.check': 'Test access',
153
+ 'gitlab.checking': 'Testing…',
154
+ 'gitlab.ok': 'Connected as {user}.',
155
+ 'gitlab.clear': 'Remove token',
156
+ 'gitlab.cleared': 'Token removed.',
157
+ 'gitlab.loadFailed': 'Could not load GitLab settings: {error}',
158
+ },
159
+ zh: {
160
+ 'section.title': 'D365 工具箱',
161
+ 'section.desc': '接入 Dataverse 环境,管理读写权限与待确认变更,查看本机数据与检查结果。',
162
+ 'tab.environments': '环境',
163
+ 'tab.permissions': '权限',
164
+ 'tab.plans': '计划',
165
+ 'tab.data': '数据',
166
+ 'tab.general': '常规',
167
+ 'data.title': '本机数据',
168
+ 'data.desc': '快照、报告与查询留证都存在本机。这里显示占用情况,并按保留策略清理。',
169
+ 'data.total': '合计',
170
+ 'data.class': '类别',
171
+ 'data.count': '文件',
172
+ 'data.size': '占用',
173
+ 'data.policy': '保留策略',
174
+ 'data.keepPolicy': '保留 {keep} 个 / {days} 天',
175
+ 'data.overLimit': '超过 {mb} MB,建议清理',
176
+ 'data.cleanup': '预演清理',
177
+ 'data.compact': '压缩快照',
178
+ 'data.plan': '将删除 {files} 个文件,释放 {size}。',
179
+ 'data.confirm': '确认删除',
180
+ 'data.deleted': '已删除 {files} 个文件,释放 {size}。',
181
+ 'data.compacted': '已压缩 {snapshots} 份快照,释放 {size}。',
182
+ 'data.nothing': '按当前策略没有需要清理的内容。',
183
+ 'data.failed': '操作失败:{error}',
184
+ 'add': '添加环境',
185
+ 'save': '保存',
186
+ 'saving': '保存中…',
187
+ 'test': '测试连接',
188
+ 'testing': '测试中…',
189
+ 'remove': '删除',
190
+ 'label': '显示名',
191
+ 'url': '环境 URL',
192
+ 'cloud': '云',
193
+ 'tenantId': '租户 ID',
194
+ 'clientId': '应用用户 ClientId',
195
+ 'secret': '客户端密钥',
196
+ 'secretSaved': '密钥已保存(来源:{source})',
197
+ 'clear': '清除已保存的密钥',
198
+ 'secretHint': '密钥以明文写入本机 d365.secrets.json,不会写进 d365.config.json;别把它提交到 Git。',
199
+ 'default': '默认环境',
200
+ 'saved': '已保存。',
201
+ 'savedSecrets': '已保存,密钥写入了 d365.secrets.json。',
202
+ 'loadFailed': '读取配置失败:{error}',
203
+ 'saveFailed': '保存失败:{error}',
204
+ 'checkOk': '连接正常:{name}({version})',
205
+ 'checkFail': '连接失败:{error}',
206
+ 'checkMissing': '缺少凭据:{list}',
207
+ 'issues': '配置告警',
208
+ 'files': '配置文件:{config}',
209
+ 'secretFile': '密钥文件:{secrets}',
210
+ 'empty': '还没有环境,点"添加环境"开始。',
211
+ 'urlRequired': '请先填写 "{key}" 的 URL。',
212
+ 'duplicate': 'key 重复:{key}',
213
+ 'existing': '已保存',
214
+ 'keyHint': '工具里用的短名,例如 dev。',
215
+ 'write.title': '权限',
216
+ 'write.desc': '选择这台机器可以对哪些环境做什么。元数据默认可读;读取业务数据需要勾「读取业务数据」;元数据修改需要先在这里授权,执行前仍要确认。',
217
+ 'write.gate': '每一次访问在发出前都会按目标环境核对下面的勾选,任何工具都绕不过这道检查。',
218
+ 'write.envSummary': '已授权 {granted}/{total}',
219
+ 'write.envNote': '元数据(表、字段、表单、视图、插件、解决方案层)归「读取元数据」管,默认勾上;全部取消就什么都查不到。业务记录需要勾「读取业务数据」;写操作除勾选外,还要在对话里点确认。',
220
+ 'write.noEnvironments': '请先添加环境。',
221
+ 'write.solution': '变更默认解决方案',
222
+ 'write.solutionHint': '解决方案唯一名:填了,导出的解决方案才会带上这次改动',
223
+ 'write.savePolicy': '保存权限',
224
+ 'write.copyToAll': '复制到其他环境',
225
+ 'write.copied': '已复制到其他环境,核对后再保存。',
226
+ 'write.savedPolicy': '权限已保存。',
227
+ 'write.saveFailed': '保存失败:{error}',
228
+ 'write.plans': '待确认的写入',
229
+ 'write.plansDesc': 'agent 准备好的计划。在你确认之前,什么都不会执行。',
230
+ 'write.noPlans': '没有待确认的变更。可以让 agent 用 d365_write 生成一个计划。',
231
+ 'write.execute': '审阅并执行',
232
+ 'write.confirmExecute': '确认执行',
233
+ 'write.executing': '执行中…',
234
+ 'write.back': '返回',
235
+ 'write.cancelPlan': '丢弃计划',
236
+ 'write.warningTitle': '这是对真实环境的写入',
237
+ 'write.warningBody': '以下操作会立即作用到 {env}。Dataverse 的元数据变更无法自动回滚,请先核对操作列表。',
238
+ 'write.destructiveBody': '其中包含不可恢复的删除操作:被删的字段或选项值连同其数据一起消失,无法通过重新部署找回。确认前请核对名称。',
239
+ 'write.highRisk': '高危',
240
+ 'write.executed': '执行完成:成功 {ok} 个,失败 {failed} 个。',
241
+ 'write.skipped': '跳过 {count} 条(已存在或无变化)。',
242
+ 'write.publishing': '正在后台发布,稍后生效。',
243
+ 'write.recent': '最近的变更记录',
244
+ 'write.recentLine': '{time} · {env} · 成功 {ok} / 失败 {failed}',
245
+ 'plans.more': '… 其余 {count} 个',
246
+ 'write.publishRunning': '发布中…',
247
+ 'write.published': '已发布',
248
+ 'write.publishFailed': '发布失败:{error}',
249
+ 'write.publishSkipped': '无需发布',
250
+ 'write.republish': '重新发布',
251
+ 'write.republishing': '发布中…',
252
+ 'write.republishOk': '已发布:{targets}',
253
+ 'write.republishFailed': '发布失败:{error}',
254
+ 'write.noTargets': '无需发布',
255
+ 'write.optionSetLabel': '选项集 {name}',
256
+ 'write.loadFailed': '读取写入配置失败:{error}',
257
+ 'general.title': '工具参数',
258
+ 'general.desc': '工具默认使用的数值,和环境清单一起存在 d365.config.json 里。',
259
+ 'general.snapshotKeep': '每个环境保留几份快照',
260
+ 'general.snapshotKeepHint': '同步后删除更旧的快照。范围 1–50。',
261
+ 'general.maxRecords': '单次返回行数上限',
262
+ 'general.maxRecordsHint': '仅在工具调用自己没传值时生效。范围 1–100000。',
263
+ 'general.timeoutMs': '请求超时(毫秒)',
264
+ 'general.timeoutMsHint': '单个 HTTP 请求的超时。范围 5000–300000。',
265
+ 'general.dataDir': '本地数据目录',
266
+ 'general.dataDirHint': '快照、差异、待确认计划、变更记录都在这里。',
267
+ 'general.save': '保存参数',
268
+ 'general.saved': '参数已保存。',
269
+ 'general.loadFailed': '读取参数失败:{error}',
270
+ 'tab.gitlab': 'GitLab',
271
+ 'gitlab.title': 'GitLab',
272
+ 'gitlab.desc': '供 d365_code_check 读取 MR、把改动的脚本对照环境做检查。只需要只读权限。',
273
+ 'gitlab.url': 'GitLab 地址',
274
+ 'gitlab.urlHint': '例如 https://gitlab.example.com,各项目共用同一个页面地址。',
275
+ 'gitlab.project': '默认项目',
276
+ 'gitlab.projectHint': 'group/project。MR 链接里带项目时以链接为准。',
277
+ 'gitlab.token': '访问令牌',
278
+ 'gitlab.tokenHint': '勾了 read_api 的个人令牌或项目令牌。存在 d365.secrets.json,不会写进 d365.config.json。',
279
+ 'gitlab.tokenSaved': '已有令牌(来源:{source}),留空表示不修改。',
280
+ 'gitlab.save': '保存',
281
+ 'gitlab.saved': '已保存。',
282
+ 'gitlab.check': '测试访问',
283
+ 'gitlab.checking': '测试中…',
284
+ 'gitlab.ok': '连接正常,当前用户 {user}。',
285
+ 'gitlab.clear': '删除令牌',
286
+ 'gitlab.cleared': '令牌已删除。',
287
+ 'gitlab.loadFailed': '读取 GitLab 设置失败:{error}',
288
+ },
289
+ }
290
+
291
+ /**
292
+ * The page translates from its own dictionary; the locale service only says
293
+ * *which* language is active. Using the service's lookup directly would turn
294
+ * one failed registration into a page of raw keys.
295
+ */
296
+ let activeLocale = 'en'
297
+ let localeService = null
298
+ const localeListeners = new Set()
299
+
300
+ /** Re-render every mounted panel (locale service arrival or language switch). */
301
+ function notifyLocale() {
302
+ for (const listener of [...localeListeners]) {
303
+ try {
304
+ listener()
305
+ } catch {
306
+ // One crashed panel must not stop the others from re-rendering.
307
+ }
308
+ }
309
+ }
310
+
311
+ function localeFrom(id) {
312
+ return String(id ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'
313
+ }
314
+
315
+ /** Active locale id from either read entry the service exposes. */
316
+ function activeIdOf(locale) {
317
+ try {
318
+ const snapshot = locale?.getLocale?.() ?? locale?.getSnapshot?.()
319
+ return snapshot?.active
320
+ } catch {
321
+ return undefined
322
+ }
323
+ }
324
+
325
+ /** Read the live locale on every call: the page must follow DSH's setting. */
326
+ function currentLocale() {
327
+ const live = activeIdOf(localeService)
328
+ if (live) return localeFrom(live)
329
+ return activeLocale
330
+ }
331
+
332
+ function t(key, params) {
333
+ const text = DICT[currentLocale()]?.[key] ?? DICT.en[key] ?? key
334
+ if (!params) return text
335
+ return String(text).replace(/\{(\w+)\}/g, (match, name) => (params[name] ?? match))
336
+ }
337
+
338
+ /** Re-render a panel when the locale service arrives or the language changes. */
339
+ function useLocaleRevision() {
340
+ const [, setRevision] = React.useState(0)
341
+ React.useEffect(() => {
342
+ const listener = () => setRevision((value) => value + 1)
343
+ localeListeners.add(listener)
344
+ return () => {
345
+ localeListeners.delete(listener)
346
+ }
347
+ }, [])
348
+ }
349
+
350
+ const T = {
351
+ text: 'var(--dsw-alias-label-primary)',
352
+ muted: 'var(--dsw-alias-label-secondary)',
353
+ border: 'var(--dsw-alias-border-l1)',
354
+ borderStrong: 'var(--dsw-alias-border-l2)',
355
+ bg: 'var(--dsw-alias-bg-layer-1)',
356
+ bg2: 'var(--dsw-alias-bg-layer-2)',
357
+ brand: 'var(--dsw-alias-brand-primary)',
358
+ error: 'var(--dsw-alias-state-error-primary)',
359
+ success: 'var(--dsw-alias-state-success-primary)',
360
+ warn: 'var(--dsw-alias-state-warn-primary)',
361
+ }
362
+
363
+ const inputStyle = {
364
+ width: '100%',
365
+ boxSizing: 'border-box',
366
+ padding: '6px 8px',
367
+ borderRadius: '6px',
368
+ border: `1px solid ${T.border}`,
369
+ background: T.bg2,
370
+ color: T.text,
371
+ font: 'inherit',
372
+ fontSize: '13px',
373
+ outline: 'none',
374
+ }
375
+
376
+ const labelStyle = {
377
+ display: 'block',
378
+ fontSize: '12px',
379
+ color: T.muted,
380
+ marginBottom: '4px',
381
+ }
382
+
383
+ function buttonStyle(kind) {
384
+ const base = {
385
+ padding: '6px 12px',
386
+ borderRadius: '6px',
387
+ fontSize: '13px',
388
+ font: 'inherit',
389
+ cursor: 'pointer',
390
+ border: `1px solid ${T.border}`,
391
+ background: 'transparent',
392
+ color: T.text,
393
+ whiteSpace: 'nowrap',
394
+ }
395
+ if (kind === 'primary') return { ...base, background: T.brand, borderColor: T.brand, color: '#fff' }
396
+ if (kind === 'danger') return { ...base, color: T.error, borderColor: 'transparent', padding: '6px 8px' }
397
+ return base
398
+ }
399
+
400
+ const thStyle = {
401
+ textAlign: 'left',
402
+ padding: '6px 8px',
403
+ borderBottom: `1px solid ${T.border}`,
404
+ color: T.muted,
405
+ fontWeight: 500,
406
+ whiteSpace: 'nowrap',
407
+ }
408
+
409
+ const tdStyle = {
410
+ padding: '6px 8px',
411
+ borderBottom: `1px solid ${T.border}`,
412
+ verticalAlign: 'top',
413
+ color: T.text,
414
+ }
415
+
416
+ /** Human-readable byte size, matching the Host's retention formatter. */
417
+ function formatBytesUi(bytes) {
418
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
419
+ const units = ['B', 'KB', 'MB', 'GB']
420
+ let value = bytes
421
+ let unit = 0
422
+ while (value >= 1024 && unit < units.length - 1) {
423
+ value /= 1024
424
+ unit += 1
425
+ }
426
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
427
+ }
428
+
429
+ function Field({ label, hint, children }) {
430
+ return h('label', { style: { display: 'block', minWidth: 0 } },
431
+ h('span', { style: labelStyle }, label),
432
+ children,
433
+ hint ? h('span', { style: { display: 'block', marginTop: '3px', fontSize: '11px', color: T.muted } }, hint) : null,
434
+ )
435
+ }
436
+
437
+ function Hint({ children, kind }) {
438
+ const color = kind === 'error' ? T.error : kind === 'ok' ? T.success : T.muted
439
+ return h('div', { style: { fontSize: '12px', color, lineHeight: 1.5 } }, children)
440
+ }
441
+
442
+ /**
443
+ * Row identity for React. It must not derive from the editable key: a card
444
+ * keyed by its own value is a new element on every keystroke, which unmounts
445
+ * the input and drops the caret after one character.
446
+ */
447
+ let draftSeq = 0
448
+ function nextDraftId() {
449
+ draftSeq += 1
450
+ return `draft-${draftSeq}`
451
+ }
452
+
453
+ function toDraft(env) {
454
+ return {
455
+ id: `env-${env.key}`,
456
+ key: env.key,
457
+ label: env.label ?? '',
458
+ url: env.url ?? '',
459
+ cloud: env.cloud ?? 'public',
460
+ tenantId: env.tenantId ?? '',
461
+ // Carried through the draft so a save round-trips them; the server
462
+ // deletes any field the request omits.
463
+ tenantIdEnv: env.tenantIdEnv ?? '',
464
+ clientIdEnv: env.clientIdEnv ?? '',
465
+ note: env.note ?? '',
466
+ clientId: env.clientId ?? '',
467
+ clientSecret: '',
468
+ clearSecret: false,
469
+ hasSecret: Boolean(env.hasSecret),
470
+ secretSource: env.secretSource,
471
+ existing: true,
472
+ }
473
+ }
474
+
475
+ // ---------------------------------------------------------------- environments
476
+
477
+ function EnvironmentsPanel() {
478
+ useLocaleRevision()
479
+ const [loading, setLoading] = React.useState(true)
480
+ const [error, setError] = React.useState(null)
481
+ const [settings, setSettings] = React.useState(null)
482
+ const [rows, setRows] = React.useState([])
483
+ const [defaultKey, setDefaultKey] = React.useState('')
484
+ const [busy, setBusy] = React.useState(false)
485
+ const [message, setMessage] = React.useState(null)
486
+ const [checks, setChecks] = React.useState({})
487
+
488
+ const applySettings = React.useCallback((next) => {
489
+ setSettings(next)
490
+ setRows((next.environments ?? []).map(toDraft))
491
+ setDefaultKey(next.defaultEnvironment ?? '')
492
+ }, [])
493
+
494
+ React.useEffect(() => {
495
+ let alive = true
496
+ ;(async () => {
497
+ try {
498
+ const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
499
+ const data = await response.json().catch(() => ({}))
500
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
501
+ if (!alive) return
502
+ applySettings(data.settings)
503
+ setError(null)
504
+ } catch (cause) {
505
+ if (alive) setError(cause.message)
506
+ } finally {
507
+ if (alive) setLoading(false)
508
+ }
509
+ })()
510
+ return () => { alive = false }
511
+ }, [applySettings])
512
+
513
+ const update = (index, patch) => {
514
+ setRows((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row)))
515
+ }
516
+
517
+ const addRow = () => {
518
+ let n = 1
519
+ while (rows.some((row) => row.key === `env${n}`)) n += 1
520
+ setRows((current) => [...current, {
521
+ id: nextDraftId(),
522
+ key: `env${n}`,
523
+ label: '',
524
+ url: '',
525
+ cloud: 'public',
526
+ tenantId: '',
527
+ clientId: '',
528
+ clientSecret: '',
529
+ clearSecret: false,
530
+ hasSecret: false,
531
+ existing: false,
532
+ }])
533
+ }
534
+
535
+ const removeRow = (index) => {
536
+ setRows((current) => current.filter((_, i) => i !== index))
537
+ }
538
+
539
+ const save = async () => {
540
+ const keys = new Set()
541
+ for (const row of rows) {
542
+ if (!row.url.trim()) {
543
+ setMessage({ kind: 'error', text: t('urlRequired', { key: row.key }) })
544
+ return
545
+ }
546
+ if (keys.has(row.key)) {
547
+ setMessage({ kind: 'error', text: t('duplicate', { key: row.key }) })
548
+ return
549
+ }
550
+ keys.add(row.key)
551
+ }
552
+
553
+ const environments = {}
554
+ for (const row of rows) {
555
+ environments[row.key] = {
556
+ label: row.label,
557
+ url: row.url,
558
+ cloud: row.cloud,
559
+ tenantId: row.tenantId,
560
+ // These three are not editable here, but they are part of the row:
561
+ // the server treats a missing field as "delete it", so dropping them
562
+ // would silently strip an env-var credential source or a note.
563
+ tenantIdEnv: row.tenantIdEnv ?? '',
564
+ clientIdEnv: row.clientIdEnv ?? '',
565
+ note: row.note ?? '',
566
+ clientId: row.clientId,
567
+ clientSecret: row.clientSecret || undefined,
568
+ clearSecret: row.clearSecret || undefined,
569
+ }
570
+ }
571
+
572
+ setBusy(true)
573
+ setMessage(null)
574
+ try {
575
+ const response = await fetch('/api/d365-meta/config', {
576
+ method: 'POST',
577
+ headers: WRITE_HEADERS,
578
+ body: JSON.stringify({ defaultEnvironment: defaultKey, environments }),
579
+ })
580
+ const data = await response.json().catch(() => ({}))
581
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
582
+ applySettings(data.settings)
583
+ setChecks({})
584
+ setMessage({ kind: 'ok', text: data.secretsTouched?.length ? t('savedSecrets') : t('saved') })
585
+ } catch (cause) {
586
+ setMessage({ kind: 'error', text: t('saveFailed', { error: cause.message }) })
587
+ } finally {
588
+ setBusy(false)
589
+ }
590
+ }
591
+
592
+ const test = async (row) => {
593
+ setChecks((current) => ({ ...current, [row.key]: { status: 'pending' } }))
594
+ try {
595
+ const response = await fetch('/api/d365-meta/check', {
596
+ method: 'POST',
597
+ headers: { 'content-type': 'application/json' },
598
+ body: JSON.stringify({ key: row.key }),
599
+ })
600
+ const data = await response.json().catch(() => ({}))
601
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
602
+ if (data.ok) {
603
+ const org = data.organization ?? {}
604
+ setChecks((current) => ({
605
+ ...current,
606
+ [row.key]: { status: 'ok', text: t('checkOk', { name: org.name ?? '—', version: org.version ?? '?' }) },
607
+ }))
608
+ return
609
+ }
610
+ const missing = data.missing?.length
611
+ ? ` ${t('checkMissing', { list: data.missing.map((item) => item.kind).join(' / ') })}`
612
+ : ''
613
+ setChecks((current) => ({
614
+ ...current,
615
+ [row.key]: { status: 'fail', text: `${t('checkFail', { error: data.error ?? '?' })}${missing}` },
616
+ }))
617
+ } catch (cause) {
618
+ setChecks((current) => ({ ...current, [row.key]: { status: 'fail', text: t('checkFail', { error: cause.message }) } }))
619
+ }
620
+ }
621
+
622
+ if (loading) return h(Hint, null, '…')
623
+ if (error) return h(Hint, { kind: 'error' }, t('loadFailed', { error }))
624
+
625
+ const cards = rows.map((row, index) => {
626
+ const check = checks[row.key]
627
+ return h('div', {
628
+ key: row.id,
629
+ style: {
630
+ border: `1px solid ${T.border}`,
631
+ borderRadius: '8px',
632
+ background: T.bg,
633
+ padding: '12px',
634
+ display: 'flex',
635
+ flexDirection: 'column',
636
+ gap: '10px',
637
+ },
638
+ },
639
+ // One row, equal column shapes: every cell is a label plus exactly one
640
+ // control of the same height, so the labels and the controls line up.
641
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' } },
642
+ h('div', { style: { flex: '0 0 116px', minWidth: '116px' } },
643
+ h(Field, { label: 'key' },
644
+ row.existing
645
+ ? h('input', {
646
+ style: { ...inputStyle, background: 'transparent', fontWeight: 600 },
647
+ value: row.key,
648
+ readOnly: true,
649
+ })
650
+ : h('input', {
651
+ style: inputStyle,
652
+ value: row.key,
653
+ placeholder: 'dev',
654
+ spellCheck: false,
655
+ onChange: (event) => update(index, { key: event.target.value.trim() }),
656
+ }),
657
+ ),
658
+ ),
659
+ h('div', { style: { flex: '1 1 150px', minWidth: 0 } },
660
+ h(Field, { label: t('label') },
661
+ h('input', {
662
+ style: inputStyle,
663
+ value: row.label,
664
+ placeholder: row.key,
665
+ onChange: (event) => update(index, { label: event.target.value }),
666
+ }),
667
+ ),
668
+ ),
669
+ h('div', { style: { flex: '0 0 118px' } },
670
+ h(Field, { label: t('cloud') },
671
+ h('select', {
672
+ style: inputStyle,
673
+ value: row.cloud,
674
+ onChange: (event) => update(index, { cloud: event.target.value }),
675
+ },
676
+ (settings?.clouds ?? ['public']).map((cloud) => h('option', { key: cloud, value: cloud }, cloud)),
677
+ ),
678
+ ),
679
+ ),
680
+ h('button', {
681
+ type: 'button',
682
+ style: { ...buttonStyle('danger'), marginLeft: 'auto' },
683
+ onClick: () => removeRow(index),
684
+ }, t('remove')),
685
+ ),
686
+ h(Field, { label: t('url') },
687
+ h('input', {
688
+ style: inputStyle,
689
+ value: row.url,
690
+ placeholder: 'https://contoso-dev.crm5.dynamics.com',
691
+ spellCheck: false,
692
+ onChange: (event) => update(index, { url: event.target.value }),
693
+ }),
694
+ ),
695
+ h('div', { style: { display: 'flex', gap: '8px' } },
696
+ h('div', { style: { flex: '1 1 50%', minWidth: 0 } },
697
+ h(Field, { label: t('tenantId') },
698
+ h('input', {
699
+ style: inputStyle,
700
+ value: row.tenantId,
701
+ placeholder: '00000000-0000-0000-0000-000000000000',
702
+ spellCheck: false,
703
+ onChange: (event) => update(index, { tenantId: event.target.value }),
704
+ }),
705
+ ),
706
+ ),
707
+ h('div', { style: { flex: '1 1 50%', minWidth: 0 } },
708
+ h(Field, { label: t('clientId') },
709
+ h('input', {
710
+ style: inputStyle,
711
+ value: row.clientId,
712
+ placeholder: '00000000-0000-0000-0000-000000000000',
713
+ spellCheck: false,
714
+ onChange: (event) => update(index, { clientId: event.target.value }),
715
+ }),
716
+ ),
717
+ ),
718
+ ),
719
+ h(Field, {
720
+ label: t('secret'),
721
+ hint: row.hasSecret ? t('secretSaved', { source: row.secretSource ?? 'secrets' }) : t('secretHint'),
722
+ },
723
+ h('input', {
724
+ style: inputStyle,
725
+ type: 'password',
726
+ value: row.clientSecret,
727
+ placeholder: row.hasSecret ? '••••••••' : '',
728
+ autoComplete: 'new-password',
729
+ onChange: (event) => update(index, { clientSecret: event.target.value, clearSecret: false }),
730
+ }),
731
+ ),
732
+ row.hasSecret
733
+ ? h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: T.muted } },
734
+ h('input', {
735
+ type: 'checkbox',
736
+ checked: row.clearSecret,
737
+ onChange: (event) => update(index, { clearSecret: event.target.checked, clientSecret: '' }),
738
+ }),
739
+ t('clear'),
740
+ )
741
+ : null,
742
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
743
+ h('button', {
744
+ type: 'button',
745
+ style: buttonStyle(),
746
+ disabled: check?.status === 'pending' || !row.existing,
747
+ onClick: () => test(row),
748
+ }, check?.status === 'pending' ? t('testing') : t('test')),
749
+ check?.status === 'ok' ? h('span', { style: { fontSize: '12px', color: T.success } }, check.text) : null,
750
+ check?.status === 'fail' ? h('span', { style: { fontSize: '12px', color: T.error } }, check.text) : null,
751
+ ),
752
+ )
753
+ })
754
+
755
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
756
+ settings?.issues?.length
757
+ ? h('div', {
758
+ style: {
759
+ border: `1px solid ${T.warn}`,
760
+ borderRadius: '8px',
761
+ padding: '10px 12px',
762
+ fontSize: '12px',
763
+ color: T.text,
764
+ display: 'flex',
765
+ flexDirection: 'column',
766
+ gap: '4px',
767
+ },
768
+ },
769
+ h('strong', { style: { fontWeight: 600 } }, t('issues')),
770
+ settings.issues.map((issue, index) => h('div', { key: index, style: { color: T.muted } }, issue)),
771
+ )
772
+ : null,
773
+
774
+ cards.length
775
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, cards)
776
+ : h(Hint, null, t('empty')),
777
+
778
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' } },
779
+ h('button', { type: 'button', style: buttonStyle(), onClick: addRow }, t('add')),
780
+ rows.length
781
+ ? h('div', { style: { flex: '0 0 200px' } },
782
+ h(Field, { label: t('default') },
783
+ h('select', {
784
+ style: inputStyle,
785
+ value: defaultKey,
786
+ onChange: (event) => setDefaultKey(event.target.value),
787
+ },
788
+ rows.map((row) => h('option', { key: row.key, value: row.key }, row.key)),
789
+ ),
790
+ ),
791
+ )
792
+ : null,
793
+ h('button', {
794
+ type: 'button',
795
+ style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
796
+ disabled: busy,
797
+ onClick: save,
798
+ }, busy ? t('saving') : t('save')),
799
+ ),
800
+
801
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
802
+
803
+ h('div', { style: { fontSize: '11px', color: T.muted, lineHeight: 1.6 } },
804
+ settings?.configFile ? h('div', null, t('files', { config: settings.configFile })) : null,
805
+ settings?.secretsFile ? h('div', null, t('secretFile', { secrets: settings.secretsFile })) : null,
806
+ ),
807
+ )
808
+ }
809
+
810
+ // ---------------------------------------------------------------- permissions
811
+
812
+ function PermissionsPanel() {
813
+ useLocaleRevision()
814
+ const [loading, setLoading] = React.useState(true)
815
+ const [error, setError] = React.useState(null)
816
+ const [capabilities, setCapabilities] = React.useState({})
817
+ const [environments, setEnvironments] = React.useState([])
818
+ const [draft, setDraft] = React.useState({})
819
+ const [solution, setSolution] = React.useState('')
820
+ const [saving, setSaving] = React.useState(false)
821
+ const [message, setMessage] = React.useState(null)
822
+ const [copySource, setCopySource] = React.useState('')
823
+
824
+ const load = React.useCallback(async () => {
825
+ try {
826
+ const response = await fetch('/api/d365-meta/write/policy')
827
+ const policy = await response.json().catch(() => ({}))
828
+ if (!response.ok) throw new Error(policy.error ?? `HTTP ${response.status}`)
829
+
830
+ const nextDraft = {}
831
+ for (const key of policy.environments ?? []) {
832
+ // An environment with no entry has never been configured: it keeps
833
+ // the default metadata read. An entry, even an empty one, is the
834
+ // whole truth.
835
+ const explicit = policy.policy?.environments?.[key]
836
+ const granted = Array.isArray(explicit) ? explicit : null
837
+ nextDraft[key] = Object.fromEntries(
838
+ Object.keys(policy.capabilities ?? {}).map((capability) => [
839
+ capability,
840
+ granted ? granted.includes(capability) : capability === 'metadata.read',
841
+ ]),
842
+ )
843
+ }
844
+ setCapabilities(policy.capabilities ?? {})
845
+ setEnvironments(policy.environments ?? [])
846
+ setDraft(nextDraft)
847
+ setSolution(policy.policy?.solutionHint ?? '')
848
+ setCopySource((current) => current || (policy.environments ?? [])[0] || '')
849
+ setError(null)
850
+ } catch (cause) {
851
+ setError(cause.message)
852
+ } finally {
853
+ setLoading(false)
854
+ }
855
+ }, [])
856
+
857
+ React.useEffect(() => {
858
+ load()
859
+ }, [load])
860
+
861
+ const toggle = (envKey, capability) => {
862
+ setDraft((current) => ({
863
+ ...current,
864
+ [envKey]: { ...(current[envKey] ?? {}), [capability]: !current[envKey]?.[capability] },
865
+ }))
866
+ }
867
+
868
+ const savePolicy = async () => {
869
+ setSaving(true)
870
+ setMessage(null)
871
+ try {
872
+ const payload = { environments: {}, solutionUniqueName: solution.trim() || undefined }
873
+ for (const [envKey, values] of Object.entries(draft)) {
874
+ const granted = Object.entries(values).filter(([, on]) => on).map(([capability]) => capability)
875
+ // An empty array is meaningful: it is how "untick everything" is
876
+ // recorded. Omitting the key would fall back to the default grants.
877
+ payload.environments[envKey] = granted
878
+ }
879
+ const response = await fetch('/api/d365-meta/write/policy', {
880
+ method: 'POST',
881
+ headers: WRITE_HEADERS,
882
+ body: JSON.stringify(payload),
883
+ })
884
+ const data = await response.json().catch(() => ({}))
885
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
886
+ // Re-read so the boxes show what was stored, not what was clicked.
887
+ await load()
888
+ setMessage({ kind: 'ok', text: t('write.savedPolicy') })
889
+ } catch (cause) {
890
+ setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
891
+ } finally {
892
+ setSaving(false)
893
+ }
894
+ }
895
+
896
+ if (loading) return h(Hint, null, '…')
897
+ if (error) return h(Hint, { kind: 'error' }, t('write.loadFailed', { error }))
898
+
899
+ // Grouping is server-driven: a capability may be added without a client change.
900
+ const groups = {}
901
+ for (const [capability, meta] of Object.entries(capabilities)) {
902
+ const group = meta.group ?? ''
903
+ groups[group] = groups[group] ?? []
904
+ groups[group].push([capability, meta])
905
+ }
906
+
907
+ const totalCapabilities = Object.keys(capabilities).length
908
+
909
+ // Permissions are per-environment, so each environment gets its own
910
+ // block, separated by a rule: a single wide table made one environment's
911
+ // row run into the next.
912
+ const environmentBlocks = environments.map((envKey, index) => {
913
+ const granted = Object.values(draft[envKey] ?? {}).filter(Boolean).length
914
+ return h('div', {
915
+ key: envKey,
916
+ style: {
917
+ display: 'flex',
918
+ flexDirection: 'column',
919
+ gap: '8px',
920
+ paddingTop: index === 0 ? 0 : '16px',
921
+ borderTop: index === 0 ? 'none' : `1px solid ${T.border}`,
922
+ },
923
+ },
924
+ h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' } },
925
+ h('span', { style: { fontSize: '13px', fontWeight: 600, color: T.text } }, envKey),
926
+ h('span', { style: { fontSize: '11px', color: T.muted } }, t('write.envSummary', { granted, total: totalCapabilities })),
927
+ ),
928
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
929
+ Object.entries(groups).map(([group, entries]) => h('div', {
930
+ key: group || '-',
931
+ style: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' },
932
+ },
933
+ group ? h('span', { style: { fontSize: '11px', color: T.muted, minWidth: '48px' } }, group) : null,
934
+ entries.map(([capability, meta]) => h('label', {
935
+ key: capability,
936
+ style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: T.muted },
937
+ title: meta.description,
938
+ },
939
+ h('input', {
940
+ type: 'checkbox',
941
+ checked: Boolean(draft[envKey]?.[capability]),
942
+ onChange: () => toggle(envKey, capability),
943
+ }),
944
+ meta.label,
945
+ meta.risk === 'high' ? h('span', { style: { color: T.error } }, t('write.highRisk')) : null,
946
+ )),
947
+ )),
948
+ ),
949
+ )
950
+ })
951
+
952
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
953
+ h('div', null,
954
+ h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('write.title')),
955
+ h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('write.desc')),
956
+ h('p', { style: { margin: '4px 0 0', fontSize: '11px', color: T.muted, lineHeight: 1.6 } }, t('write.gate')),
957
+ h('p', { style: { margin: '2px 0 0', fontSize: '11px', color: T.muted, lineHeight: 1.6 } }, t('write.envNote')),
958
+ ),
959
+
960
+ environmentBlocks.length
961
+ ? h('div', { style: { display: 'flex', flexDirection: 'column' } }, environmentBlocks)
962
+ : h(Hint, null, t('write.noEnvironments')),
963
+
964
+ h('div', { style: { maxWidth: '420px' } },
965
+ h(Field, { label: t('write.solution'), hint: t('write.solutionHint') },
966
+ h('input', {
967
+ style: inputStyle,
968
+ value: solution,
969
+ onChange: (event) => setSolution(event.target.value),
970
+ }),
971
+ ),
972
+ ),
973
+
974
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
975
+ h('button', {
976
+ type: 'button',
977
+ style: { ...buttonStyle('primary'), opacity: saving ? 0.6 : 1 },
978
+ disabled: saving,
979
+ onClick: savePolicy,
980
+ }, saving ? t('saving') : t('write.savePolicy')),
981
+ h('select', {
982
+ style: { ...inputStyle, width: 'auto' },
983
+ value: copySource,
984
+ onChange: (event) => setCopySource(event.target.value),
985
+ }, environments.map((envKey) => h('option', { key: envKey, value: envKey }, envKey))),
986
+ h('button', {
987
+ type: 'button',
988
+ style: buttonStyle('ghost'),
989
+ disabled: !environments.length,
990
+ onClick: () => {
991
+ const source = draft[copySource] ?? {}
992
+ const next = {}
993
+ for (const envKey of environments) next[envKey] = { ...source }
994
+ setDraft(next)
995
+ setMessage({ kind: 'ok', text: t('write.copied') })
996
+ },
997
+ }, t('write.copyToAll')),
998
+ ),
999
+
1000
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
1001
+ )
1002
+ }
1003
+
1004
+ // --------------------------------------------------------------------- plans
1005
+
1006
+ /**
1007
+ * One line of a plan. Option operations carry their own fields; metadata
1008
+ * operations carry a summary built server-side, so new capabilities need no
1009
+ * client change to be readable.
1010
+ */
1011
+ const OPERATION_VERBS = {
1012
+ InsertOptionValue: '+',
1013
+ CreateAttribute: '+',
1014
+ UpdateOptionValue: '~',
1015
+ UpdateAttribute: '~',
1016
+ UpdateEntity: '~',
1017
+ UpdateViewLayout: '~',
1018
+ DeleteOptionValue: '!',
1019
+ DeleteAttribute: '!',
1020
+ }
1021
+
1022
+ function describeOperation(operation) {
1023
+ const verb = OPERATION_VERBS[operation.type] ?? '·'
1024
+ if (operation.type === 'InsertOptionValue') return `${verb} ${operation.value} ${operation.label}`
1025
+ if (operation.type === 'UpdateOptionValue') {
1026
+ return `${verb} ${operation.value} ${operation.previousLabel ?? ''} → ${operation.label}`
1027
+ }
1028
+ return `${verb} ${operation.summary ?? operation.type}`
1029
+ }
1030
+
1031
+ function PlansPanel({ onCount }) {
1032
+ useLocaleRevision()
1033
+ const [loading, setLoading] = React.useState(true)
1034
+ const [error, setError] = React.useState(null)
1035
+ const [plans, setPlans] = React.useState([])
1036
+ const [changes, setChanges] = React.useState([])
1037
+ const [publishes, setPublishes] = React.useState({})
1038
+ const [message, setMessage] = React.useState(null)
1039
+ const [confirming, setConfirming] = React.useState(null)
1040
+ const [executing, setExecuting] = React.useState(null)
1041
+ const [republishing, setRepublishing] = React.useState(null)
1042
+ const [polls, setPolls] = React.useState(0)
1043
+
1044
+ const load = React.useCallback(async () => {
1045
+ try {
1046
+ const response = await fetch('/api/d365-meta/write/plans')
1047
+ const data = await response.json().catch(() => ({}))
1048
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1049
+ setPlans(data.plans ?? [])
1050
+ setChanges(data.changes ?? [])
1051
+ setPublishes(data.publishes ?? {})
1052
+ setError(null)
1053
+ } catch (cause) {
1054
+ setError(cause.message)
1055
+ } finally {
1056
+ setLoading(false)
1057
+ }
1058
+ }, [])
1059
+
1060
+ React.useEffect(() => {
1061
+ load()
1062
+ }, [load])
1063
+
1064
+ // A publish runs detached, so its outcome appears in the sidecar file
1065
+ // later. Poll while something is still in flight, but never forever: a
1066
+ // request that died with the Host leaves "running" behind for good.
1067
+ const pendingPublish = changes.some((entry) => entry.publishState === 'running' && !publishes[entry.planId])
1068
+ React.useEffect(() => {
1069
+ if (!pendingPublish || polls >= 40) return undefined
1070
+ const timer = setTimeout(() => {
1071
+ setPolls((value) => value + 1)
1072
+ load()
1073
+ }, 3000)
1074
+ return () => clearTimeout(timer)
1075
+ }, [pendingPublish, polls, load])
1076
+
1077
+ React.useEffect(() => {
1078
+ if (!pendingPublish && polls !== 0) setPolls(0)
1079
+ }, [pendingPublish, polls])
1080
+
1081
+ React.useEffect(() => {
1082
+ onCount?.(plans.length)
1083
+ }, [plans.length, onCount])
1084
+
1085
+ const cancelPlan = async (planId) => {
1086
+ try {
1087
+ const response = await fetch('/api/d365-meta/write/cancel', {
1088
+ method: 'POST',
1089
+ headers: WRITE_HEADERS,
1090
+ body: JSON.stringify({ planId }),
1091
+ })
1092
+ // A 403/404/500 here used to look like success: the list refreshed,
1093
+ // the plan was still there, and the person believed it was gone.
1094
+ const data = await response.json().catch(() => ({}))
1095
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1096
+ setConfirming(null)
1097
+ await load()
1098
+ } catch (cause) {
1099
+ setMessage({ kind: 'error', text: String(cause.message) })
1100
+ }
1101
+ }
1102
+
1103
+ const executePlan = async (planId) => {
1104
+ setExecuting(planId)
1105
+ setMessage(null)
1106
+ try {
1107
+ const response = await fetch('/api/d365-meta/write/execute', {
1108
+ method: 'POST',
1109
+ headers: WRITE_HEADERS,
1110
+ body: JSON.stringify({ planId }),
1111
+ })
1112
+ const data = await response.json().catch(() => ({}))
1113
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1114
+ const ok = data.results?.length ?? 0
1115
+ const failed = data.failures?.length ?? 0
1116
+ const publish = data.publishState === 'running' ? t('write.publishing') : ''
1117
+ setMessage({ kind: failed ? 'error' : 'ok', text: t('write.executed', { ok, failed }) + publish })
1118
+ setConfirming(null)
1119
+ await load()
1120
+ } catch (cause) {
1121
+ setMessage({ kind: 'error', text: cause.message })
1122
+ } finally {
1123
+ setExecuting(null)
1124
+ }
1125
+ }
1126
+
1127
+ const republish = async (planId) => {
1128
+ setRepublishing(planId)
1129
+ setMessage(null)
1130
+ try {
1131
+ const response = await fetch('/api/d365-meta/write/publish', {
1132
+ method: 'POST',
1133
+ headers: WRITE_HEADERS,
1134
+ body: JSON.stringify({ planId }),
1135
+ })
1136
+ const data = await response.json().catch(() => ({}))
1137
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1138
+ const publish = data.publish ?? {}
1139
+ setMessage(publish.ok
1140
+ ? { kind: 'ok', text: t('write.republishOk', { targets: targetsText(publish.targets) }) }
1141
+ : { kind: 'error', text: t('write.republishFailed', { error: publish.error ?? '' }) })
1142
+ await load()
1143
+ } catch (cause) {
1144
+ setMessage({ kind: 'error', text: String(cause.message) })
1145
+ } finally {
1146
+ setRepublishing(null)
1147
+ }
1148
+ }
1149
+
1150
+ const targetsText = (scope) => {
1151
+ const parts = [...(scope?.entities ?? [])]
1152
+ for (const name of scope?.optionSets ?? []) parts.push(t('write.optionSetLabel', { name }))
1153
+ return parts.join(' · ')
1154
+ }
1155
+
1156
+ const publishStatus = (entry) => {
1157
+ const report = publishes[entry.planId]
1158
+ if (report && report.ok) return { kind: 'ok', text: t('write.published') }
1159
+ if (report && !report.ok) return { kind: 'error', text: t('write.publishFailed', { error: report.error ?? '' }) }
1160
+ if (entry.publishState === 'running') return { kind: 'muted', text: t('write.publishRunning') }
1161
+ return { kind: 'muted', text: t('write.publishSkipped') }
1162
+ }
1163
+
1164
+ if (loading) return h(Hint, null, '…')
1165
+ if (error) return h(Hint, { kind: 'error' }, t('write.loadFailed', { error }))
1166
+
1167
+ const planCards = plans.map((plan) => {
1168
+ const isConfirming = confirming === plan.id
1169
+ return h('div', {
1170
+ key: plan.id,
1171
+ style: {
1172
+ border: `1px solid ${isConfirming ? T.error : T.border}`,
1173
+ borderRadius: '8px',
1174
+ padding: '10px 12px',
1175
+ display: 'flex',
1176
+ flexDirection: 'column',
1177
+ gap: '8px',
1178
+ background: T.bg,
1179
+ },
1180
+ },
1181
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } },
1182
+ h('strong', { style: { fontSize: '13px', color: T.text } }, plan.title ?? plan.kind),
1183
+ h('span', { style: { fontSize: '11px', color: T.muted } }, `${plan.envKey} · ${plan.createdAt ?? ''}`),
1184
+ ),
1185
+ h('div', { style: { fontSize: '12px', color: T.muted, fontFamily: 'ui-monospace, monospace', lineHeight: 1.5, maxHeight: '140px', overflow: 'auto' } },
1186
+ (plan.operations ?? []).slice(0, 12).map((operation, index) => h('div', { key: index }, describeOperation(operation))),
1187
+ (plan.operations ?? []).length > 12
1188
+ ? h('div', null, t('plans.more', { count: plan.operations.length - 12 }))
1189
+ : null,
1190
+ ),
1191
+ (plan.skipped ?? []).length
1192
+ ? h('div', { style: { fontSize: '11px', color: T.muted } }, t('write.skipped', { count: plan.skipped.length }))
1193
+ : null,
1194
+
1195
+ isConfirming
1196
+ ? h('div', {
1197
+ style: {
1198
+ border: `1px solid ${T.error}`,
1199
+ borderRadius: '6px',
1200
+ padding: '8px 10px',
1201
+ fontSize: '12px',
1202
+ color: T.text,
1203
+ display: 'flex',
1204
+ flexDirection: 'column',
1205
+ gap: '6px',
1206
+ },
1207
+ },
1208
+ h('strong', { style: { color: T.error } }, t('write.warningTitle')),
1209
+ h('span', null, t('write.warningBody', { env: plan.envKey })),
1210
+ plan.kind === 'attribute.delete' || plan.kind === 'optionValue.delete'
1211
+ ? h('span', { style: { color: T.error } }, t('write.destructiveBody'))
1212
+ : null,
1213
+ h('div', { style: { display: 'flex', gap: '8px' } },
1214
+ h('button', {
1215
+ type: 'button',
1216
+ style: { ...buttonStyle('primary'), background: T.error, borderColor: T.error },
1217
+ disabled: executing === plan.id,
1218
+ onClick: () => executePlan(plan.id),
1219
+ }, executing === plan.id ? t('write.executing') : t('write.confirmExecute')),
1220
+ h('button', { type: 'button', style: buttonStyle(), onClick: () => setConfirming(null) }, t('write.back')),
1221
+ ),
1222
+ )
1223
+ : h('div', { style: { display: 'flex', gap: '8px' } },
1224
+ h('button', {
1225
+ type: 'button',
1226
+ style: buttonStyle('primary'),
1227
+ onClick: () => setConfirming(plan.id),
1228
+ }, t('write.execute')),
1229
+ h('button', { type: 'button', style: buttonStyle('danger'), onClick: () => cancelPlan(plan.id) }, t('write.cancelPlan')),
1230
+ ),
1231
+ )
1232
+ })
1233
+
1234
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1235
+ h('div', null,
1236
+ h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('write.plans')),
1237
+ h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('write.plansDesc')),
1238
+ ),
1239
+
1240
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
1241
+
1242
+ planCards.length
1243
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, planCards)
1244
+ : h(Hint, null, t('write.noPlans')),
1245
+
1246
+ changes.length
1247
+ ? h('div', { style: { fontSize: '11px', color: T.muted, lineHeight: 1.6, marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '4px' } },
1248
+ h('div', null, t('write.recent')),
1249
+ changes.slice(0, 5).map((entry, index) => {
1250
+ const status = publishStatus(entry)
1251
+ return h('div', {
1252
+ key: entry.planId ?? index,
1253
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
1254
+ },
1255
+ h('span', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } },
1256
+ h('span', null, t('write.recentLine', {
1257
+ time: entry.recordedAt ?? '',
1258
+ env: entry.envKey ?? '',
1259
+ ok: entry.applied?.length ?? 0,
1260
+ failed: entry.failed?.length ?? 0,
1261
+ })),
1262
+ h('span', { style: { color: status.kind === 'error' ? T.error : status.kind === 'ok' ? T.success : T.muted } }, status.text),
1263
+ ),
1264
+ (entry.applied?.length ?? 0) > 0
1265
+ ? h('button', {
1266
+ type: 'button',
1267
+ style: { ...buttonStyle(), fontSize: '11px', padding: '2px 8px' },
1268
+ disabled: republishing === entry.planId,
1269
+ onClick: () => republish(entry.planId),
1270
+ }, republishing === entry.planId ? t('write.republishing') : t('write.republish'))
1271
+ : null,
1272
+ )
1273
+ }),
1274
+ )
1275
+ : null,
1276
+ )
1277
+ }
1278
+
1279
+ // ------------------------------------------------------------------- general
1280
+
1281
+ const GENERAL_FIELDS = [
1282
+ { key: 'snapshotKeep', label: 'general.snapshotKeep', hint: 'general.snapshotKeepHint', min: 1, max: 50 },
1283
+ { key: 'maxRecords', label: 'general.maxRecords', hint: 'general.maxRecordsHint', min: 1, max: 100000 },
1284
+ { key: 'timeoutMs', label: 'general.timeoutMs', hint: 'general.timeoutMsHint', min: 5000, max: 300000 },
1285
+ ]
1286
+
1287
+ function GeneralPanel() {
1288
+ useLocaleRevision()
1289
+ const [loading, setLoading] = React.useState(true)
1290
+ const [error, setError] = React.useState(null)
1291
+ const [general, setGeneral] = React.useState(null)
1292
+ const [form, setForm] = React.useState({})
1293
+ const [busy, setBusy] = React.useState(false)
1294
+ const [message, setMessage] = React.useState(null)
1295
+
1296
+ React.useEffect(() => {
1297
+ let alive = true
1298
+ ;(async () => {
1299
+ try {
1300
+ const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
1301
+ const data = await response.json().catch(() => ({}))
1302
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1303
+ if (!alive) return
1304
+ const next = data.settings?.general ?? {}
1305
+ setGeneral(next)
1306
+ setForm({
1307
+ snapshotKeep: String(next.snapshotKeep ?? ''),
1308
+ maxRecords: String(next.maxRecords ?? ''),
1309
+ timeoutMs: String(next.timeoutMs ?? ''),
1310
+ })
1311
+ setError(null)
1312
+ } catch (cause) {
1313
+ if (alive) setError(cause.message)
1314
+ } finally {
1315
+ if (alive) setLoading(false)
1316
+ }
1317
+ })()
1318
+ return () => { alive = false }
1319
+ }, [])
1320
+
1321
+ const save = async () => {
1322
+ setBusy(true)
1323
+ setMessage(null)
1324
+ try {
1325
+ const generalPayload = {}
1326
+ for (const field of GENERAL_FIELDS) {
1327
+ const value = Number(form[field.key])
1328
+ if (!Number.isInteger(value) || value < field.min || value > field.max) {
1329
+ throw new Error(`${t(field.label)}: ${field.min}–${field.max}`)
1330
+ }
1331
+ generalPayload[field.key] = value
1332
+ }
1333
+ const response = await fetch('/api/d365-meta/settings', {
1334
+ method: 'POST',
1335
+ headers: WRITE_HEADERS,
1336
+ body: JSON.stringify({ general: generalPayload }),
1337
+ })
1338
+ const data = await response.json().catch(() => ({}))
1339
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1340
+ const next = data.settings?.general ?? general
1341
+ setGeneral(next)
1342
+ setMessage({ kind: 'ok', text: t('general.saved') })
1343
+ } catch (cause) {
1344
+ setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
1345
+ } finally {
1346
+ setBusy(false)
1347
+ }
1348
+ }
1349
+
1350
+ if (loading) return h(Hint, null, '…')
1351
+ if (error) return h(Hint, { kind: 'error' }, t('general.loadFailed', { error }))
1352
+
1353
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1354
+ h('div', null,
1355
+ h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('general.title')),
1356
+ h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('general.desc')),
1357
+ ),
1358
+
1359
+ h('div', { style: { display: 'flex', gap: '10px', flexWrap: 'wrap' } },
1360
+ GENERAL_FIELDS.map((field) => h('div', { key: field.key, style: { flex: '1 1 190px', minWidth: '170px' } },
1361
+ h(Field, { label: t(field.label), hint: t(field.hint) },
1362
+ h('input', {
1363
+ style: inputStyle,
1364
+ type: 'number',
1365
+ min: field.min,
1366
+ max: field.max,
1367
+ value: form[field.key] ?? '',
1368
+ onChange: (event) => setForm((current) => ({ ...current, [field.key]: event.target.value })),
1369
+ }),
1370
+ ),
1371
+ )),
1372
+ ),
1373
+
1374
+ h(Field, { label: t('general.dataDir'), hint: t('general.dataDirHint') },
1375
+ h('input', {
1376
+ style: { ...inputStyle, background: 'transparent', color: T.muted },
1377
+ value: general?.dataDirectory ?? '',
1378
+ readOnly: true,
1379
+ }),
1380
+ ),
1381
+
1382
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
1383
+ h('button', {
1384
+ type: 'button',
1385
+ style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1386
+ disabled: busy,
1387
+ onClick: save,
1388
+ }, busy ? t('saving') : t('general.save')),
1389
+ ),
1390
+
1391
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
1392
+ )
1393
+ }
1394
+
1395
+ function GitlabPanel() {
1396
+ useLocaleRevision()
1397
+ const [loading, setLoading] = React.useState(true)
1398
+ const [error, setError] = React.useState(null)
1399
+ const [gitlab, setGitlab] = React.useState(null)
1400
+ const [form, setForm] = React.useState({ url: '', defaultProject: '', token: '' })
1401
+ const [busy, setBusy] = React.useState(false)
1402
+ const [checking, setChecking] = React.useState(false)
1403
+ const [message, setMessage] = React.useState(null)
1404
+
1405
+ React.useEffect(() => {
1406
+ let alive = true
1407
+ ;(async () => {
1408
+ try {
1409
+ const response = await fetch('/api/d365-meta/config', { headers: { accept: 'application/json' } })
1410
+ const data = await response.json().catch(() => ({}))
1411
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1412
+ if (!alive) return
1413
+ const next = data.settings?.gitlab ?? {}
1414
+ setGitlab(next)
1415
+ setForm({
1416
+ url: next.url ?? '',
1417
+ defaultProject: next.defaultProject ?? '',
1418
+ token: '',
1419
+ })
1420
+ setError(null)
1421
+ } catch (cause) {
1422
+ if (alive) setError(cause.message)
1423
+ } finally {
1424
+ if (alive) setLoading(false)
1425
+ }
1426
+ })()
1427
+ return () => { alive = false }
1428
+ }, [])
1429
+
1430
+ const save = async (extra) => {
1431
+ setBusy(true)
1432
+ setMessage(null)
1433
+ try {
1434
+ const payload = {
1435
+ url: form.url.trim(),
1436
+ defaultProject: form.defaultProject.trim(),
1437
+ // A clear request must not also carry whatever is still typed in the
1438
+ // box: the server stores a supplied token before honouring the clear.
1439
+ ...(form.token && !extra?.clearToken ? { token: form.token } : {}),
1440
+ ...(extra ?? {}),
1441
+ }
1442
+ const response = await fetch('/api/d365-meta/settings', {
1443
+ method: 'POST',
1444
+ headers: WRITE_HEADERS,
1445
+ body: JSON.stringify({ gitlab: payload }),
1446
+ })
1447
+ const data = await response.json().catch(() => ({}))
1448
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1449
+ const next = data.settings?.gitlab ?? gitlab
1450
+ setGitlab(next)
1451
+ setForm((current) => ({ ...current, token: '' }))
1452
+ setMessage({ kind: 'ok', text: extra?.clearToken ? t('gitlab.cleared') : t('gitlab.saved') })
1453
+ } catch (cause) {
1454
+ setMessage({ kind: 'error', text: t('write.saveFailed', { error: cause.message }) })
1455
+ } finally {
1456
+ setBusy(false)
1457
+ }
1458
+ }
1459
+
1460
+ const check = async () => {
1461
+ setChecking(true)
1462
+ setMessage(null)
1463
+ try {
1464
+ const response = await fetch('/api/d365-meta/gitlab/check', {
1465
+ method: 'POST',
1466
+ headers: WRITE_HEADERS,
1467
+ body: JSON.stringify({ url: form.url.trim() }),
1468
+ })
1469
+ const data = await response.json().catch(() => ({}))
1470
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1471
+ if (!data.ok) throw new Error([data.error, data.hint].filter(Boolean).join(' '))
1472
+ setMessage({ kind: 'ok', text: t('gitlab.ok', { user: data.user ?? data.name ?? '' }) })
1473
+ } catch (cause) {
1474
+ setMessage({ kind: 'error', text: cause.message })
1475
+ } finally {
1476
+ setChecking(false)
1477
+ }
1478
+ }
1479
+
1480
+ if (loading) return h(Hint, null, '…')
1481
+ if (error) return h(Hint, { kind: 'error' }, t('gitlab.loadFailed', { error }))
1482
+
1483
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1484
+ h('div', null,
1485
+ h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('gitlab.title')),
1486
+ h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('gitlab.desc')),
1487
+ ),
1488
+
1489
+ h(Field, { label: t('gitlab.url'), hint: t('gitlab.urlHint') },
1490
+ h('input', {
1491
+ style: inputStyle,
1492
+ value: form.url,
1493
+ placeholder: 'https://gitlab.example.com',
1494
+ onChange: (event) => setForm((current) => ({ ...current, url: event.target.value })),
1495
+ }),
1496
+ ),
1497
+
1498
+ h(Field, { label: t('gitlab.project'), hint: t('gitlab.projectHint') },
1499
+ h('input', {
1500
+ style: inputStyle,
1501
+ value: form.defaultProject,
1502
+ placeholder: 'group/project',
1503
+ onChange: (event) => setForm((current) => ({ ...current, defaultProject: event.target.value })),
1504
+ }),
1505
+ ),
1506
+
1507
+ h(Field, {
1508
+ label: t('gitlab.token'),
1509
+ hint: gitlab?.hasToken && !form.token
1510
+ ? t('gitlab.tokenSaved', { source: gitlab.tokenSource ?? '' })
1511
+ : t('gitlab.tokenHint'),
1512
+ },
1513
+ h('input', {
1514
+ style: inputStyle,
1515
+ type: 'password',
1516
+ value: form.token,
1517
+ placeholder: gitlab?.hasToken ? '••••••••' : '',
1518
+ autoComplete: 'off',
1519
+ onChange: (event) => setForm((current) => ({ ...current, token: event.target.value })),
1520
+ }),
1521
+ ),
1522
+
1523
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
1524
+ h('button', {
1525
+ type: 'button',
1526
+ style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1527
+ disabled: busy,
1528
+ onClick: () => save(),
1529
+ }, busy ? t('saving') : t('gitlab.save')),
1530
+ h('button', {
1531
+ type: 'button',
1532
+ style: { ...buttonStyle('ghost'), opacity: checking ? 0.6 : 1 },
1533
+ disabled: checking,
1534
+ onClick: check,
1535
+ }, checking ? t('gitlab.checking') : t('gitlab.check')),
1536
+ gitlab?.hasToken
1537
+ ? h('button', {
1538
+ type: 'button',
1539
+ style: buttonStyle('ghost'),
1540
+ disabled: busy,
1541
+ onClick: () => save({ clearToken: true }),
1542
+ }, t('gitlab.clear'))
1543
+ : null,
1544
+ ),
1545
+
1546
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
1547
+ )
1548
+ }
1549
+
1550
+ function StoragePanel() {
1551
+ useLocaleRevision()
1552
+ const [loading, setLoading] = React.useState(true)
1553
+ const [error, setError] = React.useState(null)
1554
+ const [storage, setStorage] = React.useState(null)
1555
+ const [plan, setPlan] = React.useState(null)
1556
+ const [busy, setBusy] = React.useState(false)
1557
+ const [message, setMessage] = React.useState(null)
1558
+
1559
+ const load = React.useCallback(async () => {
1560
+ const response = await fetch('/api/d365-meta/storage', { headers: { accept: 'application/json' } })
1561
+ const data = await response.json().catch(() => ({}))
1562
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1563
+ return data.storage
1564
+ }, [])
1565
+
1566
+ React.useEffect(() => {
1567
+ let alive = true
1568
+ ;(async () => {
1569
+ try {
1570
+ const next = await load()
1571
+ if (alive) {
1572
+ setStorage(next)
1573
+ setError(null)
1574
+ }
1575
+ } catch (cause) {
1576
+ if (alive) setError(cause.message)
1577
+ } finally {
1578
+ if (alive) setLoading(false)
1579
+ }
1580
+ })()
1581
+ return () => { alive = false }
1582
+ }, [load])
1583
+
1584
+ const run = async (path, body) => {
1585
+ setBusy(true)
1586
+ setMessage(null)
1587
+ try {
1588
+ const response = await fetch(path, {
1589
+ method: 'POST',
1590
+ headers: WRITE_HEADERS,
1591
+ body: JSON.stringify(body ?? {}),
1592
+ })
1593
+ const data = await response.json().catch(() => ({}))
1594
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
1595
+ if (data.storage) setStorage(data.storage)
1596
+ return data
1597
+ } catch (cause) {
1598
+ setMessage({ kind: 'error', text: t('data.failed', { error: cause.message }) })
1599
+ return null
1600
+ } finally {
1601
+ setBusy(false)
1602
+ }
1603
+ }
1604
+
1605
+ const preview = async () => {
1606
+ const data = await run('/api/d365-meta/storage/cleanup', { dryRun: true, target: 'all' })
1607
+ if (!data) return
1608
+ setPlan(data.plan)
1609
+ if (!data.plan.totalFiles) setMessage({ kind: 'muted', text: t('data.nothing') })
1610
+ }
1611
+
1612
+ const remove = async () => {
1613
+ const data = await run('/api/d365-meta/storage/cleanup', { dryRun: false, target: 'all' })
1614
+ if (!data) return
1615
+ setPlan(null)
1616
+ setMessage({ kind: 'ok', text: t('data.deleted', { files: data.deleted, size: data.bytesText }) })
1617
+ }
1618
+
1619
+ const compact = async () => {
1620
+ const data = await run('/api/d365-meta/storage/compact', {})
1621
+ if (!data) return
1622
+ setMessage({ kind: 'ok', text: t('data.compacted', { snapshots: data.snapshots, size: data.savedText }) })
1623
+ }
1624
+
1625
+ if (loading) return h(Hint, null, '…')
1626
+ if (error) return h(Hint, { kind: 'error' }, t('general.loadFailed', { error }))
1627
+ if (!storage) return null
1628
+
1629
+ const rows = storage.classes ?? []
1630
+ const percent = storage.warnMegabytes
1631
+ ? Math.min(100, Math.round((storage.totalBytes / (storage.warnMegabytes * 1048576)) * 100))
1632
+ : 0
1633
+
1634
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1635
+ h('div', null,
1636
+ h('h3', { style: { margin: 0, fontSize: '14px', color: T.text } }, t('data.title')),
1637
+ h('p', { style: { margin: '4px 0 0', fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('data.desc')),
1638
+ ),
1639
+
1640
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
1641
+ h('strong', { style: { fontSize: '13px', color: T.text } }, `${t('data.total')}:${formatBytesUi(storage.totalBytes)}`),
1642
+ h('div', { style: { flex: '1 1 200px', minWidth: '160px' } },
1643
+ h('div', { style: { height: '6px', borderRadius: '3px', background: T.border, overflow: 'hidden' } },
1644
+ h('div', {
1645
+ style: {
1646
+ width: `${percent}%`,
1647
+ height: '100%',
1648
+ background: storage.overLimit ? '#d13438' : T.brand,
1649
+ },
1650
+ }),
1651
+ ),
1652
+ ),
1653
+ storage.overLimit
1654
+ ? h('span', { style: { fontSize: '12px', color: '#d13438' } }, t('data.overLimit', { mb: storage.warnMegabytes }))
1655
+ : null,
1656
+ ),
1657
+
1658
+ h('div', { style: { overflowX: 'auto' } },
1659
+ h('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' } },
1660
+ h('thead', null, h('tr', null,
1661
+ h('th', { style: thStyle }, t('data.class')),
1662
+ h('th', { style: thStyle }, t('data.count')),
1663
+ h('th', { style: thStyle }, t('data.size')),
1664
+ h('th', { style: thStyle }, t('data.policy')),
1665
+ )),
1666
+ h('tbody', null, rows.map((row) => h('tr', { key: row.id },
1667
+ h('td', { style: tdStyle }, row.label + (row.hint ? ` · ${row.hint}` : '')),
1668
+ h('td', { style: tdStyle }, String(row.count)),
1669
+ h('td', { style: tdStyle }, formatBytesUi(row.bytes)),
1670
+ h('td', { style: tdStyle }, t('data.keepPolicy', { keep: row.keep, days: row.maxAgeDays })),
1671
+ ))),
1672
+ ),
1673
+ ),
1674
+
1675
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
1676
+ h('button', {
1677
+ type: 'button',
1678
+ style: { ...buttonStyle('primary'), opacity: busy ? 0.6 : 1 },
1679
+ disabled: busy,
1680
+ onClick: preview,
1681
+ }, t('data.cleanup')),
1682
+ h('button', {
1683
+ type: 'button',
1684
+ style: { ...buttonStyle(), opacity: busy ? 0.6 : 1 },
1685
+ disabled: busy,
1686
+ onClick: compact,
1687
+ }, t('data.compact')),
1688
+ ),
1689
+
1690
+ plan && plan.totalFiles
1691
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
1692
+ h(Hint, { kind: 'muted' }, t('data.plan', { files: plan.totalFiles, size: plan.totalText })),
1693
+ h('div', null, h('button', {
1694
+ type: 'button',
1695
+ style: { ...buttonStyle('danger'), opacity: busy ? 0.6 : 1 },
1696
+ disabled: busy,
1697
+ onClick: remove,
1698
+ }, t('data.confirm'))),
1699
+ )
1700
+ : null,
1701
+
1702
+ message ? h(Hint, { kind: message.kind }, message.text) : null,
1703
+ )
1704
+ }
1705
+
1706
+ // ---------------------------------------------------------------------- page
1707
+
1708
+ const TABS = [
1709
+ { id: 'environments', label: 'tab.environments' },
1710
+ { id: 'permissions', label: 'tab.permissions' },
1711
+ { id: 'plans', label: 'tab.plans' },
1712
+ { id: 'data', label: 'tab.data' },
1713
+ { id: 'gitlab', label: 'tab.gitlab' },
1714
+ { id: 'general', label: 'tab.general' },
1715
+ ]
1716
+
1717
+ function SettingsPage() {
1718
+ useLocaleRevision()
1719
+ const [tab, setTab] = React.useState('environments')
1720
+ const [pending, setPending] = React.useState(0)
1721
+
1722
+ const panels = {
1723
+ environments: h(EnvironmentsPanel),
1724
+ permissions: h(PermissionsPanel),
1725
+ plans: h(PlansPanel, { onCount: setPending }),
1726
+ data: h(StoragePanel),
1727
+ gitlab: h(GitlabPanel),
1728
+ general: h(GeneralPanel),
1729
+ }
1730
+
1731
+ const tabBar = h('div', {
1732
+ style: {
1733
+ display: 'flex',
1734
+ gap: '2px',
1735
+ borderBottom: `1px solid ${T.border}`,
1736
+ marginBottom: '2px',
1737
+ },
1738
+ }, TABS.map((item) => {
1739
+ const active = tab === item.id
1740
+ const suffix = item.id === 'plans' && pending ? ` (${pending})` : ''
1741
+ return h('button', {
1742
+ key: item.id,
1743
+ type: 'button',
1744
+ onClick: () => setTab(item.id),
1745
+ style: {
1746
+ padding: '7px 12px',
1747
+ font: 'inherit',
1748
+ fontSize: '13px',
1749
+ cursor: 'pointer',
1750
+ background: 'transparent',
1751
+ border: 'none',
1752
+ borderBottom: `2px solid ${active ? T.brand : 'transparent'}`,
1753
+ color: active ? T.text : T.muted,
1754
+ fontWeight: active ? 600 : 400,
1755
+ marginBottom: '-1px',
1756
+ },
1757
+ }, t(item.label) + suffix)
1758
+ }))
1759
+
1760
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '840px' } },
1761
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1762
+ h('h2', { style: { margin: 0, fontSize: '15px', fontWeight: 600, color: T.text } }, t('section.title')),
1763
+ h('p', { style: { margin: 0, fontSize: '12px', color: T.muted, lineHeight: 1.5 } }, t('section.desc')),
1764
+ ),
1765
+ tabBar,
1766
+ // Every panel stays mounted so switching tabs does not discard edits
1767
+ // or restart a connection test.
1768
+ ...TABS.map((item) => h('div', {
1769
+ key: item.id,
1770
+ style: { display: tab === item.id ? 'block' : 'none' },
1771
+ }, panels[item.id])),
1772
+ )
1773
+ }
1774
+
1775
+ return {
1776
+ inject: ['slots'],
1777
+ apply(ctx) {
1778
+ // The `locale` service is provided by @deepseek-ai/dsh-client-locale,
1779
+ // which may activate after this plugin. A one-shot `ctx.get('locale')`
1780
+ // read would then be undefined forever and the page would stay English
1781
+ // — the reported "settings page does not follow DSH's language" bug.
1782
+ // `ctx.inject` waits for the service and re-runs this body if it is
1783
+ // replaced, so the wiring is late-safe and self-healing.
1784
+ ctx.inject(['locale'], (ctx) => {
1785
+ const locale = ctx.locale
1786
+ localeService = locale
1787
+ ctx.effect(() => () => {
1788
+ if (localeService === locale) localeService = null
1789
+ })
1790
+
1791
+ const sync = () => {
1792
+ const live = activeIdOf(locale)
1793
+ if (live) activeLocale = localeFrom(live)
1794
+ notifyLocale()
1795
+ }
1796
+ sync()
1797
+ ctx.effect(() => locale.subscribe?.(sync) ?? (() => {}))
1798
+ // Registration is a courtesy for other surfaces reading this
1799
+ // namespace; the page never depends on it succeeding.
1800
+ ctx.effect(() => {
1801
+ try {
1802
+ return locale.register('d365-meta', DICT)
1803
+ } catch {
1804
+ return () => {}
1805
+ }
1806
+ })
1807
+ })
1808
+
1809
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1810
+ name: 'settings.section',
1811
+ id: 'd365-meta',
1812
+ order: 30,
1813
+ label: () => t('section.title'),
1814
+ }, SettingsPage))
1815
+ },
1816
+ }
1817
+ },
1818
+ })