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.
- package/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- 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
|
+
})
|