dsh-env-manager-plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -2
  3. package/client.js +2581 -0
  4. package/cordis.patch.yml +4 -0
  5. package/docs/00-project/current-state.md +246 -0
  6. package/docs/00-project/glossary.md +215 -0
  7. package/docs/00-project/project-brief.md +114 -0
  8. package/docs/00-project/roadmap.md +89 -0
  9. package/docs/01-architecture/DSH_Environment_Service_Memory_Skill_Brainstorm.md +493 -0
  10. package/docs/01-architecture/api-verification.md +416 -0
  11. package/docs/01-architecture/architecture-overview.md +222 -0
  12. package/docs/01-architecture/data-model.md +930 -0
  13. package/docs/01-architecture/decisions/ADR-0001-plugin-as-bundle.md +29 -0
  14. package/docs/01-architecture/decisions/ADR-0002-file-based-storage.md +25 -0
  15. package/docs/01-architecture/decisions/ADR-0003-skill-router-over-search.md +25 -0
  16. package/docs/01-architecture/decisions/ADR-0004-typescript-host-js-client.md +31 -0
  17. package/docs/01-architecture/decisions/ADR-0005-layered-rule-resolution.md +36 -0
  18. package/docs/01-architecture/decisions/ADR-0006-credentials-as-references.md +30 -0
  19. package/docs/01-architecture/decisions/ADR-0007-session-scoped-default-environment.md +28 -0
  20. package/docs/01-architecture/decisions/ADR-0008-memory-bridge-not-second-store.md +31 -0
  21. package/docs/01-architecture/decisions/ADR-0009-client-host-http-transport.md +87 -0
  22. package/docs/01-architecture/decisions/ADR-0010-environment-plane-model.md +87 -0
  23. package/docs/01-architecture/decisions/ADR-0011-k8s-node-execution-model.md +92 -0
  24. package/docs/01-architecture/design.md +666 -0
  25. package/docs/01-architecture/detailed-design.md +961 -0
  26. package/docs/01-architecture/implementation-plan.md +323 -0
  27. package/docs/01-architecture/module-boundaries.md +306 -0
  28. package/docs/01-architecture/security-model.md +227 -0
  29. package/docs/01-architecture/storage-contract.md +224 -0
  30. package/docs/01-architecture/tool-contract.md +438 -0
  31. package/docs/01-architecture/ui-design.md +587 -0
  32. package/docs/02-harness/agent-behavior.md +36 -0
  33. package/docs/02-harness/change-snapshot-policy.md +29 -0
  34. package/docs/02-harness/client-ui-integration.md +110 -0
  35. package/docs/02-harness/context-policy.md +43 -0
  36. package/docs/02-harness/harness-standard.md +91 -0
  37. package/docs/02-harness/task-execution-policy.md +26 -0
  38. package/docs/02-harness/verification-policy.md +97 -0
  39. package/docs/03-openspec/archive-rule.md +25 -0
  40. package/docs/03-openspec/bugfix-rule.md +30 -0
  41. package/docs/03-openspec/design-rule.md +33 -0
  42. package/docs/03-openspec/openspec-usage-guide.md +31 -0
  43. package/docs/03-openspec/proposal-rule.md +70 -0
  44. package/docs/03-openspec/spec-rule.md +33 -0
  45. package/docs/03-openspec/tasks-rule.md +31 -0
  46. package/docs/04-development/coding-style.md +67 -0
  47. package/docs/04-development/dependency-policy.md +43 -0
  48. package/docs/04-development/development-guide.md +85 -0
  49. package/docs/04-development/directory-convention.md +105 -0
  50. package/docs/04-development/local-run-guide.md +55 -0
  51. package/docs/04-development/testing-guide.md +102 -0
  52. package/docs/05-domain/deployment-template.md +76 -0
  53. package/docs/05-domain/environment-template.md +165 -0
  54. package/docs/05-domain/operation-binding-template.md +55 -0
  55. package/docs/05-domain/operation-record-template.md +55 -0
  56. package/docs/05-domain/prompt-template.md +90 -0
  57. package/docs/05-domain/rule-template.md +73 -0
  58. package/docs/05-domain/service-template.md +39 -0
  59. package/docs/06-operations/deployment-guide.md +54 -0
  60. package/docs/06-operations/release.md +188 -0
  61. package/docs/06-operations/security-policy.md +53 -0
  62. package/docs/06-operations/troubleshooting.md +91 -0
  63. package/docs/07-templates/adr-template.md +21 -0
  64. package/docs/07-templates/bugfix-template.md +11 -0
  65. package/docs/07-templates/case-template.md +15 -0
  66. package/docs/07-templates/design-template.md +29 -0
  67. package/docs/07-templates/proposal-template.md +19 -0
  68. package/docs/07-templates/spec-template.md +15 -0
  69. package/docs/07-templates/tasks-template.md +12 -0
  70. package/docs/99-archive/README.md +13 -0
  71. package/docs/README.md +142 -0
  72. package/icon.svg +4 -0
  73. package/index.js +8 -0
  74. package/lib/app/app.d.ts +67 -0
  75. package/lib/app/app.js +494 -0
  76. package/lib/app/app.js.map +1 -0
  77. package/lib/config.d.ts +55 -0
  78. package/lib/config.js +42 -0
  79. package/lib/config.js.map +1 -0
  80. package/lib/domain/constants.d.ts +21 -0
  81. package/lib/domain/constants.js +59 -0
  82. package/lib/domain/constants.js.map +1 -0
  83. package/lib/domain/errors.d.ts +20 -0
  84. package/lib/domain/errors.js +22 -0
  85. package/lib/domain/errors.js.map +1 -0
  86. package/lib/domain/schemas.d.ts +28 -0
  87. package/lib/domain/schemas.js +445 -0
  88. package/lib/domain/schemas.js.map +1 -0
  89. package/lib/domain/types.d.ts +412 -0
  90. package/lib/domain/types.js +2 -0
  91. package/lib/domain/types.js.map +1 -0
  92. package/lib/index.d.ts +18 -0
  93. package/lib/index.js +89 -0
  94. package/lib/index.js.map +1 -0
  95. package/lib/logger.d.ts +8 -0
  96. package/lib/logger.js +13 -0
  97. package/lib/logger.js.map +1 -0
  98. package/lib/memory/bridge.d.ts +18 -0
  99. package/lib/memory/bridge.js +13 -0
  100. package/lib/memory/bridge.js.map +1 -0
  101. package/lib/registry/integrity.d.ts +10 -0
  102. package/lib/registry/integrity.js +204 -0
  103. package/lib/registry/integrity.js.map +1 -0
  104. package/lib/registry/registry.d.ts +14 -0
  105. package/lib/registry/registry.js +182 -0
  106. package/lib/registry/registry.js.map +1 -0
  107. package/lib/resolver/merge.d.ts +23 -0
  108. package/lib/resolver/merge.js +90 -0
  109. package/lib/resolver/merge.js.map +1 -0
  110. package/lib/resolver/resolve.d.ts +18 -0
  111. package/lib/resolver/resolve.js +288 -0
  112. package/lib/resolver/resolve.js.map +1 -0
  113. package/lib/resolver/template.d.ts +10 -0
  114. package/lib/resolver/template.js +42 -0
  115. package/lib/resolver/template.js.map +1 -0
  116. package/lib/router/params.d.ts +3 -0
  117. package/lib/router/params.js +83 -0
  118. package/lib/router/params.js.map +1 -0
  119. package/lib/router/router.d.ts +11 -0
  120. package/lib/router/router.js +204 -0
  121. package/lib/router/router.js.map +1 -0
  122. package/lib/session/session.d.ts +16 -0
  123. package/lib/session/session.js +64 -0
  124. package/lib/session/session.js.map +1 -0
  125. package/lib/skill.d.ts +17 -0
  126. package/lib/skill.js +56 -0
  127. package/lib/skill.js.map +1 -0
  128. package/lib/storage/atomic.d.ts +10 -0
  129. package/lib/storage/atomic.js +107 -0
  130. package/lib/storage/atomic.js.map +1 -0
  131. package/lib/storage/paths.d.ts +10 -0
  132. package/lib/storage/paths.js +50 -0
  133. package/lib/storage/paths.js.map +1 -0
  134. package/lib/storage/records.d.ts +16 -0
  135. package/lib/storage/records.js +112 -0
  136. package/lib/storage/records.js.map +1 -0
  137. package/lib/storage/sessions.d.ts +17 -0
  138. package/lib/storage/sessions.js +65 -0
  139. package/lib/storage/sessions.js.map +1 -0
  140. package/lib/tools/tools.d.ts +14 -0
  141. package/lib/tools/tools.js +472 -0
  142. package/lib/tools/tools.js.map +1 -0
  143. package/lib/transport/http.d.ts +3 -0
  144. package/lib/transport/http.js +217 -0
  145. package/lib/transport/http.js.map +1 -0
  146. package/locale/en.json +6 -0
  147. package/locale/zh.json +6 -0
  148. package/package.json +97 -4
package/client.js ADDED
@@ -0,0 +1,2581 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-env-manager',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const { useCallback, useEffect, useMemo, useState } = React;
7
+
8
+ const MESSAGES = {
9
+ en: {
10
+ title: 'Environment Manager',
11
+ subtitle: 'Environments, services, deployments and operation routing.',
12
+ overview: 'Overview',
13
+ records: 'Records',
14
+ context: 'Context',
15
+ operations: 'Operations',
16
+ overviewHint: 'Registry health and the latest validation run.',
17
+ environmentsHint: 'Every environment, host, account and login audit. Start here to add a new environment.',
18
+ recordsHint: 'Services, deployments, rule profiles and operation bindings. Environments live on the Environments tab.',
19
+ contextHint: 'Resolve layered variables and inspect their provenance.',
20
+ operationsHint: 'Resolve the Skill bound to an operation and context.',
21
+ refresh: 'Refresh',
22
+ validate: 'Validate',
23
+ save: 'Save',
24
+ remove: 'Delete',
25
+ newRecord: 'New record',
26
+ environments: 'Environments',
27
+ services: 'Services',
28
+ deployments: 'Deployments',
29
+ ruleProfiles: 'Rule profiles',
30
+ operationBindings: 'Operation bindings',
31
+ runtime: 'Runtime',
32
+ dataDir: 'Data directory',
33
+ totalRecords: 'Total records',
34
+ warnings: 'Warnings',
35
+ noWarnings: 'No warnings.',
36
+ validation: 'Validation',
37
+ validationPassed: 'Registry is valid',
38
+ validationFailed: 'Registry has errors',
39
+ noDiagnostics: 'No diagnostics.',
40
+ selectRecord: 'Select a record from the list.',
41
+ selectRecordHint: 'Choose a record to edit its YAML, or create one from a template.',
42
+ selectEnvironmentHint: 'Choose an environment to inspect hosts, accounts and login audit, or add a new one.',
43
+ emptyRecords: 'No records yet',
44
+ emptyRecordsHint: 'Create one from a template, or refresh.',
45
+ emptyEnvironments: 'No environments yet',
46
+ emptyEnvironmentsHint: 'Add the first environment, or refresh.',
47
+ emptyRegistry: 'Empty registry',
48
+ gettingStarted: 'Get started',
49
+ gettingStartedHint: 'Register an environment first, then its services, deployments and operation bindings. Context resolution and routing become available as soon as records exist.',
50
+ openRecords: 'Open environments',
51
+ backToRecords: 'Back to environments',
52
+ basicInfo: 'Basic info',
53
+ loginRecords: 'Login records',
54
+ endpoints: 'Endpoints',
55
+ rawYaml: 'YAML',
56
+ editYaml: 'Edit YAML',
57
+ environmentOverview: 'Environment overview',
58
+ allNodes: 'All nodes',
59
+ allNodesHint: 'Every host in this environment, regardless of plane.',
60
+ nodeSearch: 'Search nodes',
61
+ nodeSearchPlaceholder: 'Name, IP, role, kind, cluster or login',
62
+ nodeKind: 'Node type',
63
+ hostKindVm: 'VM',
64
+ hostKindPhysical: 'Physical',
65
+ hostKindK8sAllInOne: 'K8s all-in-one',
66
+ hostKindK8sWorker: 'K8s worker',
67
+ hostKindPodHost: 'Pod host',
68
+ nodeAccess: 'Command access',
69
+ accessSsh: 'SSH',
70
+ accessKubectl: 'kubectl',
71
+ accessDocker: 'docker',
72
+ nodeKindHint: 'All-in-one means control plane and worker on one machine; Pod host means a host running pods and operated with docker exec.',
73
+ nodeAccessHint: 'Record the command surfaces available on this node. This is capability metadata, not a credential.',
74
+ k8sCluster: 'K8s cluster',
75
+ k8sClusterRequired: 'K8s node types require a cluster.',
76
+ k8sClusterUnknown: 'Select an existing cluster.',
77
+ clusters: 'Kubernetes / clusters',
78
+ clusterCount: 'Clusters',
79
+ k8sNodeCount: 'K8s / Pod nodes',
80
+ addCluster: 'Add cluster',
81
+ editCluster: 'Edit cluster',
82
+ clusterId: 'Cluster ID',
83
+ clusterName: 'Cluster name',
84
+ clusterKind: 'Cluster type',
85
+ clusterTopology: 'Topology',
86
+ topologyAllInOne: 'All-in-one',
87
+ topologySeparate: 'Separate',
88
+ topologyExternal: 'External',
89
+ clusterApiEndpoint: 'API endpoint',
90
+ clusterNamespaces: 'Namespaces',
91
+ clusterNodeCount: 'Nodes',
92
+ clusterRequired: 'Cluster id and name are required.',
93
+ clusterIdExists: 'That cluster id already exists.',
94
+ noClusters: 'No clusters yet',
95
+ noClustersHint: 'Add a K8s cluster before assigning K8s or Pod-host nodes.',
96
+ clustersHint: 'Clusters group K8s all-in-one, worker and Pod-host nodes.',
97
+ addNode: 'Add node',
98
+ editNode: 'Edit node',
99
+ batchImport: 'Batch import',
100
+ batchImportHint: 'One node per line: IP, name, roles, port, user. Separate multiple roles with | or /. No passwords are imported.',
101
+ batchPlaceholder: '10.20.30.52,db-02,database,22,root\n10.20.30.53,app-01,business|management,22,root',
102
+ importNodes: 'Import nodes',
103
+ batchCredentialsHint: 'Batch import creates nodes and optional accounts without secrets. Bind passwords later per node.',
104
+ nodeCount: 'Nodes',
105
+ planeSummary: 'Nodes by plane',
106
+ roleManagement: 'Management',
107
+ roleBusiness: 'Business',
108
+ roleDatabase: 'Database',
109
+ roleExtension: 'Expansion / other',
110
+ rolesRequired: 'Select at least one role.',
111
+ nodeFieldsRequired: 'Fill in node name and select at least one role.',
112
+ nodeAccountIncomplete: 'Enter both login user and password, or leave both empty.',
113
+ nodeAccountHint: 'Optional. Enter both username and password to create a password account; YAML keeps only the credential reference.',
114
+ nodeAccountEditHint: 'Leave username and password empty to keep existing accounts. Filling both adds a new password account.',
115
+ batchEmpty: 'Paste at least one node line.',
116
+ batchInvalid: 'Some lines are invalid. Check the IP, roles and comma-separated fields.',
117
+ duplicateIp: 'That IP already exists in this environment.',
118
+ duplicateNodeId: 'That node id already exists in this environment.',
119
+ invalidIp: 'Enter a valid IPv4 address.',
120
+ invalidPort: 'Enter a port between 1 and 65535.',
121
+ unknownRole: 'Unknown role.',
122
+ nodesImported: 'Nodes imported',
123
+ nodeAdded: 'Node added',
124
+ nodeUpdated: 'Node updated',
125
+ clusterAdded: 'Cluster added',
126
+ clusterUpdated: 'Cluster updated',
127
+ noNodes: 'No nodes yet',
128
+ noNodesHint: 'Add the first node and assign one or more planes.',
129
+ noNodesForPlane: 'No nodes in this plane',
130
+ noNodesForPlaneHint: 'Add a node to this plane or import several at once.',
131
+ accountScopeHint: 'Account ids are unique within this environment and form the credential reference.',
132
+ accessSummary: 'Access summary',
133
+ loginSummary: 'Login summary',
134
+ hostCount: 'Hosts',
135
+ name: 'Name',
136
+ kind: 'Kind',
137
+ target: 'Target',
138
+ port: 'Port',
139
+ accountCount: 'Accounts',
140
+ endpointCount: 'Endpoints',
141
+ latestLogin: 'Latest login',
142
+ failedLogins: 'Failed logins',
143
+ never: 'Not recorded',
144
+ active: 'Active',
145
+ inactive: 'Inactive',
146
+ host: 'Host',
147
+ accounts: 'Accounts',
148
+ adminUser: 'Admin user',
149
+ platform: 'Platform',
150
+ ipAddress: 'IP address',
151
+ roles: 'Roles',
152
+ credentialRef: 'Credential ref',
153
+ purpose: 'Purpose',
154
+ protocol: 'Protocol',
155
+ authType: 'Auth',
156
+ username: 'Username',
157
+ source: 'Source',
158
+ note: 'Note',
159
+ result: 'Result',
160
+ success: 'Success',
161
+ failure: 'Failure',
162
+ time: 'Time',
163
+ category: 'Category',
164
+ aliases: 'Aliases',
165
+ description: 'Description',
166
+ createdAt: 'Created',
167
+ updatedAt: 'Updated',
168
+ noAccounts: 'No login accounts on this host.',
169
+ noLoginRecords: 'No login records',
170
+ noLoginRecordsHint: 'Login audit entries appear here once they are recorded.',
171
+ noEndpoints: 'No access endpoints',
172
+ noEndpointsHint: 'Add SSH, web or API endpoints in the YAML tab.',
173
+ authPassword: 'Password',
174
+ authPrivateKey: 'Private key',
175
+ authAgent: 'Agent',
176
+ authToken: 'Token',
177
+ authOther: 'Other',
178
+ editBasic: 'Edit info',
179
+ addAccount: 'Add account',
180
+ addWebLogin: 'Add web login',
181
+ editAccount: 'Edit account',
182
+ webLoginUrl: 'Web login URL',
183
+ webLoginHint: 'For a web console, enter its login URL and username. The password is written once to the DSH credential service; YAML keeps only credentialRef.',
184
+ accountPasswordHint: 'Leave blank to keep the existing credential reference. Enter a password to write a new credential value.',
185
+ webLoginUrlInvalid: 'Web login URL must start with http:// or https://.',
186
+ accountAdded: 'Account added',
187
+ accountUpdated: 'Account updated',
188
+ addLogin: 'Add login record',
189
+ cancel: 'Cancel',
190
+ hostId: 'Node id',
191
+ hostname: 'Node name',
192
+ os: 'OS',
193
+ sshPort: 'SSH port',
194
+ loginAt: 'Login time',
195
+ loginUser: 'Login user',
196
+ loginResult: 'Result',
197
+ selectHost: 'Select a host',
198
+ accountId: 'Account id',
199
+ invalidForm: 'Fill in the required fields first.',
200
+ action: 'Actions',
201
+ optional: 'Optional',
202
+ yaml: 'YAML',
203
+ resolve: 'Resolve',
204
+ route: 'Route',
205
+ environmentId: 'Environment id',
206
+ serviceId: 'Service id',
207
+ deploymentId: 'Deployment id (optional)',
208
+ operation: 'Operation',
209
+ parameters: 'Parameters',
210
+ result: 'Result',
211
+ emptyResult: 'Fill in the ids and run Resolve.',
212
+ emptyRoute: 'Choose an operation and run Route.',
213
+ saved: 'Saved',
214
+ deleted: 'Deleted',
215
+ confirmDelete: 'Delete this record? A backup is kept.',
216
+ loading: 'Loading…',
217
+ panelUnavailable: 'The panel API is unavailable.',
218
+ newEnvironment: 'New environment',
219
+ newEnvironmentHint: 'Create the environment shell first, then add nodes by plane.',
220
+ environmentIdOptional: 'Environment id (optional)',
221
+ password: 'Password',
222
+ createEnvironment: 'Create environment',
223
+ fillEnvironmentShell: 'Fill in the environment name.',
224
+ defaultRoles: 'Default planes',
225
+ defaultRolesHint: 'Management, business, database and expansion are created by default. Add nodes to each plane after creating the environment.',
226
+ envIdExists: 'That environment id already exists.',
227
+ environmentCreated: 'Environment created',
228
+ },
229
+ zh: {
230
+ title: '环境管理器',
231
+ subtitle: '环境、服务、部署与操作路由。',
232
+ overview: '概览',
233
+ records: '记录',
234
+ context: '上下文',
235
+ operations: '操作',
236
+ overviewHint: '注册表健康状态与最近一次校验结果。',
237
+ environmentsHint: '所有环境、主机、账号与登录审计;新增环境从这里开始。',
238
+ recordsHint: '服务、部署、规则集与操作绑定;环境请在「环境」页维护。',
239
+ contextHint: '解析分层变量,并查看每个值的来源。',
240
+ operationsHint: '解析操作在指定上下文下绑定的 Skill。',
241
+ refresh: '刷新',
242
+ validate: '校验',
243
+ save: '保存',
244
+ remove: '删除',
245
+ newRecord: '新建记录',
246
+ environments: '环境',
247
+ services: '服务',
248
+ deployments: '部署',
249
+ ruleProfiles: '规则集',
250
+ operationBindings: '操作绑定',
251
+ runtime: '运行状态',
252
+ dataDir: '数据目录',
253
+ totalRecords: '记录总数',
254
+ warnings: '警告',
255
+ noWarnings: '无警告。',
256
+ validation: '校验',
257
+ validationPassed: '注册表有效',
258
+ validationFailed: '注册表存在错误',
259
+ noDiagnostics: '无诊断信息。',
260
+ selectRecord: '请选择一条记录。',
261
+ selectRecordHint: '从左侧选择记录编辑 YAML,或从模板新建。',
262
+ selectEnvironmentHint: '从左侧选择环境查看主机、账号与登录审计,或新增环境。',
263
+ emptyRecords: '暂无记录',
264
+ emptyRecordsHint: '从模板新建一条,或刷新列表。',
265
+ emptyEnvironments: '还没有环境',
266
+ emptyEnvironmentsHint: '新增第一套环境,或刷新列表。',
267
+ emptyRegistry: '注册表为空',
268
+ gettingStarted: '开始使用',
269
+ gettingStartedHint: '先注册环境,再补充服务、部署和操作绑定;有记录后即可解析上下文和路由操作。',
270
+ openRecords: '打开环境',
271
+ backToRecords: '返回环境列表',
272
+ basicInfo: '基本信息',
273
+ loginRecords: '登录记录',
274
+ endpoints: '访问端点',
275
+ rawYaml: 'YAML',
276
+ editYaml: '编辑 YAML',
277
+ environmentOverview: '环境概览',
278
+ allNodes: '全部节点',
279
+ allNodesHint: '环境中的全部主机,不区分平面。',
280
+ nodeSearch: '搜索节点',
281
+ nodeSearchPlaceholder: '名称、IP、角色、类型、集群或登录',
282
+ nodeKind: '节点类型',
283
+ hostKindVm: '虚机',
284
+ hostKindPhysical: '物理机',
285
+ hostKindK8sAllInOne: 'K8s 合设',
286
+ hostKindK8sWorker: 'K8s 工作节点',
287
+ hostKindPodHost: 'Pod 宿主机',
288
+ nodeAccess: '执行方式',
289
+ accessSsh: 'SSH',
290
+ accessKubectl: 'kubectl',
291
+ accessDocker: 'docker',
292
+ nodeKindHint: '合设指控制面与工作节点同机;Pod 宿主机指运行 Pod、通过 docker exec 操作的宿主机。',
293
+ nodeAccessHint: '记录节点上可用的命令入口;这是能力元数据,不是凭据。',
294
+ k8sCluster: 'K8s 集群',
295
+ k8sClusterRequired: 'K8s 类型节点必须关联集群。',
296
+ k8sClusterUnknown: '请选择已存在的集群。',
297
+ clusters: 'K8s / 集群',
298
+ clusterCount: '集群',
299
+ k8sNodeCount: 'K8s / Pod 节点',
300
+ addCluster: '添加集群',
301
+ editCluster: '编辑集群',
302
+ clusterId: '集群 ID',
303
+ clusterName: '集群名称',
304
+ clusterKind: '集群类型',
305
+ clusterTopology: '拓扑',
306
+ topologyAllInOne: '合设',
307
+ topologySeparate: '控制面/工作节点分离',
308
+ topologyExternal: '外部托管',
309
+ clusterApiEndpoint: 'API 地址',
310
+ clusterNamespaces: '命名空间',
311
+ clusterNodeCount: '节点',
312
+ clusterRequired: '请填写集群 ID 和名称。',
313
+ clusterIdExists: '该集群 ID 已存在。',
314
+ noClusters: '还没有集群',
315
+ noClustersHint: '先添加 K8s 集群,再添加 K8s 或 Pod 宿主机节点。',
316
+ clustersHint: '集群用于归组 K8s 合设、工作节点与 Pod 宿主机节点。',
317
+ addNode: '添加节点',
318
+ editNode: '编辑节点',
319
+ batchImport: '批量导入',
320
+ batchImportHint: '每行一个节点:IP、名称、角色、端口、用户;多角色用 | 或 / 分隔。不导入密码。',
321
+ batchPlaceholder: '10.20.30.52,db-02,database,22,root\n10.20.30.53,app-01,business|management,22,root',
322
+ importNodes: '导入节点',
323
+ batchCredentialsHint: '批量导入只创建节点和可选账号,不含密码;密码后续按节点绑定。',
324
+ nodeCount: '节点',
325
+ planeSummary: '节点分布',
326
+ roleManagement: '管理面',
327
+ roleBusiness: '业务面',
328
+ roleDatabase: '数据库',
329
+ roleExtension: '扩容 / 其他',
330
+ rolesRequired: '请至少选择一个角色。',
331
+ nodeFieldsRequired: '请填写节点名,并至少选择一个角色。',
332
+ nodeAccountIncomplete: '登录用户和密码要么都填,要么都留空。',
333
+ nodeAccountHint: '可选。填写登录用户和密码会创建密码账号;YAML 只保存凭据引用。',
334
+ nodeAccountEditHint: '登录用户和密码留空会保留现有账号;两者都填会新增一个密码账号。',
335
+ batchEmpty: '请至少粘贴一行节点。',
336
+ batchInvalid: '存在无效行,请检查 IP、角色和逗号分隔格式。',
337
+ duplicateIp: '该 IP 已存在于本环境。',
338
+ duplicateNodeId: '该节点 ID 已存在于本环境。',
339
+ invalidIp: '请输入合法的 IPv4 地址。',
340
+ invalidPort: '请输入 1–65535 之间的端口。',
341
+ unknownRole: '未知角色。',
342
+ nodesImported: '节点已导入',
343
+ nodeAdded: '节点已添加',
344
+ nodeUpdated: '节点已更新',
345
+ clusterAdded: '集群已添加',
346
+ clusterUpdated: '集群已更新',
347
+ noNodes: '暂无节点',
348
+ noNodesHint: '添加第一个节点,可同时选择多个平面。',
349
+ noNodesForPlane: '该平面暂无节点',
350
+ noNodesForPlaneHint: '向该平面添加节点,或批量导入多个节点。',
351
+ accountScopeHint: '账号 ID 在本环境内唯一,并用于生成凭据引用。',
352
+ accessSummary: '访问概况',
353
+ loginSummary: '登录概况',
354
+ hostCount: '主机',
355
+ name: '名称',
356
+ kind: '类型',
357
+ target: '目标',
358
+ port: '端口',
359
+ accountCount: '账号',
360
+ endpointCount: '端点',
361
+ latestLogin: '最近登录',
362
+ failedLogins: '失败次数',
363
+ never: '未记录',
364
+ active: '启用',
365
+ inactive: '停用',
366
+ host: '主机',
367
+ accounts: '账号',
368
+ adminUser: '管理员',
369
+ platform: '平台',
370
+ ipAddress: 'IP 地址',
371
+ roles: '角色',
372
+ credentialRef: '凭据引用',
373
+ purpose: '用途',
374
+ protocol: '协议',
375
+ authType: '认证',
376
+ username: '用户名',
377
+ source: '来源',
378
+ note: '备注',
379
+ result: '结果',
380
+ success: '成功',
381
+ failure: '失败',
382
+ time: '时间',
383
+ category: '类别',
384
+ aliases: '别名',
385
+ description: '描述',
386
+ createdAt: '创建时间',
387
+ updatedAt: '更新时间',
388
+ noAccounts: '该主机还没有登录账号。',
389
+ noLoginRecords: '暂无登录记录',
390
+ noLoginRecordsHint: '登记 loginRecords 后会在这里显示登录审计。',
391
+ noEndpoints: '暂无访问端点',
392
+ noEndpointsHint: '在 YAML 页补充 SSH、Web 或 API 端点。',
393
+ authPassword: '密码',
394
+ authPrivateKey: '私钥',
395
+ authAgent: 'Agent',
396
+ authToken: 'Token',
397
+ authOther: '其他',
398
+ editBasic: '编辑信息',
399
+ addAccount: '添加账号',
400
+ addWebLogin: '添加 Web 登录',
401
+ editAccount: '编辑账号',
402
+ webLoginUrl: 'Web 登录 URL',
403
+ webLoginHint: 'Web 控制台请填写登录 URL 和用户名;密码只写入 DSH 凭据服务一次,YAML 仅保留 credentialRef。',
404
+ accountPasswordHint: '留空保留现有凭据引用;填写密码会写入新的凭据值。',
405
+ webLoginUrlInvalid: 'Web 登录 URL 必须以 http:// 或 https:// 开头。',
406
+ accountAdded: '账号已添加',
407
+ accountUpdated: '账号已更新',
408
+ addLogin: '登记登录',
409
+ cancel: '取消',
410
+ hostId: '节点 ID',
411
+ hostname: '节点名',
412
+ os: '操作系统',
413
+ sshPort: 'SSH 端口',
414
+ loginAt: '登录时间',
415
+ loginUser: '登录用户',
416
+ loginResult: '结果',
417
+ selectHost: '选择主机',
418
+ accountId: '账号 ID',
419
+ invalidForm: '请先填写必填项。',
420
+ action: '操作',
421
+ optional: '可选',
422
+ yaml: 'YAML',
423
+ resolve: '解析',
424
+ route: '路由',
425
+ environmentId: '环境 ID',
426
+ serviceId: '服务 ID',
427
+ deploymentId: '部署 ID(可选)',
428
+ operation: '操作',
429
+ parameters: '参数',
430
+ result: '结果',
431
+ emptyResult: '填写 ID 后点击“解析”。',
432
+ emptyRoute: '选择操作后点击“路由”。',
433
+ saved: '已保存',
434
+ deleted: '已删除',
435
+ confirmDelete: '删除该记录?会保留备份。',
436
+ loading: '加载中…',
437
+ panelUnavailable: '面板 API 不可用。',
438
+ newEnvironment: '新建环境',
439
+ newEnvironmentHint: '先创建环境壳,再按平面添加节点。',
440
+ environmentIdOptional: '环境 ID(可选)',
441
+ password: '密码',
442
+ createEnvironment: '创建环境',
443
+ fillEnvironmentShell: '请填写环境名称。',
444
+ defaultRoles: '默认平面',
445
+ defaultRolesHint: '默认创建管理面、业务面、数据库和扩容四类;创建后在各自平面添加节点。',
446
+ envIdExists: '该环境 ID 已存在。',
447
+ environmentCreated: '环境已创建',
448
+ },
449
+ };
450
+ const lang = String(navigator.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en';
451
+ const t = (key) => MESSAGES[lang][key] || MESSAGES.en[key] || key;
452
+
453
+ const KINDS = ['environment', 'service', 'deployment', 'ruleProfile', 'operationBinding'];
454
+ const RECORD_KINDS = KINDS.filter((kind) => kind !== 'environment');
455
+ const DEFAULT_ROLES = [
456
+ { id: 'management', name: 'Management Plane' },
457
+ { id: 'business', name: 'Business Plane' },
458
+ { id: 'database', name: 'Database' },
459
+ { id: 'extension', name: 'Expansion / other' },
460
+ ];
461
+ const ROLE_KEYS = {
462
+ management: 'roleManagement',
463
+ business: 'roleBusiness',
464
+ database: 'roleDatabase',
465
+ extension: 'roleExtension',
466
+ };
467
+ const HOST_KINDS = ['vm', 'physical', 'k8s-all-in-one', 'k8s-worker', 'pod-host'];
468
+ const HOST_KIND_KEYS = {
469
+ vm: 'hostKindVm',
470
+ physical: 'hostKindPhysical',
471
+ 'k8s-all-in-one': 'hostKindK8sAllInOne',
472
+ 'k8s-worker': 'hostKindK8sWorker',
473
+ 'pod-host': 'hostKindPodHost',
474
+ };
475
+ const K8S_NODE_KINDS = new Set(['k8s-all-in-one', 'k8s-worker']);
476
+ const HOST_ACCESS_METHODS = ['ssh', 'kubectl', 'docker'];
477
+ const HOST_ACCESS_KEYS = {
478
+ ssh: 'accessSsh',
479
+ kubectl: 'accessKubectl',
480
+ docker: 'accessDocker',
481
+ };
482
+ const CLUSTER_TOPOLOGIES = ['all-in-one', 'separate', 'external'];
483
+ const CLUSTER_TOPOLOGY_KEYS = {
484
+ 'all-in-one': 'topologyAllInOne',
485
+ separate: 'topologySeparate',
486
+ external: 'topologyExternal',
487
+ };
488
+ const DEFAULT_HOST_ACCESS = {
489
+ vm: ['ssh'],
490
+ physical: ['ssh'],
491
+ 'k8s-all-in-one': ['ssh', 'kubectl', 'docker'],
492
+ 'k8s-worker': ['ssh', 'kubectl'],
493
+ 'pod-host': ['ssh', 'docker'],
494
+ };
495
+ const hostKindLabel = (value) => t(HOST_KIND_KEYS[value] || 'hostKindVm');
496
+ const hostAccessLabel = (value) => t(HOST_ACCESS_KEYS[value] || value);
497
+ const topologyLabel = (value) => t(CLUSTER_TOPOLOGY_KEYS[value] || value);
498
+ const defaultHostAccess = (kind) => [...(DEFAULT_HOST_ACCESS[kind] || ['ssh'])];
499
+ const hostKindOf = (host) => (host && HOST_KIND_KEYS[host.kind] ? host.kind : 'vm');
500
+ const hostAccessOf = (host) => (Array.isArray(host && host.access) ? host.access : []);
501
+ const KIND_LABEL = {
502
+ environment: 'environments',
503
+ service: 'services',
504
+ deployment: 'deployments',
505
+ ruleProfile: 'ruleProfiles',
506
+ operationBinding: 'operationBindings',
507
+ };
508
+ const CATEGORY_LABELS = {
509
+ en: { dev: 'Development', test: 'Test', staging: 'Staging', production: 'Production', custom: 'Custom' },
510
+ zh: { dev: '开发', test: '测试', staging: '预发', production: '生产', custom: '自定义' },
511
+ };
512
+ function categoryLabel(value) {
513
+ return (CATEGORY_LABELS[lang] || CATEGORY_LABELS.en)[value] || value || '—';
514
+ }
515
+ function slugify(value) {
516
+ return String(value || '')
517
+ .toLowerCase()
518
+ .replace(/[^a-z0-9-]+/g, '-')
519
+ .replace(/^-+|-+$/g, '')
520
+ .slice(0, 63);
521
+ }
522
+ function stableSlug(value, prefix) {
523
+ const slug = slugify(value);
524
+ if (slug) return slug;
525
+ let hash = 7;
526
+ for (const char of String(value || '')) {
527
+ hash = ((hash * 31) + char.codePointAt(0)) >>> 0;
528
+ }
529
+ return prefix + '-' + hash.toString(36);
530
+ }
531
+ function formatTime(value) {
532
+ if (!value) return t('never');
533
+ const time = new Date(value);
534
+ if (Number.isNaN(time.getTime())) return value;
535
+ return time.toLocaleString();
536
+ }
537
+ function authLabel(value) {
538
+ const keys = {
539
+ password: 'authPassword',
540
+ 'private-key': 'authPrivateKey',
541
+ agent: 'authAgent',
542
+ token: 'authToken',
543
+ other: 'authOther',
544
+ };
545
+ return value ? t(keys[value] || value) : '—';
546
+ }
547
+
548
+ async function call(op, payload) {
549
+ let response;
550
+ try {
551
+ response = await fetch('/env-manager/api', {
552
+ method: 'POST',
553
+ headers: {
554
+ 'content-type': 'application/json',
555
+ 'x-env-manager-request': '1',
556
+ },
557
+ body: JSON.stringify({ op, payload: payload || {} }),
558
+ });
559
+ } catch (error) {
560
+ throw new Error(t('panelUnavailable'));
561
+ }
562
+ const data = await response.json().catch(() => null);
563
+ if (!data || data.ok !== true) {
564
+ const error = new Error((data && data.error && data.error.message) || response.statusText);
565
+ error.code = data && data.error ? data.error.code : 'HTTP_' + response.status;
566
+ error.details = data && data.error ? data.error.details : undefined;
567
+ throw error;
568
+ }
569
+ return data.data;
570
+ }
571
+
572
+ const STYLE_ID = 'dsh-env-manager-styles';
573
+
574
+ const CSS = `
575
+ .dem-root{height:100%;min-height:100%;overflow:auto;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);padding:28px 32px 48px;box-sizing:border-box;font:13px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
576
+ .dem-root *{box-sizing:border-box}
577
+ .dem-shell{max-width:1080px;margin:0 auto;display:flex;flex-direction:column;gap:20px}
578
+ .dem-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
579
+ .dem-header-actions{display:flex;align-items:center;gap:8px;padding-top:2px}
580
+ .dem-heading{display:flex;align-items:flex-start;gap:12px;min-width:0}
581
+ .dem-mark{width:38px;height:38px;flex:none;display:flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l1);border-radius:11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary)}
582
+ .dem-heading-copy{min-width:0}
583
+ .dem-title{margin:0;font-size:22px;font-weight:650;letter-spacing:-.01em;line-height:1.25}
584
+ .dem-sub{margin:5px 0 0;color:var(--dsw-alias-label-secondary);font-size:13px;max-width:720px}
585
+ .dem-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow-x:auto}
586
+ .dem-tab{border:0;background:transparent;color:var(--dsw-alias-label-secondary);padding:9px 12px;margin-bottom:-1px;border-bottom:2px solid transparent;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
587
+ .dem-tab:hover{color:var(--dsw-alias-label-primary)}
588
+ .dem-tab[aria-selected="true"]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-brand-primary);font-weight:600}
589
+ .dem-view{display:flex;flex-direction:column;gap:14px;min-width:0}
590
+ .dem-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap}
591
+ .dem-toolbar-copy{min-width:0}
592
+ .dem-section-title{margin:0;font-size:15px;font-weight:650;line-height:1.35}
593
+ .dem-hint{margin:3px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
594
+ .dem-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
595
+ .dem-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:7px 12px;font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}
596
+ .dem-btn:hover{background:var(--dsw-alias-bg-layer-2)}
597
+ .dem-btn:disabled{opacity:.5;cursor:default}
598
+ .dem-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base)}
599
+ .dem-btn-primary:hover{background:var(--dsw-alias-brand-primary);filter:brightness(1.06)}
600
+ .dem-btn-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:transparent}
601
+ .dem-btn-danger:hover{background:var(--dsw-alias-bg-layer-2)}
602
+ .dem-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
603
+ .dem-stat{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:14px 16px;min-width:0}
604
+ .dem-stat-label{color:var(--dsw-alias-label-secondary);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
605
+ .dem-stat-value{margin-top:6px;font-size:26px;font-weight:650;line-height:1.1;font-variant-numeric:tabular-nums}
606
+ .dem-two{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);gap:14px;align-items:stretch}
607
+ .dem-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:16px 18px;min-width:0}
608
+ .dem-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
609
+ .dem-pill{display:inline-flex;align-items:center;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 9px;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);white-space:nowrap}
610
+ .dem-pill.ok{color:var(--dsw-alias-state-success-primary)}
611
+ .dem-pill.err{color:var(--dsw-alias-state-error-primary)}
612
+ .dem-pill.warn{color:var(--dsw-alias-state-warn-primary)}
613
+ .dem-kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px 16px;margin:0;font-size:12px}
614
+ .dem-kv dt{color:var(--dsw-alias-label-secondary);white-space:nowrap}
615
+ .dem-kv dd{margin:0;overflow-wrap:anywhere}
616
+ .dem-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
617
+ .dem-empty{border:1px dashed var(--dsw-alias-border-l1);border-radius:10px;padding:24px 16px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px;display:flex;flex-direction:column;gap:4px;align-items:center;justify-content:center;min-height:96px}
618
+ .dem-empty strong{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}
619
+ .dem-empty .dem-btn{margin-top:8px}
620
+ .dem-diag{display:flex;align-items:flex-start;gap:9px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}
621
+ .dem-diag:last-child{border-bottom:0;padding-bottom:0}
622
+ .dem-diag:first-child{padding-top:0}
623
+ .dem-dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary);flex:none;margin-top:5px}
624
+ .dem-dot.err{background:var(--dsw-alias-state-error-primary)}
625
+ .dem-diag-body{min-width:0}
626
+ .dem-diag-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary)}
627
+ .dem-diag-msg{overflow-wrap:anywhere}
628
+ .dem-alert{margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-state-error-primary);border-left:3px solid var(--dsw-alias-state-error-primary);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-state-error-primary);font-size:12px;white-space:pre-wrap}
629
+ .dem-toast{margin:0;display:flex;align-items:center;gap:8px;padding:9px 12px;border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-state-success-primary);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-state-success-primary);font-size:12px}
630
+ .dem-loading{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;padding:18px 0}
631
+ .dem-spinner{width:13px;height:13px;border:2px solid var(--dsw-alias-border-l1);border-top-color:var(--dsw-alias-brand-primary);border-radius:50%;animation:dem-spin .8s linear infinite;flex:none}
632
+ @keyframes dem-spin{to{transform:rotate(360deg)}}
633
+ .dem-subtabs{display:flex;gap:4px;flex-wrap:wrap;padding:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);align-self:flex-start;max-width:100%}
634
+ .dem-subtab{border:0;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:7px;padding:5px 9px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
635
+ .dem-subtab:hover{color:var(--dsw-alias-label-primary)}
636
+ .dem-subtab[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600}
637
+ .dem-record-layout{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:14px;align-items:start}
638
+ .dem-list-card{padding:8px;display:flex;flex-direction:column;gap:2px;min-height:480px;max-height:calc(100vh - 300px);overflow:auto}
639
+ .dem-list-card>.dem-empty{flex:1;margin:4px}
640
+ .dem-list-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:6px 8px 8px;color:var(--dsw-alias-label-secondary);font-size:12px}
641
+ .dem-list-title{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600}
642
+ .dem-list-item{width:100%;display:flex;flex-direction:column;gap:2px;border:0;border-radius:8px;background:transparent;color:inherit;text-align:left;padding:8px 10px;font:inherit;cursor:pointer;min-width:0}
643
+ .dem-list-item:hover{background:var(--dsw-alias-bg-layer-2)}
644
+ .dem-list-item[data-active="true"]{background:var(--dsw-alias-bg-layer-2);box-shadow:inset 2px 0 var(--dsw-alias-brand-primary)}
645
+ .dem-item-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
646
+ .dem-item-meta{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
647
+ .dem-editor{padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-height:480px}
648
+ .dem-editor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
649
+ .dem-editor-title{font-size:14px;font-weight:650;overflow-wrap:anywhere}
650
+ .dem-editor-sub{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px}
651
+ .dem-textarea{width:100%;flex:1;min-height:420px;resize:vertical;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:11px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.55;tab-size:2}
652
+ .dem-empty-editor{min-height:480px;display:flex;align-items:center;justify-content:center;padding:20px}
653
+ .dem-empty-editor .dem-empty{min-height:220px;width:100%}
654
+ .dem-form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}
655
+ .dem-form-grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}
656
+ .dem-field{display:flex;flex-direction:column;gap:5px;min-width:0}
657
+ .dem-label{color:var(--dsw-alias-label-secondary);font-size:12px}
658
+ .dem-input{width:100%;min-width:0;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px 9px;font:inherit;font-size:12px}
659
+ .dem-form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}
660
+ .dem-result{margin:0;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere;max-height:520px;overflow:auto}
661
+ .dem-flow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:10px}
662
+ .dem-flow-step{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:5px 10px;background:var(--dsw-alias-bg-layer-2);font-size:12px;white-space:nowrap}
663
+ .dem-flow-index{width:16px;height:16px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);font-size:10px;color:var(--dsw-alias-label-secondary)}
664
+ .dem-flow-arrow{color:var(--dsw-alias-label-secondary);font-size:12px}
665
+ .dem-flow-actions{display:flex;justify-content:flex-end;margin-top:12px}
666
+ .dem-detail{display:flex;flex-direction:column;gap:14px}
667
+ .dem-detail-top{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
668
+ .dem-back{border:0;background:transparent;color:var(--dsw-alias-label-secondary);padding:2px 0;font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
669
+ .dem-back:hover{color:var(--dsw-alias-label-primary)}
670
+ .dem-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap}
671
+ .dem-detail-identity{display:flex;align-items:flex-start;gap:12px;min-width:0}
672
+ .dem-detail-mark{width:42px;height:42px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);display:flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);flex:none}
673
+ .dem-detail-title{margin:0;font-size:21px;font-weight:650;line-height:1.2;overflow-wrap:anywhere}
674
+ .dem-detail-sub{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:5px;color:var(--dsw-alias-label-secondary);font-size:12px}
675
+ .dem-detail-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
676
+ .dem-detail-tabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l1)}
677
+ .dem-detail-tab{border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);padding:8px 10px;font:inherit;font-size:12px;cursor:pointer;margin-bottom:-1px;white-space:nowrap}
678
+ .dem-detail-tab:hover{color:var(--dsw-alias-label-primary)}
679
+ .dem-detail-tab[aria-selected="true"]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-brand-primary);font-weight:600}
680
+ .dem-detail-grid{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(250px,.75fr);gap:14px;align-items:start}
681
+ .dem-detail-main,.dem-detail-rail{display:flex;flex-direction:column;gap:14px;min-width:0}
682
+ .dem-info-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 22px}
683
+ .dem-info-item{min-width:0}
684
+ .dem-info-label{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.4}
685
+ .dem-info-value{margin-top:2px;font-size:12px;overflow-wrap:anywhere}
686
+ .dem-info-value.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
687
+ .dem-info-wide{grid-column:1/-1}
688
+ .dem-chips{display:flex;gap:5px;flex-wrap:wrap}
689
+ .dem-chip{display:inline-flex;align-items:center;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);padding:2px 8px;font-size:11px}
690
+ .dem-host-card{display:flex;flex-direction:column;gap:10px}
691
+ .dem-host-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
692
+ .dem-host-name{font-size:13px;font-weight:650;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
693
+ .dem-host-meta{display:flex;gap:12px;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:11px;margin-top:3px}
694
+ .dem-host-meta code,.dem-code-inline{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
695
+ .dem-link{color:var(--dsw-alias-brand-primary);text-decoration:none;word-break:break-all}
696
+ .dem-link:hover{text-decoration:underline}
697
+ .dem-account-url{display:block;margin-top:2px;font-size:11px}
698
+ .dem-node-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
699
+ .dem-node-search{flex:1;min-width:180px;max-width:320px}
700
+ .dem-plane-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
701
+ .dem-plane-card{border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-2);color:inherit;padding:10px 11px;text-align:left;font:inherit;cursor:pointer;min-width:0}
702
+ .dem-plane-card:hover{border-color:var(--dsw-alias-brand-primary)}
703
+ .dem-plane-card[data-active="true"]{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 0 0 0 1px var(--dsw-alias-brand-primary)}
704
+ .dem-plane-name{display:block;color:var(--dsw-alias-label-secondary);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
705
+ .dem-plane-count{display:block;font-size:20px;font-weight:650;line-height:1.2;margin-top:3px;font-variant-numeric:tabular-nums}
706
+ .dem-check-grid{display:flex;flex-wrap:wrap;gap:6px}
707
+ .dem-check{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-2);padding:5px 9px;font-size:12px;cursor:pointer;white-space:nowrap}
708
+ .dem-check input{margin:0;accent-color:var(--dsw-alias-brand-primary)}
709
+ .dem-batch-example{margin:0;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;white-space:pre-wrap;color:var(--dsw-alias-label-secondary)}
710
+ .dem-form-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.45}
711
+ .dem-table-wrap{overflow-x:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:9px}
712
+ .dem-table{width:100%;border-collapse:collapse;font-size:12px;min-width:560px}
713
+ .dem-table th{text-align:left;font-weight:500;color:var(--dsw-alias-label-secondary);font-size:11px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;background:var(--dsw-alias-bg-layer-2)}
714
+ .dem-table td{padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top;overflow-wrap:anywhere}
715
+ .dem-table tr:last-child td{border-bottom:0}
716
+ .dem-table .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
717
+ .dem-table-empty{padding:14px 10px;color:var(--dsw-alias-label-secondary);font-size:12px;text-align:center}
718
+ .dem-detail-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
719
+ .dem-detail-stat{border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:9px 10px;background:var(--dsw-alias-bg-layer-2);min-width:0}
720
+ .dem-detail-stat b{display:block;font-size:18px;line-height:1.2;font-weight:650;font-variant-numeric:tabular-nums}
721
+ .dem-detail-stat span{display:block;color:var(--dsw-alias-label-secondary);font-size:11px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
722
+ .dem-inline-form{border:1px dashed var(--dsw-alias-border-l1);border-radius:10px;padding:12px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:12px}
723
+ .dem-wizard .dem-inline-form{margin-top:14px}
724
+ .dem-inline-form .dem-textarea-sm{min-height:64px}
725
+ .dem-form-foot{display:flex;justify-content:flex-end;gap:8px}
726
+ .dem-status{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
727
+ .dem-status::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-secondary)}
728
+ .dem-status.active{color:var(--dsw-alias-state-success-primary)}
729
+ .dem-status.active::before{background:var(--dsw-alias-state-success-primary)}
730
+ .dem-status.inactive{color:var(--dsw-alias-state-error-primary)}
731
+ .dem-status.inactive::before{background:var(--dsw-alias-state-error-primary)}
732
+ .dem-btn:focus-visible,.dem-tab:focus-visible,.dem-subtab:focus-visible,.dem-list-item:focus-visible,.dem-input:focus-visible,.dem-textarea:focus-visible,.dem-back:focus-visible,.dem-detail-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
733
+ @media (max-width:820px){.dem-root{padding:20px 16px 36px}.dem-two{grid-template-columns:1fr}.dem-record-layout{grid-template-columns:1fr}.dem-list-card{max-height:280px;min-height:0}.dem-detail-grid{grid-template-columns:1fr}.dem-info-grid{grid-template-columns:1fr}.dem-form-grid,.dem-form-grid-3{grid-template-columns:1fr}}
734
+ `;
735
+
736
+ function ensureStyles() {
737
+ if (typeof document === 'undefined') return;
738
+ try {
739
+ let tag = document.getElementById(STYLE_ID);
740
+ if (!tag) {
741
+ tag = document.createElement('style');
742
+ tag.id = STYLE_ID;
743
+ document.head.appendChild(tag);
744
+ }
745
+ tag.textContent = CSS;
746
+ } catch (error) {
747
+ // Styling is cosmetic; a failed injection must not break the panel.
748
+ }
749
+ }
750
+
751
+ function Button(props) {
752
+ const className = [
753
+ 'dem-btn',
754
+ props.variant === 'primary' ? 'dem-btn-primary' : '',
755
+ props.variant === 'danger' ? 'dem-btn-danger' : '',
756
+ props.className || '',
757
+ ].filter(Boolean).join(' ');
758
+ return h('button', {
759
+ type: 'button',
760
+ className,
761
+ onClick: props.onClick,
762
+ disabled: props.disabled,
763
+ title: props.title,
764
+ }, props.children);
765
+ }
766
+
767
+ function Field(props) {
768
+ return h('label', { className: 'dem-field' },
769
+ h('span', { className: 'dem-label' }, props.label),
770
+ h('input', {
771
+ className: 'dem-input',
772
+ type: props.type || 'text',
773
+ value: props.value,
774
+ placeholder: props.placeholder || '',
775
+ required: props.required || false,
776
+ disabled: props.disabled || false,
777
+ autoComplete: props.autoComplete || 'off',
778
+ inputMode: props.inputMode,
779
+ onChange: (event) => props.onChange(event.target.value),
780
+ }));
781
+ }
782
+
783
+ function Select(props) {
784
+ return h('label', { className: 'dem-field' },
785
+ h('span', { className: 'dem-label' }, props.label),
786
+ h('select', {
787
+ className: 'dem-input',
788
+ value: props.value,
789
+ onChange: (event) => props.onChange(event.target.value),
790
+ }, (props.options || []).map((option) =>
791
+ h('option', { key: option.value, value: option.value }, option.label))));
792
+ }
793
+
794
+ function TextArea(props) {
795
+ return h('label', { className: 'dem-field', style: props.wide ? { gridColumn: '1 / -1' } : undefined },
796
+ h('span', { className: 'dem-label' }, props.label),
797
+ h('textarea', {
798
+ className: 'dem-textarea dem-textarea-sm',
799
+ value: props.value,
800
+ placeholder: props.placeholder || '',
801
+ onChange: (event) => props.onChange(event.target.value),
802
+ rows: props.rows || 3,
803
+ }));
804
+ }
805
+
806
+ function ErrorBox(props) {
807
+ if (!props.error) return null;
808
+ return h('div', { className: 'dem-alert', role: 'alert' }, props.error.message || String(props.error));
809
+ }
810
+
811
+ function Toast(props) {
812
+ if (!props.message) return null;
813
+ return h('div', { className: 'dem-toast', role: 'status' }, props.message);
814
+ }
815
+
816
+ function DiagnosticsList(props) {
817
+ const items = props.items || [];
818
+ if (!items.length) {
819
+ return h('div', { className: 'dem-empty' },
820
+ h('strong', null, t('noDiagnostics')));
821
+ }
822
+ return h('div', null, items.map((item, index) =>
823
+ h('div', { key: index, className: 'dem-diag' },
824
+ h('span', { className: 'dem-dot' + (item.severity === 'error' ? ' err' : '') }),
825
+ h('div', { className: 'dem-diag-body' },
826
+ h('div', { className: 'dem-diag-code' }, item.code),
827
+ h('div', { className: 'dem-diag-msg' }, item.message)))));
828
+ }
829
+
830
+ function countFor(meta, kind) {
831
+ const key = kind === 'ruleProfile' ? 'ruleProfiles'
832
+ : kind === 'operationBinding' ? 'operationBindings'
833
+ : kind + 's';
834
+ return Number((meta.counts || {})[key] || 0);
835
+ }
836
+
837
+ function Overview(props) {
838
+ const [meta, setMeta] = useState(null);
839
+ const [report, setReport] = useState(null);
840
+ const [error, setError] = useState(null);
841
+ const [loading, setLoading] = useState(true);
842
+ const load = useCallback(async () => {
843
+ setLoading(true);
844
+ try {
845
+ setError(null);
846
+ setMeta(await call('meta'));
847
+ } catch (next) {
848
+ setError(next);
849
+ } finally {
850
+ setLoading(false);
851
+ }
852
+ }, []);
853
+ useEffect(() => { load(); }, [load]);
854
+ const validate = async () => {
855
+ try {
856
+ setError(null);
857
+ setReport(await call('validate'));
858
+ } catch (next) {
859
+ setError(next);
860
+ }
861
+ };
862
+ const toolbar = h('div', { className: 'dem-toolbar' },
863
+ h('div', { className: 'dem-toolbar-copy' },
864
+ h('h2', { className: 'dem-section-title' }, t('overview')),
865
+ h('p', { className: 'dem-hint' }, t('overviewHint'))),
866
+ h('div', { className: 'dem-actions' },
867
+ h(Button, { onClick: load, disabled: loading }, t('refresh')),
868
+ h(Button, { onClick: validate, variant: 'primary' }, t('validate'))));
869
+ if (!meta) {
870
+ return h('div', { className: 'dem-view' }, toolbar,
871
+ error ? h(ErrorBox, { error }) : h('div', { className: 'dem-loading' }, h('span', { className: 'dem-spinner' }), t('loading')));
872
+ }
873
+ const total = KINDS.reduce((sum, kind) => sum + countFor(meta, kind), 0);
874
+ const warnings = meta.warnings || [];
875
+ return h('div', { className: 'dem-view' },
876
+ toolbar,
877
+ error ? h(ErrorBox, { error }) : null,
878
+ h('div', { className: 'dem-stats' }, KINDS.map((kind) =>
879
+ h('div', { key: kind, className: 'dem-stat' },
880
+ h('div', { className: 'dem-stat-label' }, t(KIND_LABEL[kind])),
881
+ h('div', { className: 'dem-stat-value' }, String(countFor(meta, kind)))))),
882
+ h('div', { className: 'dem-two' },
883
+ h('div', { className: 'dem-card' },
884
+ h('div', { className: 'dem-section-head' },
885
+ h('h3', { className: 'dem-section-title' }, t('runtime')),
886
+ h('span', { className: 'dem-pill' }, 'v' + meta.version)),
887
+ h('dl', { className: 'dem-kv' },
888
+ h('dt', null, t('dataDir')),
889
+ h('dd', { className: 'dem-code' }, meta.dataDir),
890
+ h('dt', null, t('totalRecords')),
891
+ h('dd', null, String(total)))),
892
+ h('div', { className: 'dem-card' },
893
+ h('div', { className: 'dem-section-head' },
894
+ h('h3', { className: 'dem-section-title' }, t('warnings')),
895
+ h('span', { className: 'dem-pill' + (warnings.length ? ' warn' : ' ok') }, String(warnings.length))),
896
+ warnings.length
897
+ ? h(DiagnosticsList, { items: warnings })
898
+ : h('div', { className: 'dem-empty' },
899
+ h('strong', null, t('noWarnings')),
900
+ h('span', null, t('validationPassed'))))),
901
+ total === 0 ? h('div', { className: 'dem-card' },
902
+ h('div', { className: 'dem-section-head' },
903
+ h('h3', { className: 'dem-section-title' }, t('gettingStarted')),
904
+ h('span', { className: 'dem-pill' }, t('emptyRegistry'))),
905
+ h('p', { className: 'dem-hint' }, t('gettingStartedHint')),
906
+ h('div', { className: 'dem-flow' },
907
+ KINDS.filter((kind) => kind !== 'ruleProfile').map((kind, index) =>
908
+ h(React.Fragment, { key: kind },
909
+ index > 0 ? h('span', { className: 'dem-flow-arrow' }, '→') : null,
910
+ h('span', { className: 'dem-flow-step' },
911
+ h('span', { className: 'dem-flow-index' }, String(index + 1)),
912
+ t(KIND_LABEL[kind]))))),
913
+ h('div', { className: 'dem-flow-actions' },
914
+ h(Button, { onClick: props.onCreateEnvironment, variant: 'primary' }, t('newEnvironment')))) : null,
915
+ report ? h('div', { className: 'dem-card' },
916
+ h('div', { className: 'dem-section-head' },
917
+ h('h3', { className: 'dem-section-title' }, t('validation')),
918
+ h('span', { className: 'dem-pill ' + (report.ok ? 'ok' : 'err') },
919
+ report.ok ? t('validationPassed') : t('validationFailed'))),
920
+ h(DiagnosticsList, { items: report.diagnostics })) : null);
921
+ }
922
+
923
+ const TEMPLATES = {
924
+ environment: 'id: new-env\nschemaVersion: 1\nname: New Environment\naliases: []\ncategory: test\nroles:\n - id: management\n name: Management Plane\n - id: business\n name: Business Plane\n - id: database\n name: Database\n - id: extension\n name: Expansion / other\nhosts: []\nclusters: []\nendpoints: []\n',
925
+ service: 'id: new-service\nschemaVersion: 1\nname: New Service\naliases: []\ncategory: backend\ngit:\n url: https://example.invalid/repo.git\n defaultBranch: main\ndeployModes: [kubernetes]\nruleProfileRefs: []\n',
926
+ deployment: 'id: new-deployment\nschemaVersion: 1\nenvironmentId: new-env\nserviceId: new-service\nruntimeMode: kubernetes\nlocation:\n kind: kubernetes\n clusterRef: k8s-01\n namespace: default\n workload:\n kind: Deployment\n name: new-service\n',
927
+ ruleProfile: 'id: new-rule\nschemaVersion: 1\ndescription: New rule profile\nvariables: {}\npaths: {}\n',
928
+ operationBinding: 'id: new-binding\nschemaVersion: 1\noperation: redeploy\nmatch:\n environmentId: new-env\n serviceId: new-service\nskill:\n name: example-skill\nparameters: {}\npolicy:\n requiresApproval: true\n allowProduction: false\nenabled: true\n',
929
+ };
930
+
931
+ function EnvironmentDetail(props) {
932
+ const [tab, setTab] = useState('overview');
933
+ const [editing, setEditing] = useState(null);
934
+ const [draft, setDraft] = useState({});
935
+ const [saving, setSaving] = useState(false);
936
+ const [formError, setFormError] = useState(null);
937
+ const [nodeQuery, setNodeQuery] = useState('');
938
+ const [batchText, setBatchText] = useState('');
939
+ const [expandedHost, setExpandedHost] = useState(null);
940
+ const record = props.record || {};
941
+ const hosts = record.hosts || [];
942
+ const accounts = hosts.flatMap((host) => (host.accounts || []).map((account) => ({ host, account })));
943
+ const logins = [...(record.loginRecords || [])].sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
944
+ const latest = logins[0] || null;
945
+ const lastAccountLogin = accounts.reduce((latestAt, entry) => {
946
+ const value = entry.account.lastLoginAt;
947
+ return value && (!latestAt || Date.parse(value) > Date.parse(latestAt)) ? value : latestAt;
948
+ }, '');
949
+ const failed = logins.filter((item) => item.result === 'failure').length;
950
+ const endpoints = record.endpoints || [];
951
+ const clusters = record.clusters || [];
952
+ const declaredRoles = record.roles || [];
953
+ const roleCatalog = [...declaredRoles];
954
+ for (const role of DEFAULT_ROLES) {
955
+ if (!roleCatalog.some((item) => item.id === role.id)) roleCatalog.push(role);
956
+ }
957
+ const roleById = new Map(roleCatalog.map((role) => [role.id, role]));
958
+ const roleName = (id) => {
959
+ const role = roleById.get(id);
960
+ if (ROLE_KEYS[id]) return t(ROLE_KEYS[id]);
961
+ return (role && role.name) || id;
962
+ };
963
+ const countByRole = (roleId) => hosts.filter((host) => (host.roles || []).includes(roleId)).length;
964
+ const hostById = new Map(hosts.map((host) => [host.id, host]));
965
+ const clusterById = new Map(clusters.map((cluster) => [cluster.id, cluster]));
966
+ const endpointTarget = (endpoint) => {
967
+ if (endpoint.hostRef) {
968
+ const host = hostById.get(endpoint.hostRef);
969
+ return host ? host.name + ' (' + endpoint.hostRef + ')' : endpoint.hostRef;
970
+ }
971
+ return endpoint.clusterRef || endpoint.url || '—';
972
+ };
973
+
974
+ const field = (key) => (value) => setDraft((current) => ({ ...current, [key]: value }));
975
+ const toIso = (value) => {
976
+ if (!value) return undefined;
977
+ const parsed = new Date(value);
978
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
979
+ };
980
+ const localNow = () => {
981
+ const now = new Date();
982
+ return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
983
+ };
984
+ const isIpv4 = (value) => /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(String(value || '').trim());
985
+ const rolesForSave = (selectedIds) => {
986
+ const nextRoles = [...declaredRoles];
987
+ for (const id of selectedIds) {
988
+ if (!nextRoles.some((role) => role.id === id)) {
989
+ const standard = DEFAULT_ROLES.find((role) => role.id === id);
990
+ if (standard) nextRoles.push({ ...standard });
991
+ }
992
+ }
993
+ return nextRoles;
994
+ };
995
+ const uniqueAccountId = (hostId, username) => {
996
+ const base = stableSlug(hostId + '-' + username, 'account');
997
+ let candidate = base;
998
+ let suffix = 2;
999
+ while (accounts.some((entry) => entry.account.id === candidate)) {
1000
+ candidate = base + '-' + suffix;
1001
+ suffix += 1;
1002
+ }
1003
+ return candidate;
1004
+ };
1005
+ const saveNext = async (next, cleanupRef) => {
1006
+ setSaving(true);
1007
+ setFormError(null);
1008
+ try {
1009
+ const normalized = { ...next };
1010
+ if (!Array.isArray(normalized.roles) || normalized.roles.length === 0) {
1011
+ normalized.roles = DEFAULT_ROLES.map((role) => ({ ...role }));
1012
+ }
1013
+ await props.onSaveRecord(normalized);
1014
+ setEditing(null);
1015
+ setNodeQuery('');
1016
+ return true;
1017
+ } catch (nextError) {
1018
+ setFormError(nextError);
1019
+ if (cleanupRef && props.onCredentialUnset) {
1020
+ await props.onCredentialUnset(cleanupRef).catch(() => {});
1021
+ }
1022
+ return false;
1023
+ } finally {
1024
+ setSaving(false);
1025
+ }
1026
+ };
1027
+
1028
+ const openBasic = () => {
1029
+ setEditing('basic');
1030
+ setDraft({
1031
+ name: record.name || '',
1032
+ aliases: (record.aliases || []).join(', '),
1033
+ category: record.category || 'test',
1034
+ description: record.description || '',
1035
+ });
1036
+ };
1037
+ const submitBasic = () => {
1038
+ if (!String(draft.name || '').trim()) return;
1039
+ saveNext({
1040
+ ...record,
1041
+ name: String(draft.name).trim(),
1042
+ aliases: String(draft.aliases || '').split(',').map((item) => item.trim()).filter(Boolean),
1043
+ category: draft.category || 'custom',
1044
+ description: String(draft.description || '').trim() || undefined,
1045
+ });
1046
+ };
1047
+
1048
+ const openHost = (roleId) => {
1049
+ setFormError(null);
1050
+ setEditing('host');
1051
+ setDraft({
1052
+ id: '',
1053
+ name: '',
1054
+ kind: 'vm',
1055
+ access: defaultHostAccess('vm'),
1056
+ clusterRef: '',
1057
+ ip: '',
1058
+ sshPort: '22',
1059
+ platform: '',
1060
+ adminUser: '',
1061
+ credentialRef: '',
1062
+ roles: roleId ? [roleId] : [],
1063
+ username: '',
1064
+ password: '',
1065
+ });
1066
+ };
1067
+ const openEditHost = (host) => {
1068
+ setFormError(null);
1069
+ setEditing('host-edit:' + host.id);
1070
+ setDraft({
1071
+ id: host.id,
1072
+ name: host.name || '',
1073
+ kind: hostKindOf(host),
1074
+ access: hostAccessOf(host).length ? hostAccessOf(host) : defaultHostAccess(hostKindOf(host)),
1075
+ clusterRef: host.clusterRef || '',
1076
+ ip: host.ip || '',
1077
+ sshPort: host.sshPort ? String(host.sshPort) : '22',
1078
+ platform: host.platform || host.os || '',
1079
+ adminUser: host.adminUser || '',
1080
+ credentialRef: host.credentialRef || '',
1081
+ roles: [...(host.roles || [])],
1082
+ username: '',
1083
+ password: '',
1084
+ });
1085
+ };
1086
+ const changeHostKind = (kind) => {
1087
+ setDraft((current) => ({
1088
+ ...current,
1089
+ kind,
1090
+ access: defaultHostAccess(kind),
1091
+ clusterRef: K8S_NODE_KINDS.has(kind) || kind === 'pod-host' ? current.clusterRef : '',
1092
+ }));
1093
+ };
1094
+ const toggleHostAccess = (method) => {
1095
+ setDraft((current) => {
1096
+ const access = Array.isArray(current.access) ? current.access : [];
1097
+ return {
1098
+ ...current,
1099
+ access: access.includes(method)
1100
+ ? access.filter((item) => item !== method)
1101
+ : [...access, method],
1102
+ };
1103
+ });
1104
+ };
1105
+ const submitHost = async (existingHost) => {
1106
+ const name = String(draft.name || '').trim();
1107
+ const id = existingHost
1108
+ ? existingHost.id
1109
+ : stableSlug(String(draft.id || '').trim() || name, 'node');
1110
+ const selectedRoles = Array.isArray(draft.roles) ? draft.roles : [];
1111
+ if (!name || selectedRoles.length === 0) {
1112
+ setFormError({ message: t('nodeFieldsRequired') });
1113
+ return;
1114
+ }
1115
+ if (!existingHost && hosts.some((host) => host.id === id)) {
1116
+ setFormError({ message: t('duplicateNodeId') + ' (' + id + ')' });
1117
+ return;
1118
+ }
1119
+ const ip = String(draft.ip || '').trim();
1120
+ if (ip && !isIpv4(ip)) {
1121
+ setFormError({ message: t('invalidIp') });
1122
+ return;
1123
+ }
1124
+ if (ip && hosts.some((host) => host.ip === ip && (!existingHost || host.id !== existingHost.id))) {
1125
+ setFormError({ message: t('duplicateIp') });
1126
+ return;
1127
+ }
1128
+ const username = String(draft.username || '').trim();
1129
+ const password = String(draft.password || '');
1130
+ if ((username && !password) || (!username && password)) {
1131
+ setFormError({ message: t('nodeAccountIncomplete') });
1132
+ return;
1133
+ }
1134
+ const kind = HOST_KIND_KEYS[draft.kind] ? draft.kind : 'vm';
1135
+ const access = Array.isArray(draft.access) ? draft.access : [];
1136
+ const clusterRef = String(draft.clusterRef || '').trim();
1137
+ if (K8S_NODE_KINDS.has(kind) && !clusterRef) {
1138
+ setFormError({ message: t('k8sClusterRequired') });
1139
+ return;
1140
+ }
1141
+ if (clusterRef && !clusters.some((cluster) => cluster.id === clusterRef)) {
1142
+ setFormError({ message: t('k8sClusterUnknown') });
1143
+ return;
1144
+ }
1145
+ const port = draft.sshPort ? Number(draft.sshPort) : undefined;
1146
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
1147
+ setFormError({ message: t('invalidPort') });
1148
+ return;
1149
+ }
1150
+ const host = {
1151
+ ...(existingHost || {}),
1152
+ id,
1153
+ name,
1154
+ kind,
1155
+ access,
1156
+ clusterRef: clusterRef || undefined,
1157
+ ip: ip || undefined,
1158
+ sshPort: Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined,
1159
+ platform: String(draft.platform || '').trim() || undefined,
1160
+ adminUser: String(draft.adminUser || '').trim() || undefined,
1161
+ credentialRef: String(draft.credentialRef || '').trim() || undefined,
1162
+ roles: selectedRoles,
1163
+ isActive: existingHost ? existingHost.isActive : true,
1164
+ accounts: existingHost ? [...(existingHost.accounts || [])] : [],
1165
+ };
1166
+ let cleanupRef = null;
1167
+ if (username && password) {
1168
+ const accountId = uniqueAccountId(id, username);
1169
+ const credentialRef = 'env-manager/' + record.id + '/' + accountId;
1170
+ try {
1171
+ await props.onCredential(credentialRef, password);
1172
+ } catch (next) {
1173
+ setFormError(next);
1174
+ return;
1175
+ }
1176
+ cleanupRef = credentialRef;
1177
+ if (!host.credentialRef) host.credentialRef = credentialRef;
1178
+ host.accounts = host.accounts.concat([{
1179
+ id: accountId,
1180
+ username,
1181
+ purpose: existingHost ? 'added from node edit' : 'ssh login',
1182
+ protocol: 'ssh',
1183
+ port: host.sshPort || 22,
1184
+ authType: 'password',
1185
+ credentialRef,
1186
+ }]);
1187
+ }
1188
+ const nextHosts = existingHost
1189
+ ? hosts.map((item) => item.id === existingHost.id ? host : item)
1190
+ : [...hosts, host];
1191
+ const ok = await saveNext({
1192
+ ...record,
1193
+ roles: rolesForSave(selectedRoles),
1194
+ hosts: nextHosts,
1195
+ }, cleanupRef);
1196
+ if (ok && props.onMessage) props.onMessage(t(existingHost ? 'nodeUpdated' : 'nodeAdded'));
1197
+ };
1198
+
1199
+ const openCluster = (existingCluster) => {
1200
+ setFormError(null);
1201
+ setEditing(existingCluster ? 'cluster-edit:' + existingCluster.id : 'cluster');
1202
+ setDraft({
1203
+ id: existingCluster ? existingCluster.id : '',
1204
+ name: existingCluster ? existingCluster.name : '',
1205
+ kind: existingCluster ? existingCluster.kind : 'kubernetes',
1206
+ topology: existingCluster ? (existingCluster.topology || '') : 'all-in-one',
1207
+ apiEndpoint: existingCluster ? existingCluster.apiEndpoint || '' : '',
1208
+ namespaces: existingCluster ? (existingCluster.namespaces || []).join(', ') : '',
1209
+ credentialRef: existingCluster ? existingCluster.credentialRef || '' : '',
1210
+ note: existingCluster ? existingCluster.note || '' : '',
1211
+ });
1212
+ };
1213
+ const submitCluster = async (existingCluster) => {
1214
+ const name = String(draft.name || '').trim();
1215
+ const id = existingCluster
1216
+ ? existingCluster.id
1217
+ : stableSlug(String(draft.id || '').trim() || name, 'cluster');
1218
+ if (!name || !id) {
1219
+ setFormError({ message: t('clusterRequired') });
1220
+ return;
1221
+ }
1222
+ if (!existingCluster && clusters.some((cluster) => cluster.id === id)) {
1223
+ setFormError({ message: t('clusterIdExists') });
1224
+ return;
1225
+ }
1226
+ const cluster = {
1227
+ ...(existingCluster || {}),
1228
+ id,
1229
+ name,
1230
+ kind: draft.kind || 'kubernetes',
1231
+ topology: draft.kind === 'docker' ? undefined : (draft.topology || undefined),
1232
+ apiEndpoint: String(draft.apiEndpoint || '').trim() || undefined,
1233
+ namespaces: String(draft.namespaces || '').split(',').map((item) => item.trim()).filter(Boolean),
1234
+ credentialRef: String(draft.credentialRef || '').trim() || undefined,
1235
+ note: String(draft.note || '').trim() || undefined,
1236
+ };
1237
+ const next = existingCluster
1238
+ ? clusters.map((item) => item.id === existingCluster.id ? cluster : item)
1239
+ : [...clusters, cluster];
1240
+ const ok = await saveNext({ ...record, clusters: next });
1241
+ if (ok && props.onMessage) props.onMessage(t(existingCluster ? 'clusterUpdated' : 'clusterAdded'));
1242
+ };
1243
+
1244
+ const openAccount = (host, existingAccount, presetProtocol) => {
1245
+ const protocol = existingAccount
1246
+ ? (existingAccount.protocol || 'ssh')
1247
+ : (presetProtocol || 'ssh');
1248
+ setFormError(null);
1249
+ setExpandedHost(host.id);
1250
+ setEditing(existingAccount
1251
+ ? 'account-edit:' + host.id + ':' + existingAccount.id
1252
+ : 'account:' + host.id);
1253
+ setDraft({
1254
+ id: existingAccount ? existingAccount.id : '',
1255
+ username: existingAccount ? existingAccount.username : '',
1256
+ purpose: existingAccount ? existingAccount.purpose || '' : '',
1257
+ protocol,
1258
+ authType: existingAccount ? (existingAccount.authType || 'other') : (protocol === 'web' ? 'password' : 'private-key'),
1259
+ loginUrl: existingAccount ? existingAccount.loginUrl || '' : '',
1260
+ credentialRef: existingAccount ? existingAccount.credentialRef || '' : '',
1261
+ lastLoginAt: existingAccount ? existingAccount.lastLoginAt || '' : '',
1262
+ lastLoginFrom: existingAccount ? existingAccount.lastLoginFrom || '' : '',
1263
+ password: '',
1264
+ });
1265
+ };
1266
+ const submitAccount = async (host, existingAccount) => {
1267
+ const username = String(draft.username || '').trim();
1268
+ if (!username) {
1269
+ setFormError({ message: t('invalidForm') });
1270
+ return;
1271
+ }
1272
+ const id = existingAccount
1273
+ ? existingAccount.id
1274
+ : (String(draft.id || '').trim() || uniqueAccountId(host.id, username));
1275
+ if (!existingAccount && accounts.some((entry) => entry.account.id === id)) {
1276
+ setFormError({ message: t('accountId') + ': ' + id });
1277
+ return;
1278
+ }
1279
+ const loginUrl = String(draft.loginUrl || '').trim();
1280
+ if (loginUrl && !/^https?:\/\//i.test(loginUrl)) {
1281
+ setFormError({ message: t('webLoginUrlInvalid') });
1282
+ return;
1283
+ }
1284
+ const protocol = draft.protocol || 'ssh';
1285
+ const password = String(draft.password || '');
1286
+ let credentialRef = String(draft.credentialRef || '').trim() || undefined;
1287
+ let cleanupRef = null;
1288
+ if (password) {
1289
+ const existingRef = existingAccount && existingAccount.credentialRef;
1290
+ const ref = existingRef && existingRef.startsWith('env-manager/')
1291
+ ? existingRef
1292
+ : 'env-manager/' + record.id + '/' + id;
1293
+ try {
1294
+ await props.onCredential(ref, password);
1295
+ } catch (next) {
1296
+ setFormError(next);
1297
+ return;
1298
+ }
1299
+ if (ref !== existingRef) cleanupRef = ref;
1300
+ credentialRef = ref;
1301
+ }
1302
+ const account = {
1303
+ ...(existingAccount || {}),
1304
+ id,
1305
+ username,
1306
+ purpose: String(draft.purpose || '').trim() || undefined,
1307
+ protocol,
1308
+ loginUrl: loginUrl || undefined,
1309
+ port: protocol === 'ssh' ? (host.sshPort || 22) : undefined,
1310
+ authType: password ? 'password' : (draft.authType || 'other'),
1311
+ credentialRef,
1312
+ lastLoginAt: toIso(draft.lastLoginAt),
1313
+ lastLoginFrom: String(draft.lastLoginFrom || '').trim() || undefined,
1314
+ };
1315
+ const nextAccounts = existingAccount
1316
+ ? (host.accounts || []).map((item) => item.id === existingAccount.id ? account : item)
1317
+ : [...(host.accounts || []), account];
1318
+ const ok = await saveNext({
1319
+ ...record,
1320
+ hosts: hosts.map((item) => item.id === host.id
1321
+ ? { ...item, accounts: nextAccounts }
1322
+ : item),
1323
+ }, cleanupRef);
1324
+ if (ok && props.onMessage) props.onMessage(t(existingAccount ? 'accountUpdated' : 'accountAdded'));
1325
+ };
1326
+
1327
+ const submitBatch = async () => {
1328
+ const lines = String(batchText || '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
1329
+ if (lines.length === 0) {
1330
+ setFormError({ message: t('batchEmpty') });
1331
+ return;
1332
+ }
1333
+ const nextHosts = [];
1334
+ const usedIps = new Set(hosts.map((host) => host.ip).filter(Boolean));
1335
+ const usedIds = new Set(hosts.map((host) => host.id));
1336
+ const usedRoles = new Set();
1337
+ const problems = [];
1338
+ for (const [index, line] of lines.entries()) {
1339
+ const parts = line.split(',').map((part) => part.trim());
1340
+ if (parts.length < 3 || parts.length > 5) {
1341
+ problems.push((index + 1) + ': ' + line);
1342
+ continue;
1343
+ }
1344
+ const [ip, rawName, rawRoles, rawPort, rawUser] = parts;
1345
+ const roleIds = rawRoles.split(/[|/]/).map((role) => role.trim()).filter(Boolean);
1346
+ const unknown = roleIds.filter((role) => !roleById.has(role));
1347
+ if (!ip || !isIpv4(ip) || roleIds.length === 0 || unknown.length) {
1348
+ problems.push((index + 1) + ': ' + (ip && !isIpv4(ip) ? t('invalidIp') : line));
1349
+ continue;
1350
+ }
1351
+ if (usedIps.has(ip)) {
1352
+ problems.push((index + 1) + ': ' + t('duplicateIp') + ' ' + ip);
1353
+ continue;
1354
+ }
1355
+ const port = rawPort ? Number(rawPort) : 22;
1356
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1357
+ problems.push((index + 1) + ': ' + line);
1358
+ continue;
1359
+ }
1360
+ const name = rawName || 'node-' + ip;
1361
+ const baseId = stableSlug(name, 'node');
1362
+ let id = baseId;
1363
+ let suffix = 2;
1364
+ while (usedIds.has(id)) {
1365
+ id = baseId + '-' + suffix;
1366
+ suffix += 1;
1367
+ }
1368
+ usedIds.add(id);
1369
+ usedIps.add(ip);
1370
+ for (const role of roleIds) usedRoles.add(role);
1371
+ const account = rawUser ? {
1372
+ id: id + '-' + stableSlug(rawUser, 'user'),
1373
+ username: rawUser,
1374
+ purpose: 'ssh login',
1375
+ protocol: 'ssh',
1376
+ port,
1377
+ authType: 'password',
1378
+ } : undefined;
1379
+ nextHosts.push({
1380
+ id,
1381
+ name,
1382
+ ip,
1383
+ sshPort: port,
1384
+ roles: roleIds,
1385
+ kind: 'vm',
1386
+ access: ['ssh'],
1387
+ isActive: true,
1388
+ accounts: account ? [account] : [],
1389
+ });
1390
+ }
1391
+ if (problems.length) {
1392
+ setFormError({ message: t('batchInvalid') + '\n' + problems.slice(0, 3).join('\n') });
1393
+ return;
1394
+ }
1395
+ const ok = await saveNext({
1396
+ ...record,
1397
+ roles: rolesForSave([...usedRoles]),
1398
+ hosts: [...hosts, ...nextHosts],
1399
+ });
1400
+ if (ok) {
1401
+ setBatchText('');
1402
+ if (props.onMessage) props.onMessage(t('nodesImported') + ': ' + nextHosts.length);
1403
+ }
1404
+ };
1405
+
1406
+ const openLogin = () => {
1407
+ setEditing('login');
1408
+ setDraft({
1409
+ at: localNow(),
1410
+ username: '',
1411
+ hostRef: '',
1412
+ accountId: '',
1413
+ protocol: 'ssh',
1414
+ sourceIp: '',
1415
+ result: 'success',
1416
+ note: '',
1417
+ });
1418
+ };
1419
+ const submitLogin = () => {
1420
+ const username = String(draft.username || '').trim();
1421
+ const at = toIso(draft.at);
1422
+ if (!username || !at) return;
1423
+ const entry = {
1424
+ id: 'login-' + Date.now(),
1425
+ at,
1426
+ username,
1427
+ hostRef: draft.hostRef || undefined,
1428
+ accountId: draft.accountId || undefined,
1429
+ protocol: draft.protocol || 'ssh',
1430
+ sourceIp: String(draft.sourceIp || '').trim() || undefined,
1431
+ result: draft.result === 'failure' ? 'failure' : 'success',
1432
+ note: String(draft.note || '').trim() || undefined,
1433
+ };
1434
+ const hosts = (record.hosts || []).map((host) => ({
1435
+ ...host,
1436
+ accounts: (host.accounts || []).map((account) => {
1437
+ if (entry.result !== 'success') return account;
1438
+ const matches = entry.accountId
1439
+ ? account.id === entry.accountId && (!entry.hostRef || entry.hostRef === host.id)
1440
+ : entry.hostRef === host.id && account.username === entry.username;
1441
+ if (!matches) return account;
1442
+ const previous = account.lastLoginAt ? Date.parse(account.lastLoginAt) : 0;
1443
+ if (previous > Date.parse(entry.at)) return account;
1444
+ return {
1445
+ ...account,
1446
+ lastLoginAt: entry.at,
1447
+ lastLoginFrom: entry.sourceIp || account.lastLoginFrom,
1448
+ };
1449
+ }),
1450
+ }));
1451
+ saveNext({
1452
+ ...record,
1453
+ hosts,
1454
+ loginRecords: [entry, ...(record.loginRecords || [])],
1455
+ });
1456
+ };
1457
+
1458
+ const info = (label, value, options = {}) => h('div', {
1459
+ className: 'dem-info-item' + (options.wide ? ' dem-info-wide' : ''),
1460
+ },
1461
+ h('div', { className: 'dem-info-label' }, label),
1462
+ h('div', { className: 'dem-info-value' + (options.mono ? ' mono' : '') },
1463
+ value === undefined || value === null || value === '' ? '—' : value));
1464
+
1465
+ const planeSummary = h('div', { className: 'dem-plane-grid' }, roleCatalog.map((role) =>
1466
+ h('button', {
1467
+ key: role.id,
1468
+ type: 'button',
1469
+ className: 'dem-plane-card',
1470
+ 'data-active': tab === 'role:' + role.id ? 'true' : 'false',
1471
+ onClick: () => setTab('role:' + role.id),
1472
+ },
1473
+ h('span', { className: 'dem-plane-name' }, roleName(role.id)),
1474
+ h('span', { className: 'dem-plane-count' }, String(countByRole(role.id))))));
1475
+
1476
+ const basicForm = h('div', { className: 'dem-inline-form' },
1477
+ h('div', { className: 'dem-form-grid dem-form-grid-3' },
1478
+ h(Field, { label: t('name'), value: draft.name || '', onChange: field('name') }),
1479
+ h(Field, { label: t('aliases'), value: draft.aliases || '', onChange: field('aliases'), placeholder: 'ta, test-a' }),
1480
+ h(Select, {
1481
+ label: t('category'),
1482
+ value: draft.category || 'test',
1483
+ onChange: field('category'),
1484
+ options: ['dev', 'test', 'staging', 'production', 'custom'].map((value) => ({ value, label: categoryLabel(value) })),
1485
+ }),
1486
+ h(TextArea, { label: t('description'), value: draft.description || '', onChange: field('description'), rows: 2, wide: true })),
1487
+ h('div', { className: 'dem-form-foot' },
1488
+ h(Button, { onClick: () => setEditing(null) }, t('cancel')),
1489
+ h(Button, { onClick: submitBasic, variant: 'primary', disabled: saving }, t('save'))));
1490
+
1491
+ const overview = h('div', { className: 'dem-detail-grid' },
1492
+ h('div', { className: 'dem-detail-main' },
1493
+ h('div', { className: 'dem-card' },
1494
+ h('div', { className: 'dem-section-head' },
1495
+ h('h3', { className: 'dem-section-title' }, t('basicInfo')),
1496
+ h('div', { className: 'dem-actions' },
1497
+ h('span', { className: 'dem-pill' }, categoryLabel(record.category)),
1498
+ h(Button, { onClick: openBasic, disabled: editing === 'basic' }, t('editBasic')))),
1499
+ editing === 'basic' ? basicForm : h('div', { className: 'dem-info-grid' },
1500
+ info('ID', record.id, { mono: true }),
1501
+ info(t('category'), categoryLabel(record.category)),
1502
+ info(t('name'), record.name),
1503
+ info(t('aliases'), (record.aliases || []).join(', ') || '—'),
1504
+ info(t('description'), record.description, { wide: true }),
1505
+ info(t('createdAt'), formatTime(record.createdAt)),
1506
+ info(t('updatedAt'), formatTime(record.updatedAt)),
1507
+ info(t('hostCount'), String(hosts.length)),
1508
+ info(t('accountCount'), String(accounts.length)))),
1509
+ h('div', { className: 'dem-card' },
1510
+ h('div', { className: 'dem-section-head' },
1511
+ h('h3', { className: 'dem-section-title' }, t('planeSummary')),
1512
+ h(Button, { onClick: () => openHost(), variant: 'primary' }, t('addNode'))),
1513
+ planeSummary),
1514
+ h('div', { className: 'dem-card' },
1515
+ h('div', { className: 'dem-section-head' },
1516
+ h('h3', { className: 'dem-section-title' }, t('allNodes')),
1517
+ h('span', { className: 'dem-pill' }, String(hosts.length))),
1518
+ hosts.length
1519
+ ? h('div', { className: 'dem-chips' }, hosts.map((host) =>
1520
+ h('span', { key: host.id, className: 'dem-chip' }, host.name + ' · ' + (host.ip || host.id))))
1521
+ : h('div', { className: 'dem-empty' },
1522
+ h('strong', null, t('noNodes')),
1523
+ h('span', null, t('noNodesHint')),
1524
+ h(Button, { onClick: () => openHost(), variant: 'primary' }, t('addNode'))))),
1525
+ h('div', { className: 'dem-detail-rail' },
1526
+ h('div', { className: 'dem-card' },
1527
+ h('div', { className: 'dem-section-head' },
1528
+ h('h3', { className: 'dem-section-title' }, t('accessSummary'))),
1529
+ h('div', { className: 'dem-detail-stats' },
1530
+ h('div', { className: 'dem-detail-stat' },
1531
+ h('b', null, String(hosts.length)), h('span', null, t('hostCount'))),
1532
+ h('div', { className: 'dem-detail-stat' },
1533
+ h('b', null, String(accounts.length)), h('span', null, t('accountCount'))),
1534
+ h('div', { className: 'dem-detail-stat' },
1535
+ h('b', null, String(endpoints.length)), h('span', null, t('endpointCount'))),
1536
+ h('div', { className: 'dem-detail-stat' },
1537
+ h('b', null, String(clusters.length)), h('span', null, t('clusterCount'))),
1538
+ h('div', { className: 'dem-detail-stat' },
1539
+ h('b', null, String(hosts.filter((host) => !['vm', 'physical'].includes(hostKindOf(host))).length)),
1540
+ h('span', null, t('k8sNodeCount'))),
1541
+ h('div', { className: 'dem-detail-stat' },
1542
+ h('b', null, String(failed)), h('span', null, t('failedLogins')))),
1543
+ h('div', { className: 'dem-info-item', style: { marginTop: '12px' } },
1544
+ h('div', { className: 'dem-info-label' }, t('latestLogin')),
1545
+ h('div', { className: 'dem-info-value' },
1546
+ latest ? formatTime(latest.at) : (lastAccountLogin ? formatTime(lastAccountLogin) : t('never'))))),
1547
+ h('div', { className: 'dem-card' },
1548
+ h('div', { className: 'dem-section-head' },
1549
+ h('h3', { className: 'dem-section-title' }, t('roles'))),
1550
+ h('div', { className: 'dem-chips' }, roleCatalog.map((role) =>
1551
+ h('span', { key: role.id, className: 'dem-chip', title: role.id },
1552
+ roleName(role.id) + ' · ' + countByRole(role.id)))))));
1553
+
1554
+ const hostForm = (existingHost) => h('div', { className: 'dem-inline-form' },
1555
+ h('div', { className: 'dem-form-grid dem-form-grid-3' },
1556
+ h(Field, {
1557
+ label: t('hostId'),
1558
+ value: draft.id || '',
1559
+ onChange: field('id'),
1560
+ placeholder: 'node-01',
1561
+ disabled: Boolean(existingHost),
1562
+ }),
1563
+ h(Field, { label: t('hostname'), value: draft.name || '', onChange: field('name'), placeholder: 'node-01' }),
1564
+ h(Select, {
1565
+ label: t('nodeKind'),
1566
+ value: draft.kind || 'vm',
1567
+ onChange: changeHostKind,
1568
+ options: HOST_KINDS.map((value) => ({ value, label: hostKindLabel(value) })),
1569
+ }),
1570
+ h(Select, {
1571
+ label: t('k8sCluster'),
1572
+ value: draft.clusterRef || '',
1573
+ onChange: field('clusterRef'),
1574
+ options: [{ value: '', label: '—' }].concat(clusters.map((cluster) => ({
1575
+ value: cluster.id,
1576
+ label: cluster.name || cluster.id,
1577
+ }))),
1578
+ }),
1579
+ h(Field, { label: t('ipAddress'), value: draft.ip || '', onChange: field('ip'), placeholder: '10.0.1.10' }),
1580
+ h(Field, { label: t('sshPort'), value: draft.sshPort || '', onChange: field('sshPort'), type: 'number', inputMode: 'numeric' }),
1581
+ h(Field, { label: t('platform'), value: draft.platform || '', onChange: field('platform'), placeholder: 'Linux / x86_64' }),
1582
+ h(Field, { label: t('adminUser'), value: draft.adminUser || '', onChange: field('adminUser'), placeholder: 'root' }),
1583
+ h(Field, { label: t('credentialRef'), value: draft.credentialRef || '', onChange: field('credentialRef'), placeholder: 'cred-host-root' })),
1584
+ h('div', { className: 'dem-field' },
1585
+ h('span', { className: 'dem-label' }, t('roles')),
1586
+ h('div', { className: 'dem-check-grid' }, roleCatalog.map((role) =>
1587
+ h('label', { key: role.id, className: 'dem-check' },
1588
+ h('input', {
1589
+ type: 'checkbox',
1590
+ checked: (draft.roles || []).includes(role.id),
1591
+ onChange: () => setDraft((current) => {
1592
+ const selected = current.roles || [];
1593
+ return {
1594
+ ...current,
1595
+ roles: selected.includes(role.id)
1596
+ ? selected.filter((id) => id !== role.id)
1597
+ : [...selected, role.id],
1598
+ };
1599
+ }),
1600
+ }),
1601
+ roleName(role.id))))),
1602
+ h('div', { className: 'dem-field' },
1603
+ h('span', { className: 'dem-label' }, t('nodeAccess')),
1604
+ h('div', { className: 'dem-check-grid' }, HOST_ACCESS_METHODS.map((method) =>
1605
+ h('label', { key: method, className: 'dem-check' },
1606
+ h('input', {
1607
+ type: 'checkbox',
1608
+ checked: (draft.access || []).includes(method),
1609
+ onChange: () => toggleHostAccess(method),
1610
+ }),
1611
+ hostAccessLabel(method))))),
1612
+ h('p', { className: 'dem-hint' }, t('nodeKindHint') + ' ' + t('nodeAccessHint')),
1613
+ h('div', { className: 'dem-form-grid dem-form-grid-3' },
1614
+ h(Field, { label: t('loginUser'), value: draft.username || '', onChange: field('username'), placeholder: 'root', autoComplete: 'username' }),
1615
+ h(Field, { label: t('password'), value: draft.password || '', onChange: field('password'), type: 'password', autoComplete: 'new-password' })),
1616
+ h('p', { className: 'dem-hint' }, existingHost ? t('nodeAccountEditHint') : t('nodeAccountHint')),
1617
+ formError ? h('div', { className: 'dem-form-error' }, formError.message || String(formError)) : null,
1618
+ h('div', { className: 'dem-form-foot' },
1619
+ h(Button, { onClick: () => { setEditing(null); setFormError(null); } }, t('cancel')),
1620
+ h(Button, { onClick: () => submitHost(existingHost), variant: 'primary', disabled: saving }, t('save'))));
1621
+
1622
+ const accountForm = (host, existingAccount) => h('div', { className: 'dem-inline-form' },
1623
+ h('p', { className: 'dem-hint' }, existingAccount ? t('editAccount') : t('addAccount')),
1624
+ h('div', { className: 'dem-form-grid' },
1625
+ h(Field, {
1626
+ label: t('accountId'),
1627
+ value: draft.id || '',
1628
+ onChange: field('id'),
1629
+ placeholder: 'root',
1630
+ disabled: Boolean(existingAccount),
1631
+ }),
1632
+ h(Field, { label: t('username'), value: draft.username || '', onChange: field('username'), placeholder: 'root', autoComplete: 'username' }),
1633
+ h(Field, {
1634
+ label: t('webLoginUrl'),
1635
+ value: draft.loginUrl || '',
1636
+ onChange: field('loginUrl'),
1637
+ placeholder: 'https://console.example.com/login',
1638
+ }),
1639
+ h(Field, { label: t('purpose'), value: draft.purpose || '', onChange: field('purpose'), placeholder: 'DBA / admin' }),
1640
+ h(Select, {
1641
+ label: t('protocol'), value: draft.protocol || 'ssh', onChange: (value) => setDraft((current) => ({
1642
+ ...current,
1643
+ protocol: value,
1644
+ authType: value === 'web' ? 'password' : current.authType,
1645
+ })),
1646
+ options: ['ssh', 'rdp', 'web', 'k8s-api', 'docker', 'custom'].map((value) => ({ value, label: value.toUpperCase() })),
1647
+ }),
1648
+ h(Select, {
1649
+ label: t('authType'), value: draft.authType || 'private-key', onChange: field('authType'),
1650
+ options: ['password', 'private-key', 'agent', 'token', 'other'].map((value) => ({ value, label: authLabel(value) })),
1651
+ }),
1652
+ h(Field, {
1653
+ label: t('password'),
1654
+ value: draft.password || '',
1655
+ onChange: field('password'),
1656
+ type: 'password',
1657
+ placeholder: t('optional'),
1658
+ autoComplete: 'new-password',
1659
+ }),
1660
+ h(Field, { label: t('credentialRef'), value: draft.credentialRef || '', onChange: field('credentialRef'), placeholder: 'cred-host-root' }),
1661
+ h(Field, { label: t('latestLogin'), value: draft.lastLoginAt || '', onChange: field('lastLoginAt'), type: 'datetime-local' }),
1662
+ h(Field, { label: t('source'), value: draft.lastLoginFrom || '', onChange: field('lastLoginFrom'), placeholder: '10.0.8.21' })),
1663
+ h('p', { className: 'dem-hint' }, t('webLoginHint') + ' ' + t('accountPasswordHint')),
1664
+ formError ? h('div', { className: 'dem-form-error' }, formError.message || String(formError)) : null,
1665
+ h('div', { className: 'dem-form-foot' },
1666
+ h(Button, { onClick: () => { setEditing(null); setFormError(null); } }, t('cancel')),
1667
+ h(Button, { onClick: () => submitAccount(host, existingAccount), variant: 'primary', disabled: saving }, t('save'))));
1668
+
1669
+ const editingAccountFor = (host) => {
1670
+ if (!editing) return null;
1671
+ const prefix = 'account-edit:' + host.id + ':';
1672
+ if (!editing.startsWith(prefix)) return null;
1673
+ const accountId = editing.slice(prefix.length);
1674
+ return (host.accounts || []).find((account) => account.id === accountId) || null;
1675
+ };
1676
+
1677
+ const nodesView = (roleId) => {
1678
+ const activeRole = roleId ? roleById.get(roleId) : null;
1679
+ const query = nodeQuery.trim().toLowerCase();
1680
+ const visibleHosts = hosts.filter((host) => {
1681
+ if (roleId && !(host.roles || []).includes(roleId)) return false;
1682
+ if (!query) return true;
1683
+ return [
1684
+ host.id,
1685
+ host.name,
1686
+ host.ip,
1687
+ host.platform,
1688
+ host.adminUser,
1689
+ hostKindOf(host),
1690
+ hostKindLabel(hostKindOf(host)),
1691
+ host.clusterRef,
1692
+ host.clusterRef ? ((clusterById.get(host.clusterRef) || {}).name || '') : '',
1693
+ ...hostAccessOf(host),
1694
+ ...hostAccessOf(host).map((method) => hostAccessLabel(method)),
1695
+ ...(host.accounts || []).flatMap((account) => [
1696
+ account.username,
1697
+ account.purpose,
1698
+ account.loginUrl,
1699
+ ]),
1700
+ ...(host.roles || []),
1701
+ ].filter(Boolean).join(' ').toLowerCase().includes(query);
1702
+ });
1703
+ const editingHost = editing && editing.startsWith('host-edit:')
1704
+ ? hosts.find((host) => host.id === editing.slice('host-edit:'.length))
1705
+ : null;
1706
+ return h('div', { className: 'dem-detail-main' },
1707
+ h('div', { className: 'dem-toolbar' },
1708
+ h('div', { className: 'dem-toolbar-copy' },
1709
+ h('h3', { className: 'dem-section-title' }, activeRole ? roleName(activeRole.id) : t('allNodes')),
1710
+ h('p', { className: 'dem-hint' },
1711
+ (activeRole ? roleName(activeRole.id) + ' · ' : '') + visibleHosts.length + ' ' + t('nodeCount'))),
1712
+ h('div', { className: 'dem-node-toolbar' },
1713
+ h('input', {
1714
+ className: 'dem-input dem-node-search',
1715
+ value: nodeQuery,
1716
+ placeholder: t('nodeSearchPlaceholder'),
1717
+ 'aria-label': t('nodeSearch'),
1718
+ onChange: (event) => setNodeQuery(event.target.value),
1719
+ }),
1720
+ h(Button, { onClick: () => openHost(roleId), variant: 'primary' }, t('addNode')),
1721
+ h(Button, { onClick: () => { setFormError(null); setEditing('batch'); } }, t('batchImport')))),
1722
+ editing === 'host' || editingHost ? hostForm(editingHost) : null,
1723
+ editing === 'batch' ? h('div', { className: 'dem-inline-form' },
1724
+ h('p', { className: 'dem-hint' }, t('batchImportHint')),
1725
+ h('pre', { className: 'dem-batch-example' }, t('batchPlaceholder')),
1726
+ h('textarea', {
1727
+ className: 'dem-textarea dem-textarea-sm',
1728
+ value: batchText,
1729
+ placeholder: t('batchPlaceholder'),
1730
+ rows: 6,
1731
+ 'aria-label': t('batchImport'),
1732
+ onChange: (event) => setBatchText(event.target.value),
1733
+ spellCheck: false,
1734
+ }),
1735
+ h('p', { className: 'dem-hint' }, t('batchCredentialsHint')),
1736
+ formError ? h('div', { className: 'dem-form-error' }, formError.message || String(formError)) : null,
1737
+ h('div', { className: 'dem-form-foot' },
1738
+ h(Button, { onClick: () => { setEditing(null); setFormError(null); } }, t('cancel')),
1739
+ h(Button, { onClick: submitBatch, variant: 'primary', disabled: saving }, t('importNodes')))) : null,
1740
+ visibleHosts.length
1741
+ ? visibleHosts.map((host) =>
1742
+ h('div', { key: host.id, className: 'dem-card dem-host-card' },
1743
+ h('div', { className: 'dem-host-head' },
1744
+ h('div', null,
1745
+ h('div', { className: 'dem-host-name' },
1746
+ host.name || host.id,
1747
+ host.isActive === true ? h('span', { className: 'dem-status active' }, t('active')) : null,
1748
+ host.isActive === false ? h('span', { className: 'dem-status inactive' }, t('inactive')) : null),
1749
+ h('div', { className: 'dem-host-meta' },
1750
+ h('span', null, t('ipAddress') + ': ' + (host.ip || '—') + (host.sshPort ? ':' + host.sshPort : '')),
1751
+ h('span', null, t('platform') + ': ' + (host.platform || host.os || '—')),
1752
+ h('span', null, t('adminUser') + ': ' + (host.adminUser || '—')),
1753
+ h('span', null, t('accountCount') + ': ' + (host.accounts || []).length),
1754
+ host.clusterRef ? h('span', null, t('k8sCluster') + ': ' + ((clusterById.get(host.clusterRef) || {}).name || host.clusterRef)) : null,
1755
+ host.credentialRef ? h('span', { className: 'dem-code-inline' }, t('credentialRef') + ': ' + host.credentialRef) : null)),
1756
+ h('div', { className: 'dem-actions' },
1757
+ h('div', { className: 'dem-chips' },
1758
+ h('span', { className: 'dem-chip' }, hostKindLabel(hostKindOf(host))),
1759
+ ...hostAccessOf(host).map((method) =>
1760
+ h('span', { key: method, className: 'dem-chip' }, hostAccessLabel(method))),
1761
+ ...(host.roles || []).map((role) =>
1762
+ h('span', { key: role, className: 'dem-chip' }, roleName(role)))),
1763
+ h(Button, { onClick: () => openEditHost(host) }, t('editNode')),
1764
+ h(Button, {
1765
+ onClick: () => setExpandedHost(expandedHost === host.id ? null : host.id),
1766
+ }, t('accounts') + ' (' + (host.accounts || []).length + ')'),
1767
+ h(Button, { onClick: () => { setExpandedHost(host.id); openAccount(host); } }, t('addAccount')),
1768
+ h(Button, { onClick: () => { setExpandedHost(host.id); openAccount(host, null, 'web'); } }, t('addWebLogin')))),
1769
+ expandedHost === host.id || editing === 'account:' + host.id || editingAccountFor(host)
1770
+ ? h(React.Fragment, null,
1771
+ editing === 'account:' + host.id || editingAccountFor(host)
1772
+ ? accountForm(host, editingAccountFor(host))
1773
+ : null,
1774
+ (host.accounts || []).length
1775
+ ? h('div', { className: 'dem-table-wrap' },
1776
+ h('table', { className: 'dem-table' },
1777
+ h('thead', null, h('tr', null,
1778
+ h('th', null, t('username')),
1779
+ h('th', null, t('purpose')),
1780
+ h('th', null, t('protocol')),
1781
+ h('th', null, t('authType')),
1782
+ h('th', null, t('credentialRef')),
1783
+ h('th', null, t('latestLogin')),
1784
+ h('th', null, t('source')),
1785
+ h('th', null, t('action')))),
1786
+ h('tbody', null, (host.accounts || []).map((account) =>
1787
+ h('tr', { key: account.id },
1788
+ h('td', null,
1789
+ h('strong', null, account.username || account.id),
1790
+ account.loginUrl
1791
+ ? h('a', {
1792
+ className: 'dem-link dem-account-url',
1793
+ href: account.loginUrl,
1794
+ target: '_blank',
1795
+ rel: 'noreferrer noopener',
1796
+ }, account.loginUrl)
1797
+ : null),
1798
+ h('td', null, account.purpose || '—'),
1799
+ h('td', { className: 'mono' }, (account.protocol || 'ssh').toUpperCase()),
1800
+ h('td', null, authLabel(account.authType)),
1801
+ h('td', { className: 'mono' }, account.credentialRef || '—'),
1802
+ h('td', null, account.lastLoginAt ? formatTime(account.lastLoginAt) : t('never')),
1803
+ h('td', { className: 'mono' }, account.lastLoginFrom || '—'),
1804
+ h('td', null, h(Button, { onClick: () => openAccount(host, account) }, t('editAccount'))))))))
1805
+ : h('div', { className: 'dem-table-empty' },
1806
+ t('noAccounts') + ' ',
1807
+ h(Button, { onClick: () => openAccount(host) }, t('addAccount')),
1808
+ h(Button, { onClick: () => openAccount(host, null, 'web') }, t('addWebLogin'))))
1809
+ : null))
1810
+ : h('div', { className: 'dem-card' },
1811
+ h('div', { className: 'dem-empty' },
1812
+ h('strong', null, activeRole ? t('noNodesForPlane') : t('noNodes')),
1813
+ h('span', null, activeRole ? t('noNodesForPlaneHint') : t('noNodesHint')),
1814
+ h(Button, { onClick: () => openHost(roleId), variant: 'primary' }, t('addNode')))));
1815
+ };
1816
+
1817
+ const selectedHost = hosts.find((host) => host.id === draft.hostRef);
1818
+ const loginForm = h('div', { className: 'dem-card' },
1819
+ h('div', { className: 'dem-inline-form' },
1820
+ h('div', { className: 'dem-form-grid' },
1821
+ h(Field, { label: t('loginAt'), value: draft.at || '', onChange: field('at'), type: 'datetime-local' }),
1822
+ h(Field, { label: t('loginUser'), value: draft.username || '', onChange: field('username'), placeholder: 'root' }),
1823
+ h(Select, {
1824
+ label: t('host'), value: draft.hostRef || '', onChange: (value) => {
1825
+ setDraft((current) => ({ ...current, hostRef: value, accountId: '' }));
1826
+ },
1827
+ options: [{ value: '', label: t('selectHost') }].concat(hosts.map((host) => ({ value: host.id, label: host.name || host.id }))),
1828
+ }),
1829
+ h(Select, {
1830
+ label: t('accountId'), value: draft.accountId || '', onChange: (value) => {
1831
+ const account = ((selectedHost && selectedHost.accounts) || []).find((item) => item.id === value);
1832
+ setDraft((current) => ({
1833
+ ...current,
1834
+ accountId: value,
1835
+ username: account ? account.username : current.username,
1836
+ }));
1837
+ },
1838
+ options: [{ value: '', label: '—' }].concat(((selectedHost && selectedHost.accounts) || []).map((account) => ({
1839
+ value: account.id,
1840
+ label: account.username + ' (' + account.id + ')',
1841
+ }))),
1842
+ }),
1843
+ h(Select, {
1844
+ label: t('protocol'), value: draft.protocol || 'ssh', onChange: field('protocol'),
1845
+ options: ['ssh', 'rdp', 'web', 'k8s-api', 'docker', 'custom'].map((value) => ({ value, label: value.toUpperCase() })),
1846
+ }),
1847
+ h(Field, { label: t('source'), value: draft.sourceIp || '', onChange: field('sourceIp'), placeholder: '10.0.8.21' }),
1848
+ h(Select, {
1849
+ label: t('loginResult'), value: draft.result || 'success', onChange: field('result'),
1850
+ options: [{ value: 'success', label: t('success') }, { value: 'failure', label: t('failure') }],
1851
+ }),
1852
+ h(Field, { label: t('note'), value: draft.note || '', onChange: field('note'), placeholder: t('optional') })),
1853
+ h('div', { className: 'dem-form-foot' },
1854
+ h(Button, { onClick: () => setEditing(null) }, t('cancel')),
1855
+ h(Button, { onClick: submitLogin, variant: 'primary', disabled: saving }, t('save')))));
1856
+
1857
+ const loginsView = h('div', { className: 'dem-detail-main' },
1858
+ h('div', { className: 'dem-toolbar' },
1859
+ h('div', { className: 'dem-toolbar-copy' },
1860
+ h('h3', { className: 'dem-section-title' }, t('loginRecords') + ' · ' + logins.length),
1861
+ h('p', { className: 'dem-hint' }, t('time') + ' · ' + t('result') + ' · ' + t('source'))),
1862
+ h(Button, { onClick: openLogin, variant: 'primary', disabled: editing === 'login' }, t('addLogin'))),
1863
+ editing === 'login' ? loginForm : null,
1864
+ h('div', { className: 'dem-card' },
1865
+ logins.length
1866
+ ? h('div', { className: 'dem-table-wrap' },
1867
+ h('table', { className: 'dem-table' },
1868
+ h('thead', null, h('tr', null,
1869
+ h('th', null, t('time')),
1870
+ h('th', null, t('result')),
1871
+ h('th', null, t('username')),
1872
+ h('th', null, t('host')),
1873
+ h('th', null, t('source')),
1874
+ h('th', null, t('protocol')),
1875
+ h('th', null, t('note')))),
1876
+ h('tbody', null, logins.map((login) =>
1877
+ h('tr', { key: login.id },
1878
+ h('td', { className: 'mono' }, formatTime(login.at)),
1879
+ h('td', null, h('span', { className: 'dem-status ' + (login.result === 'failure' ? 'inactive' : 'active') },
1880
+ login.result === 'failure' ? t('failure') : t('success'))),
1881
+ h('td', null, login.username),
1882
+ h('td', null, login.hostRef ? ((hostById.get(login.hostRef) || {}).name || login.hostRef) : '—'),
1883
+ h('td', { className: 'mono' }, login.sourceIp || '—'),
1884
+ h('td', { className: 'mono' }, (login.protocol || 'ssh').toUpperCase()),
1885
+ h('td', null, login.note || '—'))))))
1886
+ : h('div', { className: 'dem-empty' },
1887
+ h('strong', null, t('noLoginRecords')),
1888
+ h('span', null, t('noLoginRecordsHint')))));
1889
+
1890
+ const clusterForm = (existingCluster) => h('div', { className: 'dem-inline-form' },
1891
+ h('div', { className: 'dem-form-grid dem-form-grid-3' },
1892
+ h(Field, {
1893
+ label: t('clusterId'),
1894
+ value: draft.id || '',
1895
+ onChange: field('id'),
1896
+ placeholder: 'k8s-01',
1897
+ disabled: Boolean(existingCluster),
1898
+ }),
1899
+ h(Field, { label: t('clusterName'), value: draft.name || '', onChange: field('name'), placeholder: 'k8s-prod' }),
1900
+ h(Select, {
1901
+ label: t('clusterKind'),
1902
+ value: draft.kind || 'kubernetes',
1903
+ onChange: field('kind'),
1904
+ options: ['kubernetes', 'docker'].map((value) => ({ value, label: value.toUpperCase() })),
1905
+ }),
1906
+ draft.kind === 'docker' ? null : h(Select, {
1907
+ label: t('clusterTopology'),
1908
+ value: draft.topology || '',
1909
+ onChange: field('topology'),
1910
+ options: [{ value: '', label: '—' }].concat(CLUSTER_TOPOLOGIES.map((value) => ({ value, label: topologyLabel(value) }))),
1911
+ }),
1912
+ h(Field, { label: t('clusterApiEndpoint'), value: draft.apiEndpoint || '', onChange: field('apiEndpoint'), placeholder: 'https://10.0.1.20:6443' }),
1913
+ h(Field, { label: t('clusterNamespaces'), value: draft.namespaces || '', onChange: field('namespaces'), placeholder: 'dv-sop, business' }),
1914
+ h(Field, { label: t('credentialRef'), value: draft.credentialRef || '', onChange: field('credentialRef'), placeholder: 'cred-k8s-prod' })),
1915
+ h(TextArea, { label: t('note'), value: draft.note || '', onChange: field('note'), rows: 2, wide: true }),
1916
+ formError ? h('div', { className: 'dem-form-error' }, formError.message || String(formError)) : null,
1917
+ h('div', { className: 'dem-form-foot' },
1918
+ h(Button, { onClick: () => { setEditing(null); setFormError(null); } }, t('cancel')),
1919
+ h(Button, { onClick: () => submitCluster(existingCluster), variant: 'primary', disabled: saving }, t('save'))));
1920
+
1921
+ const editingCluster = editing && editing.startsWith('cluster-edit:')
1922
+ ? clusters.find((cluster) => cluster.id === editing.slice('cluster-edit:'.length))
1923
+ : null;
1924
+ const clusterRows = clusters.map((cluster) => {
1925
+ const nodeCount = hosts.filter((host) => host.clusterRef === cluster.id).length;
1926
+ return h('tr', { key: cluster.id },
1927
+ h('td', { className: 'mono' }, cluster.id),
1928
+ h('td', null, cluster.name),
1929
+ h('td', null, String(cluster.kind || '').toUpperCase()),
1930
+ h('td', null, cluster.kind === 'kubernetes' ? (cluster.topology ? topologyLabel(cluster.topology) : '—') : '—'),
1931
+ h('td', { className: 'mono' }, cluster.apiEndpoint || '—'),
1932
+ h('td', null, (cluster.namespaces || []).join(', ') || '—'),
1933
+ h('td', null, String(nodeCount)),
1934
+ h('td', { className: 'mono' }, cluster.credentialRef || '—'),
1935
+ h('td', null, h(Button, { onClick: () => openCluster(cluster) }, t('editCluster'))));
1936
+ });
1937
+ const clusterTable = h('div', { className: 'dem-card' },
1938
+ h('div', { className: 'dem-table-wrap' },
1939
+ h('table', { className: 'dem-table' },
1940
+ h('thead', null, h('tr', null,
1941
+ h('th', null, 'ID'),
1942
+ h('th', null, t('clusterName')),
1943
+ h('th', null, t('clusterKind')),
1944
+ h('th', null, t('clusterTopology')),
1945
+ h('th', null, t('clusterApiEndpoint')),
1946
+ h('th', null, t('clusterNamespaces')),
1947
+ h('th', null, t('clusterNodeCount')),
1948
+ h('th', null, t('credentialRef')),
1949
+ h('th', null, ''))),
1950
+ h('tbody', null, clusterRows))));
1951
+ const clustersView = h('div', { className: 'dem-detail-main' },
1952
+ h('div', { className: 'dem-toolbar' },
1953
+ h('div', { className: 'dem-toolbar-copy' },
1954
+ h('h3', { className: 'dem-section-title' }, t('clusters') + ' · ' + clusters.length),
1955
+ h('p', { className: 'dem-hint' }, t('clustersHint'))),
1956
+ h(Button, { onClick: () => openCluster(), variant: 'primary', disabled: editing === 'cluster' }, t('addCluster'))),
1957
+ editing === 'cluster' || editingCluster ? clusterForm(editingCluster) : null,
1958
+ clusters.length
1959
+ ? clusterTable
1960
+ : h('div', { className: 'dem-card' },
1961
+ h('div', { className: 'dem-empty' },
1962
+ h('strong', null, t('noClusters')),
1963
+ h('span', null, t('noClustersHint')),
1964
+ h(Button, { onClick: () => openCluster(), variant: 'primary' }, t('addCluster')))));
1965
+
1966
+ const endpointsView = endpoints.length
1967
+ ? h('div', { className: 'dem-card' },
1968
+ h('div', { className: 'dem-section-head' },
1969
+ h('h3', { className: 'dem-section-title' }, t('endpoints')),
1970
+ h('span', { className: 'dem-pill' }, String(endpoints.length))),
1971
+ h('div', { className: 'dem-table-wrap' },
1972
+ h('table', { className: 'dem-table' },
1973
+ h('thead', null, h('tr', null,
1974
+ h('th', null, 'ID'),
1975
+ h('th', null, t('kind')),
1976
+ h('th', null, t('target')),
1977
+ h('th', null, t('protocol')),
1978
+ h('th', null, t('port')),
1979
+ h('th', null, t('credentialRef')),
1980
+ h('th', null, t('note')))),
1981
+ h('tbody', null, endpoints.map((endpoint) =>
1982
+ h('tr', { key: endpoint.id },
1983
+ h('td', { className: 'mono' }, endpoint.id),
1984
+ h('td', null, (endpoint.kind || '').toUpperCase()),
1985
+ h('td', null, endpointTarget(endpoint)),
1986
+ h('td', { className: 'mono' }, endpoint.url || '—'),
1987
+ h('td', null, endpoint.port ? String(endpoint.port) : '—'),
1988
+ h('td', { className: 'mono' }, endpoint.credentialRef || '—'),
1989
+ h('td', null, endpoint.note || '—')))))))
1990
+ : h('div', { className: 'dem-card' },
1991
+ h('div', { className: 'dem-empty' },
1992
+ h('strong', null, t('noEndpoints')),
1993
+ h('span', null, t('noEndpointsHint'))));
1994
+
1995
+ const yamlView = h('div', { className: 'dem-card dem-editor' },
1996
+ h('div', { className: 'dem-editor-head' },
1997
+ h('div', null,
1998
+ h('div', { className: 'dem-editor-title' }, t('rawYaml')),
1999
+ h('div', { className: 'dem-editor-sub' }, 'environment / ' + record.id)),
2000
+ h('div', { className: 'dem-actions' },
2001
+ h(Button, { onClick: props.onSave, variant: 'primary' }, t('save')),
2002
+ h(Button, { onClick: props.onRemove, variant: 'danger' }, t('remove')))),
2003
+ h('textarea', {
2004
+ className: 'dem-textarea',
2005
+ 'aria-label': t('rawYaml'),
2006
+ value: props.yaml,
2007
+ onChange: (event) => props.onYamlChange(event.target.value),
2008
+ spellCheck: false,
2009
+ }));
2010
+
2011
+ const tabs = [
2012
+ ['overview', t('environmentOverview')],
2013
+ ['hosts', t('allNodes')],
2014
+ ...roleCatalog.map((role) => ['role:' + role.id, roleName(role.id)]),
2015
+ ['logins', t('loginRecords')],
2016
+ ['clusters', t('clusters')],
2017
+ ['endpoints', t('endpoints')],
2018
+ ['yaml', t('rawYaml')],
2019
+ ];
2020
+ const activeTab = tabs.some(([value]) => value === tab) ? tab : 'overview';
2021
+ const body = activeTab === 'overview' ? overview
2022
+ : activeTab === 'hosts' ? nodesView(null)
2023
+ : activeTab.startsWith('role:') ? nodesView(activeTab.slice('role:'.length))
2024
+ : activeTab === 'logins' ? loginsView
2025
+ : activeTab === 'clusters' ? clustersView
2026
+ : activeTab === 'endpoints' ? endpointsView
2027
+ : yamlView;
2028
+
2029
+ return h('div', { className: 'dem-detail' },
2030
+ h('div', { className: 'dem-detail-top' },
2031
+ h('button', { type: 'button', className: 'dem-back', onClick: props.onBack }, '← ' + t('backToRecords'))),
2032
+ h('div', { className: 'dem-detail-head' },
2033
+ h('div', { className: 'dem-detail-identity' },
2034
+ h('span', { className: 'dem-detail-mark' }, h(EnvManagerIcon, { size: 22 })),
2035
+ h('div', null,
2036
+ h('h2', { className: 'dem-detail-title' }, record.name || record.id),
2037
+ h('div', { className: 'dem-detail-sub' },
2038
+ h('span', { className: 'dem-detail-id' }, record.id),
2039
+ h('span', { className: 'dem-pill' }, categoryLabel(record.category)),
2040
+ h('span', { className: 'dem-status ' + (hosts.some((host) => host.isActive === true) ? 'active' : '') },
2041
+ hosts.length + ' ' + t('hostCount').toLowerCase() + ' · ' + clusters.length + ' ' + t('clusterCount').toLowerCase() + ' · ' + accounts.length + ' ' + t('accountCount').toLowerCase())))),
2042
+ h('div', { className: 'dem-actions' },
2043
+ h(Button, { onClick: () => setTab('yaml'), variant: 'primary' }, t('editYaml')),
2044
+ h(Button, { onClick: props.onRemove, variant: 'danger' }, t('remove')))),
2045
+ h('div', { className: 'dem-detail-tabs', role: 'tablist' }, tabs.map(([value, label]) =>
2046
+ h('button', {
2047
+ key: value,
2048
+ type: 'button',
2049
+ className: 'dem-detail-tab',
2050
+ role: 'tab',
2051
+ 'aria-selected': activeTab === value ? 'true' : 'false',
2052
+ onClick: () => setTab(value),
2053
+ }, label))),
2054
+ body);
2055
+ }
2056
+
2057
+ function EnvironmentWizard(props) {
2058
+ const [form, setForm] = useState({
2059
+ name: '',
2060
+ id: '',
2061
+ category: 'test',
2062
+ description: '',
2063
+ });
2064
+ const [saving, setSaving] = useState(false);
2065
+ const [error, setError] = useState(null);
2066
+ const field = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
2067
+ const roleLabel = (id) => ROLE_KEYS[id] ? t(ROLE_KEYS[id]) : ((DEFAULT_ROLES.find((role) => role.id === id) || {}).name || id);
2068
+ const envId = stableSlug(String(form.id || '').trim() || form.name, 'env');
2069
+
2070
+ const submit = async () => {
2071
+ const name = String(form.name || '').trim();
2072
+ if (!name) {
2073
+ setError({ message: t('fillEnvironmentShell') });
2074
+ return;
2075
+ }
2076
+ if ((props.existingIds || []).includes(envId)) {
2077
+ setError({ message: t('envIdExists') });
2078
+ return;
2079
+ }
2080
+ setSaving(true);
2081
+ setError(null);
2082
+ try {
2083
+ await props.onCreate({
2084
+ id: envId,
2085
+ schemaVersion: 1,
2086
+ name,
2087
+ aliases: [],
2088
+ category: form.category || 'test',
2089
+ description: String(form.description || '').trim() || undefined,
2090
+ roles: DEFAULT_ROLES.map((role) => ({ ...role })),
2091
+ hosts: [],
2092
+ clusters: [],
2093
+ endpoints: [],
2094
+ loginRecords: [],
2095
+ });
2096
+ } catch (next) {
2097
+ setError(next);
2098
+ setSaving(false);
2099
+ }
2100
+ };
2101
+
2102
+ return h('div', { className: 'dem-card dem-wizard' },
2103
+ h('div', { className: 'dem-section-head' },
2104
+ h('div', null,
2105
+ h('h3', { className: 'dem-section-title' }, t('newEnvironment')),
2106
+ h('p', { className: 'dem-hint' }, t('newEnvironmentHint')))),
2107
+ h('div', { className: 'dem-inline-form' },
2108
+ h('div', { className: 'dem-form-grid dem-form-grid-3' },
2109
+ h(Field, { label: t('name'), value: form.name, onChange: field('name'), placeholder: 'oracle-prod', required: true }),
2110
+ h(Field, { label: t('environmentIdOptional'), value: form.id, onChange: field('id'), placeholder: t('optional') }),
2111
+ h(Select, {
2112
+ label: t('category'), value: form.category, onChange: field('category'),
2113
+ options: ['dev', 'test', 'staging', 'production', 'custom'].map((value) => ({ value, label: categoryLabel(value) })),
2114
+ }),
2115
+ h(TextArea, { label: t('description'), value: form.description, onChange: field('description'), rows: 2, wide: true })),
2116
+ h('div', { className: 'dem-field' },
2117
+ h('span', { className: 'dem-label' }, t('defaultRoles')),
2118
+ h('div', { className: 'dem-chips' }, DEFAULT_ROLES.map((role) =>
2119
+ h('span', { key: role.id, className: 'dem-chip' }, roleLabel(role.id))))),
2120
+ h('p', { className: 'dem-hint' }, t('defaultRolesHint')),
2121
+ h(ErrorBox, { error }),
2122
+ h('div', { className: 'dem-form-foot' },
2123
+ h(Button, { onClick: props.onCancel, disabled: saving }, t('cancel')),
2124
+ h(Button, { onClick: submit, variant: 'primary', disabled: saving }, t('createEnvironment')))));
2125
+ }
2126
+
2127
+ function Records(props) {
2128
+ const environmentMode = props.kind === 'environment';
2129
+ const [recordKind, setRecordKind] = useState('service');
2130
+ const kind = environmentMode ? 'environment' : recordKind;
2131
+ const [items, setItems] = useState([]);
2132
+ const [selected, setSelected] = useState(null);
2133
+ const [record, setRecord] = useState(null);
2134
+ const [yaml, setYaml] = useState('');
2135
+ const [creating, setCreating] = useState(false);
2136
+ const [error, setError] = useState(null);
2137
+ const [message, setMessage] = useState(null);
2138
+
2139
+ const load = useCallback(async (nextKind) => {
2140
+ try {
2141
+ setError(null);
2142
+ const data = await call('list', { kind: nextKind });
2143
+ setItems(data.items || []);
2144
+ } catch (next) {
2145
+ setError(next);
2146
+ }
2147
+ }, []);
2148
+
2149
+ useEffect(() => {
2150
+ load(kind);
2151
+ setSelected(null);
2152
+ setRecord(null);
2153
+ setYaml('');
2154
+ setCreating(false);
2155
+ setMessage(null);
2156
+ }, [kind, load]);
2157
+
2158
+ useEffect(() => {
2159
+ if (!environmentMode || !props.createRequest) return;
2160
+ setSelected(null);
2161
+ setRecord(null);
2162
+ setYaml('');
2163
+ setError(null);
2164
+ setMessage(null);
2165
+ setCreating(true);
2166
+ if (props.onCreateConsumed) props.onCreateConsumed();
2167
+ }, [environmentMode, props.createRequest, props.onCreateConsumed]);
2168
+
2169
+ const open = async (id) => {
2170
+ try {
2171
+ setError(null);
2172
+ setMessage(null);
2173
+ const data = await call('get', { kind, id });
2174
+ setSelected(id);
2175
+ setRecord(data.record || null);
2176
+ setYaml(data.yaml);
2177
+ } catch (next) {
2178
+ setError(next);
2179
+ }
2180
+ };
2181
+
2182
+ const startNew = () => {
2183
+ setCreating(false);
2184
+ setSelected(null);
2185
+ setRecord(null);
2186
+ setYaml(TEMPLATES[kind] || '');
2187
+ setMessage(null);
2188
+ };
2189
+
2190
+ const startWizard = () => {
2191
+ setSelected(null);
2192
+ setRecord(null);
2193
+ setYaml('');
2194
+ setError(null);
2195
+ setMessage(null);
2196
+ setCreating(true);
2197
+ };
2198
+
2199
+ const saveCredential = async (ref, value) => {
2200
+ await call('credential', { ref, value });
2201
+ };
2202
+ const saveCredentialUnset = async (ref) => {
2203
+ await call('credentialUnset', { ref });
2204
+ };
2205
+
2206
+ const createEnvironment = async (nextRecord) => {
2207
+ let result;
2208
+ try {
2209
+ setError(null);
2210
+ result = await call('save', { kind: 'environment', record: nextRecord });
2211
+ } catch (next) {
2212
+ setError(next);
2213
+ throw next;
2214
+ }
2215
+ setMessage(t('environmentCreated') + ': ' + result.id);
2216
+ setCreating(false);
2217
+ await load('environment');
2218
+ setSelected(result.id);
2219
+ try {
2220
+ const fresh = await call('get', { kind: 'environment', id: result.id });
2221
+ setRecord(fresh.record || null);
2222
+ setYaml(fresh.yaml);
2223
+ } catch (next) {
2224
+ setError(next);
2225
+ }
2226
+ };
2227
+
2228
+ const closeDetail = () => {
2229
+ setSelected(null);
2230
+ setRecord(null);
2231
+ setYaml('');
2232
+ setMessage(null);
2233
+ };
2234
+
2235
+ const save = async () => {
2236
+ try {
2237
+ setError(null);
2238
+ const result = await call('save', { kind, yaml });
2239
+ setMessage(t('saved') + ': ' + result.id);
2240
+ setSelected(result.id);
2241
+ if (kind === 'environment') {
2242
+ const fresh = await call('get', { kind, id: result.id });
2243
+ setRecord(fresh.record || null);
2244
+ setYaml(fresh.yaml);
2245
+ }
2246
+ await load(kind);
2247
+ } catch (next) {
2248
+ setError(next);
2249
+ }
2250
+ };
2251
+
2252
+ const saveRecord = async (nextRecord) => {
2253
+ try {
2254
+ setError(null);
2255
+ const result = await call('save', { kind: 'environment', record: nextRecord });
2256
+ setMessage(t('saved') + ': ' + result.id);
2257
+ setSelected(result.id);
2258
+ const fresh = await call('get', { kind: 'environment', id: result.id });
2259
+ setRecord(fresh.record || null);
2260
+ setYaml(fresh.yaml);
2261
+ await load('environment');
2262
+ } catch (next) {
2263
+ setError(next);
2264
+ throw next;
2265
+ }
2266
+ };
2267
+
2268
+ const remove = async () => {
2269
+ if (!selected) return;
2270
+ if (!window.confirm(t('confirmDelete'))) return;
2271
+ try {
2272
+ setError(null);
2273
+ await call('remove', { kind, id: selected });
2274
+ setMessage(t('deleted') + ': ' + selected);
2275
+ setSelected(null);
2276
+ setRecord(null);
2277
+ setYaml('');
2278
+ await load(kind);
2279
+ } catch (next) {
2280
+ setError(next);
2281
+ }
2282
+ };
2283
+
2284
+ return h('div', { className: 'dem-view' },
2285
+ h('div', { className: 'dem-toolbar' },
2286
+ h('div', { className: 'dem-toolbar-copy' },
2287
+ h('h2', { className: 'dem-section-title' }, t(environmentMode ? 'environments' : 'records')),
2288
+ h('p', { className: 'dem-hint' }, t(environmentMode ? 'environmentsHint' : 'recordsHint'))),
2289
+ h('div', { className: 'dem-actions' },
2290
+ environmentMode ? null : h(Button, { onClick: startNew }, t('newRecord')),
2291
+ h(Button, { onClick: () => load(kind) }, t('refresh')))),
2292
+ environmentMode ? null : h('div', { className: 'dem-subtabs', role: 'group' }, RECORD_KINDS.map((item) =>
2293
+ h('button', {
2294
+ key: item,
2295
+ type: 'button',
2296
+ className: 'dem-subtab',
2297
+ 'aria-pressed': kind === item ? 'true' : 'false',
2298
+ onClick: () => setRecordKind(item),
2299
+ }, t(KIND_LABEL[item])))),
2300
+ error ? h(ErrorBox, { error }) : null,
2301
+ h(Toast, { message }),
2302
+ kind === 'environment' && selected && record
2303
+ ? h(EnvironmentDetail, {
2304
+ record,
2305
+ yaml,
2306
+ onBack: closeDetail,
2307
+ onYamlChange: setYaml,
2308
+ onSave: save,
2309
+ onSaveRecord: saveRecord,
2310
+ onRemove: remove,
2311
+ onCredential: saveCredential,
2312
+ onCredentialUnset: saveCredentialUnset,
2313
+ onMessage: setMessage,
2314
+ })
2315
+ : kind === 'environment' && creating
2316
+ ? h(EnvironmentWizard, {
2317
+ existingIds: items.map((item) => item.id),
2318
+ onCreate: createEnvironment,
2319
+ onCancel: () => setCreating(false),
2320
+ })
2321
+ : h('div', { className: 'dem-record-layout' },
2322
+ h('div', { className: 'dem-card dem-list-card' },
2323
+ h('div', { className: 'dem-list-head' },
2324
+ h('span', { className: 'dem-list-title' }, t(KIND_LABEL[kind])),
2325
+ h('span', null, String(items.length))),
2326
+ items.length === 0
2327
+ ? h('div', { className: 'dem-empty' },
2328
+ h('strong', null, t(environmentMode ? 'emptyEnvironments' : 'emptyRecords')),
2329
+ h('span', null, t(environmentMode ? 'emptyEnvironmentsHint' : 'emptyRecordsHint')))
2330
+ : items.map((item) =>
2331
+ h('button', {
2332
+ key: item.id,
2333
+ type: 'button',
2334
+ className: 'dem-list-item',
2335
+ 'data-active': selected === item.id ? 'true' : 'false',
2336
+ onClick: () => open(item.id),
2337
+ title: item.id,
2338
+ },
2339
+ h('span', { className: 'dem-item-name' }, item.name || item.id),
2340
+ h('span', { className: 'dem-item-meta' }, item.id + (item.summary ? ' · ' + item.summary : ''))))),
2341
+ selected || yaml
2342
+ ? h('div', { className: 'dem-card dem-editor' },
2343
+ h('div', { className: 'dem-editor-head' },
2344
+ h('div', null,
2345
+ h('div', { className: 'dem-editor-title' }, selected || t('newRecord')),
2346
+ selected ? h('div', { className: 'dem-editor-sub' }, kind + ' / ' + selected) : null),
2347
+ h('div', { className: 'dem-actions' },
2348
+ h(Button, { onClick: save, variant: 'primary' }, t('save')),
2349
+ selected ? h(Button, { onClick: remove, variant: 'danger' }, t('remove')) : null)),
2350
+ h('textarea', {
2351
+ className: 'dem-textarea',
2352
+ 'aria-label': t('yaml'),
2353
+ value: yaml,
2354
+ onChange: (event) => setYaml(event.target.value),
2355
+ spellCheck: false,
2356
+ }))
2357
+ : h('div', { className: 'dem-card dem-empty-editor' },
2358
+ h('div', { className: 'dem-empty' },
2359
+ h('strong', null, t('selectRecord')),
2360
+ h('span', null, t(environmentMode ? 'selectEnvironmentHint' : 'selectRecordHint')),
2361
+ h(Button, { onClick: environmentMode ? startWizard : startNew, variant: 'primary' },
2362
+ environmentMode ? t('newEnvironment') : t('newRecord'))))));
2363
+ }
2364
+
2365
+ function ContextView() {
2366
+ const [environmentId, setEnvironmentId] = useState('');
2367
+ const [serviceId, setServiceId] = useState('');
2368
+ const [deploymentId, setDeploymentId] = useState('');
2369
+ const [result, setResult] = useState(null);
2370
+ const [error, setError] = useState(null);
2371
+ const run = async () => {
2372
+ try {
2373
+ setError(null);
2374
+ setResult(await call('resolve', { environmentId, serviceId, deploymentId: deploymentId || undefined }));
2375
+ } catch (next) {
2376
+ setError(next);
2377
+ }
2378
+ };
2379
+ return h('div', { className: 'dem-view' },
2380
+ h('div', { className: 'dem-toolbar' },
2381
+ h('div', { className: 'dem-toolbar-copy' },
2382
+ h('h2', { className: 'dem-section-title' }, t('context')),
2383
+ h('p', { className: 'dem-hint' }, t('contextHint')))),
2384
+ error ? h(ErrorBox, { error }) : null,
2385
+ h('div', { className: 'dem-card' },
2386
+ h('div', { className: 'dem-form-grid' },
2387
+ h(Field, { label: t('environmentId'), value: environmentId, onChange: setEnvironmentId, placeholder: 'test-a' }),
2388
+ h(Field, { label: t('serviceId'), value: serviceId, onChange: setServiceId, placeholder: 'aicoservice' }),
2389
+ h(Field, { label: t('deploymentId'), value: deploymentId, onChange: setDeploymentId, placeholder: 'deploy-001' })),
2390
+ h('div', { className: 'dem-form-actions' },
2391
+ h(Button, { onClick: run, variant: 'primary' }, t('resolve')))),
2392
+ result
2393
+ ? h('div', { className: 'dem-card' },
2394
+ h('div', { className: 'dem-section-head' }, h('h3', { className: 'dem-section-title' }, t('result'))),
2395
+ h('pre', { className: 'dem-result' }, JSON.stringify(result.context || result, null, 2)))
2396
+ : h('div', { className: 'dem-card' }, h('div', { className: 'dem-empty' }, h('span', null, t('emptyResult')))));
2397
+ }
2398
+
2399
+ function OperationsView() {
2400
+ const [operation, setOperation] = useState('redeploy');
2401
+ const [environmentId, setEnvironmentId] = useState('');
2402
+ const [serviceId, setServiceId] = useState('');
2403
+ const [deploymentId, setDeploymentId] = useState('');
2404
+ const [result, setResult] = useState(null);
2405
+ const [error, setError] = useState(null);
2406
+ const run = async () => {
2407
+ try {
2408
+ setError(null);
2409
+ setResult(await call('route', { operation, environmentId, serviceId, deploymentId: deploymentId || undefined }));
2410
+ } catch (next) {
2411
+ setError(next);
2412
+ }
2413
+ };
2414
+ return h('div', { className: 'dem-view' },
2415
+ h('div', { className: 'dem-toolbar' },
2416
+ h('div', { className: 'dem-toolbar-copy' },
2417
+ h('h2', { className: 'dem-section-title' }, t('operations')),
2418
+ h('p', { className: 'dem-hint' }, t('operationsHint')))),
2419
+ error ? h(ErrorBox, { error }) : null,
2420
+ h('div', { className: 'dem-card' },
2421
+ h('div', { className: 'dem-form-grid' },
2422
+ h(Field, { label: t('operation'), value: operation, onChange: setOperation, placeholder: 'redeploy' }),
2423
+ h(Field, { label: t('environmentId'), value: environmentId, onChange: setEnvironmentId, placeholder: 'test-a' }),
2424
+ h(Field, { label: t('serviceId'), value: serviceId, onChange: setServiceId, placeholder: 'aicoservice' }),
2425
+ h(Field, { label: t('deploymentId'), value: deploymentId, onChange: setDeploymentId, placeholder: 'deploy-001' })),
2426
+ h('div', { className: 'dem-form-actions' },
2427
+ h(Button, { onClick: run, variant: 'primary' }, t('route')))),
2428
+ result
2429
+ ? h('div', { className: 'dem-card' },
2430
+ h('div', { className: 'dem-section-head' }, h('h3', { className: 'dem-section-title' }, t('result'))),
2431
+ h('pre', { className: 'dem-result' }, JSON.stringify(result, null, 2)))
2432
+ : h('div', { className: 'dem-card' }, h('div', { className: 'dem-empty' }, h('span', null, t('emptyRoute')))));
2433
+ }
2434
+
2435
+ function EnvManagerIcon(props) {
2436
+ const size =
2437
+ typeof props?.size === 'number' && Number.isFinite(props.size) && props.size > 0
2438
+ ? props.size
2439
+ : 20;
2440
+ return h('svg', {
2441
+ viewBox: '0 0 20 20',
2442
+ width: size,
2443
+ height: size,
2444
+ fill: 'none',
2445
+ 'aria-hidden': true,
2446
+ style: {
2447
+ display: 'block',
2448
+ opacity: props?.active === false ? 0.72 : 1,
2449
+ color: 'currentColor',
2450
+ },
2451
+ }, [
2452
+ h('rect', {
2453
+ key: 'top',
2454
+ x: 2.6,
2455
+ y: 3.2,
2456
+ width: 14.8,
2457
+ height: 5.4,
2458
+ rx: 1.6,
2459
+ stroke: 'currentColor',
2460
+ strokeWidth: 1.3,
2461
+ }),
2462
+ h('rect', {
2463
+ key: 'bottom',
2464
+ x: 2.6,
2465
+ y: 11.4,
2466
+ width: 14.8,
2467
+ height: 5.4,
2468
+ rx: 1.6,
2469
+ stroke: 'currentColor',
2470
+ strokeWidth: 1.3,
2471
+ }),
2472
+ h('circle', { key: 'top-dot', cx: 5.7, cy: 5.9, r: 0.9, fill: 'currentColor' }),
2473
+ h('circle', { key: 'bottom-dot', cx: 5.7, cy: 14.1, r: 0.9, fill: 'currentColor' }),
2474
+ h('path', {
2475
+ key: 'top-line',
2476
+ d: 'M8.6 5.9h5.8',
2477
+ stroke: 'currentColor',
2478
+ strokeWidth: 1.3,
2479
+ strokeLinecap: 'round',
2480
+ }),
2481
+ h('path', {
2482
+ key: 'bottom-line',
2483
+ d: 'M8.6 14.1h5.8',
2484
+ stroke: 'currentColor',
2485
+ strokeWidth: 1.3,
2486
+ strokeLinecap: 'round',
2487
+ }),
2488
+ ]);
2489
+ }
2490
+
2491
+ function Panel() {
2492
+ const [tab, setTab] = useState('environments');
2493
+ const [createRequest, setCreateRequest] = useState(0);
2494
+ const tabs = useMemo(() => [
2495
+ ['environments', t('environments')],
2496
+ ['overview', t('overview')],
2497
+ ['records', t('records')],
2498
+ ['context', t('context')],
2499
+ ['operations', t('operations')],
2500
+ ], []);
2501
+ const requestCreate = () => {
2502
+ setTab('environments');
2503
+ setCreateRequest((value) => value + 1);
2504
+ };
2505
+ return h('div', { className: 'dem-root' },
2506
+ h('div', { className: 'dem-shell' },
2507
+ h('header', { className: 'dem-header' },
2508
+ h('div', { className: 'dem-heading' },
2509
+ h('div', { className: 'dem-mark' }, h(EnvManagerIcon, { size: 22, active: true })),
2510
+ h('div', { className: 'dem-heading-copy' },
2511
+ h('h1', { className: 'dem-title' }, t('title')),
2512
+ h('p', { className: 'dem-sub' }, t('subtitle')))),
2513
+ h('div', { className: 'dem-header-actions' },
2514
+ h(Button, { onClick: requestCreate, variant: 'primary' }, t('newEnvironment')))),
2515
+ h('div', { className: 'dem-tabs', role: 'tablist' }, tabs.map(([key, label]) =>
2516
+ h('button', {
2517
+ key,
2518
+ type: 'button',
2519
+ role: 'tab',
2520
+ className: 'dem-tab',
2521
+ 'aria-selected': tab === key ? 'true' : 'false',
2522
+ onClick: () => setTab(key),
2523
+ }, label))),
2524
+ tab === 'overview' ? h(Overview, { onCreateEnvironment: requestCreate }) : null,
2525
+ tab === 'environments' ? h(Records, {
2526
+ kind: 'environment',
2527
+ createRequest,
2528
+ onCreateConsumed: () => setCreateRequest(0),
2529
+ }) : null,
2530
+ tab === 'records' ? h(Records) : null,
2531
+ tab === 'context' ? h(ContextView) : null,
2532
+ tab === 'operations' ? h(OperationsView) : null));
2533
+ }
2534
+
2535
+ return {
2536
+ inject: ['slots', 'locale'],
2537
+ apply(ctx) {
2538
+ ensureStyles();
2539
+ try {
2540
+ if (ctx.locale && ctx.locale.register) ctx.locale.register('envManager', MESSAGES);
2541
+ } catch (error) {
2542
+ // Locale registration is best-effort; the panel has its own fallback.
2543
+ }
2544
+ ctx.slots.inject('sidebar.panellist', () => {
2545
+ try {
2546
+ return ctx.slots.register({
2547
+ name: 'sidebar.panellist',
2548
+ id: 'dsh-env-manager',
2549
+ order: 40,
2550
+ label: () => {
2551
+ let active = lang;
2552
+ try {
2553
+ const id = String(ctx.locale?.getLocale?.().active || '');
2554
+ if (id) active = id.toLowerCase().startsWith('zh') ? 'zh' : 'en';
2555
+ } catch (error) {
2556
+ // Keep the browser-derived fallback.
2557
+ }
2558
+ return MESSAGES[active].title;
2559
+ },
2560
+ }, EnvManagerIcon);
2561
+ } catch (error) {
2562
+ console.warn('[dsh-env-manager] sidebar entry unavailable:', error);
2563
+ return () => undefined;
2564
+ }
2565
+ });
2566
+ ctx.slots.inject('main', () => {
2567
+ try {
2568
+ return ctx.slots.register({
2569
+ name: 'main',
2570
+ key: 'dsh-env-manager',
2571
+ inject: () => ({}),
2572
+ }, Panel);
2573
+ } catch (error) {
2574
+ console.warn('[dsh-env-manager] main panel unavailable:', error);
2575
+ return () => undefined;
2576
+ }
2577
+ });
2578
+ },
2579
+ };
2580
+ },
2581
+ });